Skip to content

Commit 63a4b8a

Browse files
authored
Merge pull request #5 from constructive-io/devin/1773779860-update-deps-command
feat: add update-deps command for cross-repo dependency sync
2 parents d93683f + 0d51d4c commit 63a4b8a

4 files changed

Lines changed: 414 additions & 0 deletions

File tree

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import fs from 'node:fs/promises';
2+
import { glob } from 'glob';
3+
import { runUpdateDeps } from '../src/commands/updateDeps';
4+
5+
jest.mock('node:fs/promises');
6+
jest.mock('glob');
7+
8+
const mockedFs = fs as jest.Mocked<typeof fs>;
9+
const mockedGlob = glob as jest.MockedFunction<typeof glob>;
10+
11+
// Helpers to build package.json strings
12+
function makePkg(name: string, version: string, deps?: Record<string, string>, devDeps?: Record<string, string>) {
13+
const pkg: Record<string, unknown> = { name, version };
14+
if (deps) pkg.dependencies = deps;
15+
if (devDeps) pkg.devDependencies = devDeps;
16+
return JSON.stringify(pkg);
17+
}
18+
19+
const WORKSPACE_YAML = `packages:\n - 'packages/*'\n - 'graphile/*'\n`;
20+
21+
describe('runUpdateDeps', () => {
22+
let consoleLogSpy: jest.SpyInstance;
23+
let consoleErrorSpy: jest.SpyInstance;
24+
25+
beforeEach(() => {
26+
jest.clearAllMocks();
27+
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
28+
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
29+
});
30+
31+
afterEach(() => {
32+
consoleLogSpy.mockRestore();
33+
consoleErrorSpy.mockRestore();
34+
});
35+
36+
it('should throw if --from is missing', async () => {
37+
await expect(runUpdateDeps(['--in', '/target'])).rejects.toThrow('Missing required argument: --from');
38+
});
39+
40+
it('should throw if --in is missing', async () => {
41+
await expect(runUpdateDeps(['--from', '/source'])).rejects.toThrow('Missing required argument: --in');
42+
});
43+
44+
it('should discover source packages and match against target', async () => {
45+
// Source workspace
46+
mockedFs.readFile.mockImplementation(async (filePath: any) => {
47+
const p = filePath.toString();
48+
if (p.endsWith('pnpm-workspace.yaml') && p.includes('source')) {
49+
return WORKSPACE_YAML;
50+
}
51+
if (p.endsWith('pnpm-workspace.yaml') && p.includes('target')) {
52+
return `packages:\n - 'application/*'\n`;
53+
}
54+
// Source packages
55+
if (p.includes('source') && p.includes('packages/foo/package.json')) {
56+
return makePkg('@scope/foo', '2.0.0');
57+
}
58+
if (p.includes('source') && p.includes('graphile/bar/package.json')) {
59+
return makePkg('graphile-bar', '1.5.0');
60+
}
61+
// Target packages
62+
if (p.includes('target') && p.includes('package.json') && p.includes('application/myapp')) {
63+
return makePkg('myapp', '1.0.0', {
64+
'@scope/foo': '^1.0.0',
65+
'graphile-bar': '^1.5.0',
66+
'unrelated-pkg': '^3.0.0'
67+
});
68+
}
69+
if (p.includes('target') && p.endsWith('package.json') && !p.includes('application')) {
70+
return makePkg('target-root', '1.0.0', {
71+
'graphile-bar': '^1.3.0'
72+
});
73+
}
74+
throw new Error(`ENOENT: ${p}`);
75+
});
76+
77+
mockedGlob.mockImplementation(async (patterns: any, opts: any) => {
78+
const cwd = opts?.cwd || '';
79+
if (cwd.includes('source')) {
80+
return ['packages/foo/package.json', 'graphile/bar/package.json'];
81+
}
82+
if (cwd.includes('target')) {
83+
return ['application/myapp/package.json'];
84+
}
85+
return [];
86+
});
87+
88+
const result = await runUpdateDeps(['--from', '/source', '--in', '/target']);
89+
90+
// Should find 2 source packages
91+
expect(result.sourcePackages).toHaveLength(2);
92+
expect(result.sourcePackages.map(p => p.name).sort()).toEqual(['@scope/foo', 'graphile-bar']);
93+
94+
// Should match 3 deps (foo in myapp, bar in myapp, bar in root)
95+
expect(result.matchedPackages).toHaveLength(3);
96+
const matchedNames = result.matchedPackages.map(p => p.name);
97+
expect(matchedNames).toContain('@scope/foo');
98+
expect(matchedNames).toContain('graphile-bar');
99+
100+
// @scope/foo ^1.0.0 -> 2.0.0 is outdated
101+
const fooMatch = result.matchedPackages.find(p => p.name === '@scope/foo');
102+
expect(fooMatch?.outdated).toBe(true);
103+
expect(fooMatch?.currentVersion).toBe('^1.0.0');
104+
expect(fooMatch?.availableVersion).toBe('2.0.0');
105+
106+
// graphile-bar ^1.5.0 -> 1.5.0 is NOT outdated (same version)
107+
const barMatchApp = result.matchedPackages.find(p => p.name === 'graphile-bar' && p.consumer === 'myapp');
108+
expect(barMatchApp?.outdated).toBe(false);
109+
110+
// graphile-bar ^1.3.0 -> 1.5.0 IS outdated
111+
const barMatchRoot = result.matchedPackages.find(p => p.name === 'graphile-bar' && p.consumer === 'target-root');
112+
expect(barMatchRoot?.outdated).toBe(true);
113+
114+
// Overall: has changes
115+
expect(result.has_dep_changes).toBe(true);
116+
expect(result.outdatedPackages).toHaveLength(2);
117+
118+
// JSON output was written to stdout
119+
expect(consoleLogSpy).toHaveBeenCalled();
120+
const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
121+
expect(output.has_dep_changes).toBe(true);
122+
});
123+
124+
it('should report no changes when all deps are up to date', async () => {
125+
mockedFs.readFile.mockImplementation(async (filePath: any) => {
126+
const p = filePath.toString();
127+
if (p.endsWith('pnpm-workspace.yaml') && p.includes('source')) {
128+
return WORKSPACE_YAML;
129+
}
130+
if (p.endsWith('pnpm-workspace.yaml') && p.includes('target')) {
131+
throw new Error('ENOENT');
132+
}
133+
if (p.includes('source') && p.includes('packages/foo/package.json')) {
134+
return makePkg('@scope/foo', '2.0.0');
135+
}
136+
// Target root package.json
137+
if (p.includes('target') && p.endsWith('package.json')) {
138+
return makePkg('target', '1.0.0', { '@scope/foo': '^2.0.0' });
139+
}
140+
throw new Error(`ENOENT: ${p}`);
141+
});
142+
143+
mockedGlob.mockImplementation(async (patterns: any, opts: any) => {
144+
const cwd = opts?.cwd || '';
145+
if (cwd.includes('source')) {
146+
return ['packages/foo/package.json'];
147+
}
148+
return [];
149+
});
150+
151+
const result = await runUpdateDeps(['--from', '/source', '--in', '/target']);
152+
153+
expect(result.matchedPackages).toHaveLength(1);
154+
expect(result.outdatedPackages).toHaveLength(0);
155+
expect(result.has_dep_changes).toBe(false);
156+
});
157+
158+
it('should handle workspace: protocol as not outdated', async () => {
159+
mockedFs.readFile.mockImplementation(async (filePath: any) => {
160+
const p = filePath.toString();
161+
if (p.endsWith('pnpm-workspace.yaml') && p.includes('source')) {
162+
return WORKSPACE_YAML;
163+
}
164+
if (p.endsWith('pnpm-workspace.yaml') && p.includes('target')) {
165+
throw new Error('ENOENT');
166+
}
167+
if (p.includes('source') && p.includes('packages/foo/package.json')) {
168+
return makePkg('@scope/foo', '5.0.0');
169+
}
170+
if (p.includes('target') && p.endsWith('package.json')) {
171+
return makePkg('target', '1.0.0', { '@scope/foo': 'workspace:*' });
172+
}
173+
throw new Error(`ENOENT: ${p}`);
174+
});
175+
176+
mockedGlob.mockImplementation(async (patterns: any, opts: any) => {
177+
const cwd = opts?.cwd || '';
178+
if (cwd.includes('source')) {
179+
return ['packages/foo/package.json'];
180+
}
181+
return [];
182+
});
183+
184+
const result = await runUpdateDeps(['--from', '/source', '--in', '/target']);
185+
186+
expect(result.matchedPackages).toHaveLength(1);
187+
expect(result.matchedPackages[0].outdated).toBe(false);
188+
expect(result.has_dep_changes).toBe(false);
189+
});
190+
});

packages/makage/src/cli.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { runAssets } from './commands/assets';
66
import { runBuild } from './commands/build';
77
import { runBuildTs } from './commands/buildTs';
88
import { runUpdateWorkspace } from './commands/updateWorkspace';
9+
import { runUpdateDeps } from './commands/updateDeps';
910

1011
const [, , cmd, ...rest] = process.argv;
1112

@@ -33,6 +34,9 @@ async function main() {
3334
case 'update-workspace':
3435
await runUpdateWorkspace(rest);
3536
break;
37+
case 'update-deps':
38+
await runUpdateDeps(rest);
39+
break;
3640
case '-h':
3741
case '--help':
3842
default:
@@ -57,6 +61,7 @@ Usage:
5761
makage assets
5862
makage build-ts [--dev]
5963
makage update-workspace
64+
makage update-deps --from <source-workspace> --in <target-repo>
6065
`);
6166
}
6267

0 commit comments

Comments
 (0)