Skip to content

Commit 13d99aa

Browse files
authored
Add type devDependencies during init scaffolding (#107)
* Add type devDependencies during init scaffolding * Add TypeScript dev dependency to init scaffolding * Address PR feedback for init dependency typing
1 parent c74b433 commit 13d99aa

4 files changed

Lines changed: 125 additions & 13 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
.npmrc
2+
.DS_Store
23
node_modules/
34
dist/
45
cli/lib/

cli/src/commands/everywhere/init.ts

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,44 @@ const THIS_DIR = path.dirname(fileURLToPath(import.meta.url));
1313
// init.ts compiles to cli/dist/commands/everywhere/init.js, so the SDK's root
1414
// package.json is four levels up. Both dev and published layouts match.
1515
const SDK_PKG_PATH = path.resolve(THIS_DIR, '../../../../package.json');
16+
const TYPE_DEFINITIONS_BY_DEP: Record<string, string> = {
17+
react: '@types/react',
18+
'react-dom': '@types/react-dom',
19+
};
20+
const DEFAULT_DEV_DEPENDENCIES: Record<string, string> = {
21+
typescript: '^5',
22+
};
23+
24+
type InitPackageJson = {
25+
name?: string;
26+
version?: string;
27+
title?: string;
28+
dependencies?: Record<string, string>;
29+
devDependencies?: Record<string, string>;
30+
};
31+
32+
type InitPackageJsonWithDependencies = InitPackageJson & {
33+
dependencies: Record<string, string>;
34+
};
1635

1736
function getSdkVersion(): string {
1837
const pkg = JSON.parse(fs.readFileSync(SDK_PKG_PATH, 'utf-8')) as { version: string };
1938
return pkg.version;
2039
}
2140

41+
export const resolveTypeDevDependencies = (
42+
desiredDeps: Record<string, string>
43+
): Record<string, string> => {
44+
const desiredTypeDeps: Record<string, string> = { ...DEFAULT_DEV_DEPENDENCIES };
45+
for (const depName of Object.keys(desiredDeps)) {
46+
const typePackageName = TYPE_DEFINITIONS_BY_DEP[depName];
47+
if (typePackageName) {
48+
desiredTypeDeps[typePackageName] = desiredDeps[depName];
49+
}
50+
}
51+
return desiredTypeDeps;
52+
};
53+
2254
// On Windows, npm is a `.cmd` shim that requires the explicit extension when
2355
// spawned without a shell. Avoid `shell: true` because Node deprecates passing
2456
// args alongside it (DEP0190).
@@ -119,12 +151,7 @@ export default class InitCommand extends EverywhereBaseCommand {
119151
}
120152

121153
// Pre-check 2: package.json parses and has a name
122-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as {
123-
name?: string;
124-
version?: string;
125-
title?: string;
126-
dependencies?: Record<string, string>;
127-
};
154+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as InitPackageJson;
128155
if (!pkg.name || typeof pkg.name !== 'string') {
129156
this.error('package.json must have a "name" field.');
130157
}
@@ -153,13 +180,14 @@ export default class InitCommand extends EverywhereBaseCommand {
153180
react: '^19',
154181
'react-dom': '^19',
155182
};
183+
const desiredDevDeps = resolveTypeDevDependencies(desiredDeps);
156184
const existingDeps: Record<string, string> = pkg.dependencies ?? {};
185+
const existingDevDeps: Record<string, string> = pkg.devDependencies ?? {};
157186
const added: Array<{ name: string; version: string }> = [];
158-
const skipped: Array<{ name: string; existingVersion: string }> = [];
187+
const addedDevDeps: Array<{ name: string; version: string }> = [];
159188

160189
for (const [name, version] of Object.entries(desiredDeps)) {
161190
if (name in existingDeps) {
162-
skipped.push({ name, existingVersion: existingDeps[name] });
163191
if (verbose) {
164192
this.log(
165193
`Dependency already present: ${name} (keeping ${chalk.dim(existingDeps[name])})`
@@ -173,12 +201,38 @@ export default class InitCommand extends EverywhereBaseCommand {
173201
}
174202
}
175203

204+
for (const [name, version] of Object.entries(desiredDevDeps)) {
205+
if (name in existingDevDeps || name in existingDeps) {
206+
if (verbose) {
207+
const source = name in existingDevDeps ? 'devDependencies' : 'dependencies';
208+
const existingVersion = existingDevDeps[name] ?? existingDeps[name];
209+
this.log(
210+
`Type dependency already present in ${source}: ${name} (keeping ${chalk.dim(existingVersion)})`
211+
);
212+
}
213+
} else {
214+
addedDevDeps.push({ name, version });
215+
if (verbose) {
216+
this.log(`Adding dev dependency: ${name} ${chalk.dim(version)}`);
217+
}
218+
}
219+
}
220+
176221
// Mutation 1: write package.json if anything was added
177-
if (added.length > 0 || title) {
178-
const newPkg = { ...pkg, dependencies: { ...existingDeps } };
222+
if (added.length > 0 || addedDevDeps.length > 0 || title) {
223+
const newPkg: InitPackageJsonWithDependencies = {
224+
...pkg,
225+
dependencies: { ...existingDeps },
226+
};
179227
for (const { name, version } of added) {
180228
newPkg.dependencies[name] = version;
181229
}
230+
if (addedDevDeps.length > 0 || pkg.devDependencies) {
231+
newPkg.devDependencies = { ...existingDevDeps };
232+
for (const { name, version } of addedDevDeps) {
233+
newPkg.devDependencies[name] = version;
234+
}
235+
}
182236
if (title) {
183237
newPkg.title = title;
184238
}

cli/tests/commands/everywhere/init.test.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2-
import InitCommand from '../../../src/commands/everywhere/init.js';
2+
import InitCommand, { resolveTypeDevDependencies } from '../../../src/commands/everywhere/init.js';
33
import EverywhereBaseCommand from '../../../src/lib/command.js';
44

55
describe('everywhere init', () => {
@@ -51,6 +51,47 @@ describe('everywhere init', () => {
5151
});
5252
});
5353

54+
describe('resolveTypeDevDependencies', () => {
55+
describe('when desired dependencies use non-default versions', () => {
56+
it('aligns @types package versions with desired runtime dependency versions', () => {
57+
expect(
58+
resolveTypeDevDependencies({
59+
react: '^18.3.1',
60+
'react-dom': '^18.3.1',
61+
})
62+
).toEqual({
63+
typescript: '^5',
64+
'@types/react': '^18.3.1',
65+
'@types/react-dom': '^18.3.1',
66+
});
67+
});
68+
});
69+
70+
describe('when desired dependencies include react and react-dom', () => {
71+
it('returns @types packages for both dependencies', () => {
72+
expect(
73+
resolveTypeDevDependencies({
74+
react: '^19',
75+
'react-dom': '^19',
76+
'@workday/everywhere': '^1.0.0',
77+
})
78+
).toEqual({
79+
typescript: '^5',
80+
'@types/react': '^19',
81+
'@types/react-dom': '^19',
82+
});
83+
});
84+
});
85+
86+
describe('when desired dependencies have no mapped types package', () => {
87+
it('returns only the default development dependencies', () => {
88+
expect(resolveTypeDevDependencies({ '@workday/everywhere': '^1.0.0' })).toEqual({
89+
typescript: '^5',
90+
});
91+
});
92+
});
93+
});
94+
5495
describe('runNpmInstall', () => {
5596
beforeEach(() => {
5697
vi.resetModules();
@@ -117,7 +158,9 @@ describe('runNpmInstall', () => {
117158
const { runNpmInstall } = await import('../../../src/commands/everywhere/init.js');
118159
await runNpmInstall('/fake/dir');
119160

120-
const opts = mockSpawn.mock.calls[0][2] as { shell?: boolean };
161+
const firstCall = mockSpawn.mock.calls[0];
162+
expect(firstCall).toBeDefined();
163+
const opts = firstCall?.[2] as { shell?: boolean };
121164
expect(opts.shell).not.toBe(true);
122165
});
123166
});
@@ -304,7 +347,9 @@ describe('runNpmInit', () => {
304347
const { runNpmInit } = await import('../../../src/commands/everywhere/init.js');
305348
await runNpmInit('/fake/dir');
306349

307-
const opts = mockSpawn.mock.calls[0][2] as { shell?: boolean };
350+
const firstCall = mockSpawn.mock.calls[0];
351+
expect(firstCall).toBeDefined();
352+
const opts = firstCall?.[2] as { shell?: boolean };
308353
expect(opts.shell).not.toBe(true);
309354
});
310355
});

cli/tests/tsconfig.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"extends": "../tsconfig.json",
3+
"compilerOptions": {
4+
"rootDir": "..",
5+
"noEmit": true,
6+
"declaration": false,
7+
"sourceMap": false,
8+
"inlineSources": false,
9+
"types": ["node", "vitest/globals"]
10+
},
11+
"include": ["./**/*.ts", "./**/*.tsx", "../src/**/*.ts", "../src/**/*.tsx"]
12+
}

0 commit comments

Comments
 (0)