@smoothbricks/cli 0.10.2 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +25 -13
  2. package/dist/cli.js +24 -0
  3. package/dist/github-ci/index.d.ts +17 -2
  4. package/dist/github-ci/index.d.ts.map +1 -1
  5. package/dist/github-ci/index.js +141 -11
  6. package/dist/github-ci/outputs.d.ts +22 -0
  7. package/dist/github-ci/outputs.d.ts.map +1 -0
  8. package/dist/github-ci/outputs.js +649 -0
  9. package/dist/monorepo/ci-workflow.js +1 -1
  10. package/dist/monorepo/managed-files.d.ts +1 -0
  11. package/dist/monorepo/managed-files.d.ts.map +1 -1
  12. package/dist/monorepo/managed-files.js +38 -0
  13. package/dist/monorepo/package-policy.d.ts.map +1 -1
  14. package/dist/monorepo/package-policy.js +26 -0
  15. package/dist/monorepo/packs/index.d.ts +3 -0
  16. package/dist/monorepo/packs/index.d.ts.map +1 -1
  17. package/dist/monorepo/packs/index.js +5 -2
  18. package/dist/monorepo/publish-workflow.d.ts +1 -0
  19. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  20. package/dist/monorepo/publish-workflow.js +239 -2
  21. package/dist/nx/index.d.ts +6 -0
  22. package/dist/nx/index.d.ts.map +1 -1
  23. package/dist/nx/index.js +55 -5
  24. package/dist/release/github-release.d.ts +3 -0
  25. package/dist/release/github-release.d.ts.map +1 -1
  26. package/dist/release/github-release.js +11 -0
  27. package/dist/release/index.d.ts +19 -1
  28. package/dist/release/index.d.ts.map +1 -1
  29. package/dist/release/index.js +140 -31
  30. package/dist/release/orchestration.d.ts +8 -1
  31. package/dist/release/orchestration.d.ts.map +1 -1
  32. package/dist/release/orchestration.js +38 -1
  33. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +5 -0
  34. package/managed/raw/tooling/direnv/setup-environment.ts +40 -3
  35. package/managed/templates/github/actions/cache-nix-devenv/action.yml +12 -10
  36. package/managed/templates/github/actions/cache-node-modules/action.yml +3 -2
  37. package/managed/templates/github/actions/cache-nx/action.yml +2 -2
  38. package/managed/templates/github/actions/save-nix-devenv/action.yml +11 -3
  39. package/package.json +7 -4
  40. package/src/cli.ts +30 -4
  41. package/src/github-ci/index.test.ts +486 -0
  42. package/src/github-ci/index.ts +172 -13
  43. package/src/github-ci/outputs.ts +506 -0
  44. package/src/monorepo/__tests__/ci-workflow.test.ts +11 -0
  45. package/src/monorepo/__tests__/publish-workflow.test.ts +222 -1
  46. package/src/monorepo/ci-workflow.ts +1 -1
  47. package/src/monorepo/managed-files.test.ts +62 -0
  48. package/src/monorepo/managed-files.ts +41 -0
  49. package/src/monorepo/package-policy.test.ts +50 -1
  50. package/src/monorepo/package-policy.ts +28 -0
  51. package/src/monorepo/packs/index.test.ts +18 -1
  52. package/src/monorepo/packs/index.ts +7 -3
  53. package/src/monorepo/publish-workflow.ts +479 -2
  54. package/src/nx/index.test.ts +39 -0
  55. package/src/nx/index.ts +65 -5
  56. package/src/release/__tests__/fixture-repo.test.ts +18 -16
  57. package/src/release/__tests__/github-release.test.ts +11 -0
  58. package/src/release/__tests__/orchestration.test.ts +57 -2
  59. package/src/release/__tests__/trust-publisher.test.ts +91 -2
  60. package/src/release/github-release.ts +12 -0
  61. package/src/release/index.ts +218 -40
  62. package/src/release/orchestration.ts +55 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smoothbricks/cli",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "type": "module",
5
5
  "description": "SmoothBricks monorepo automation CLI",
6
6
  "bin": {
@@ -52,14 +52,17 @@
52
52
  ],
53
53
  "dependencies": {
54
54
  "@arethetypeswrong/core": "^0.18.2",
55
- "@smoothbricks/nx-plugin": "0.3.1",
56
- "@smoothbricks/validation": "0.1.2",
55
+ "@prettier/sync": "^0.6.1",
56
+ "@smoothbricks/nx-plugin": "0.3.2",
57
+ "@smoothbricks/validation": "0.1.3",
57
58
  "commander": "^14.0.3",
59
+ "prettier": "^3.6.1",
58
60
  "publint": "^0.3.18",
59
61
  "semver": "^7.7.4",
60
62
  "sherif": "^1.11.1",
61
63
  "toml-eslint-parser": "^1.0.3",
62
- "tslib": "^2.8.1"
64
+ "tslib": "^2.8.1",
65
+ "typia": "13.1.1"
63
66
  },
64
67
  "devDependencies": {
65
68
  "fast-check": "^4.4.0"
package/src/cli.ts CHANGED
@@ -147,10 +147,25 @@ function buildProgram(): Command {
147
147
  .command('repair-pending')
148
148
  .description('Repair incomplete older release commits before releasing the current HEAD')
149
149
  .option('--dry-run [dryRun]', 'run without pushing, publishing, or writing GitHub Releases')
150
- .action(async (options: { dryRun?: string | boolean }) => {
150
+ .option('--ref <ref>', 'fixed release graph ref to inspect')
151
+ .option('--platform-outputs <path>', 'cross-platform repair outputs grouped by release SHA')
152
+ .action(async (options: { dryRun?: string | boolean; platformOutputs?: string; ref?: string }) => {
153
+ // The source self-hosting shim has no Typia transform; release commands import transformed output validators.
151
154
  const { releaseRepairPending } = await import('./release/index.js');
152
155
  await releaseRepairPending(await findRepoRoot(), { ...options, dryRun: booleanOption(options.dryRun) });
153
156
  });
157
+ release
158
+ .command('build-platform-outputs')
159
+ .description('Build selected current and pending-release platform outputs')
160
+ .requiredOption('--bump <bump>', 'auto, patch, minor, major, or prerelease')
161
+ .requiredOption('--targets <targets>', 'comma-separated Nx platform target names or globs')
162
+ .requiredOption('--output <path>', 'output directory for current and repair artifacts')
163
+ .option('--ref <ref>', 'fixed release graph ref to inspect')
164
+ .action(async (options: { bump: string; output: string; ref?: string; targets: string }) => {
165
+ // The source self-hosting shim has no Typia transform; release commands import transformed output validators.
166
+ const { releaseCollectPlatformOutputs } = await import('./release/index.js');
167
+ await releaseCollectPlatformOutputs(await findRepoRoot(), options);
168
+ });
154
169
  release
155
170
  .command('version')
156
171
  .option('--bump <bump>', 'auto, patch, minor, major, or prerelease', 'auto')
@@ -322,9 +337,20 @@ function buildProgram(): Command {
322
337
  .requiredOption('--targets <targets>')
323
338
  .option('--projects <projects>')
324
339
  .option('--configuration <configuration>')
325
- .action(async (options: { targets: string; projects?: string; configuration?: string }) => {
326
- const { githubCiNxRunMany } = await import('./github-ci/index.js');
327
- await githubCiNxRunMany(await findRepoRoot(), options);
340
+ .option('--collect-outputs <directory>')
341
+ .action(
342
+ async (options: { targets: string; projects?: string; configuration?: string; collectOutputs?: string }) => {
343
+ const { githubCiNxRunMany } = await import('./github-ci/index.js');
344
+ await githubCiNxRunMany(await findRepoRoot(), options);
345
+ },
346
+ );
347
+ githubCi
348
+ .command('apply-outputs <directories...>')
349
+ .requiredOption('--source-sha <sha>', 'expected source commit SHA')
350
+ .action(async (directories: string[], options: { sourceSha: string }) => {
351
+ // GitHub CI commands stay lazy so source self-hosting can initialize Typia only at manifest boundaries.
352
+ const { githubCiApplyOutputs } = await import('./github-ci/index.js');
353
+ await githubCiApplyOutputs(await findRepoRoot(), directories, options.sourceSha);
328
354
  });
329
355
  githubCi
330
356
  .command('nx-deploy')
@@ -0,0 +1,486 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { $ } from 'bun';
6
+ import { decode } from '../lib/run.js';
7
+ import type { ProjectTargets } from '../nx/index.js';
8
+ import {
9
+ expandNxTargetDependencyRuns,
10
+ expandNxTargetRuns,
11
+ githubCiNxRunMany,
12
+ nxRunManyArgs,
13
+ nxSmartArgs,
14
+ readGitHeadSha,
15
+ } from './index.js';
16
+ import {
17
+ applyCollectedOutputs,
18
+ type CollectedOutputsManifest,
19
+ collectNxOutputs,
20
+ resolveDeclaredOutput,
21
+ } from './outputs.js';
22
+
23
+ const SOURCE_SHA = 'a'.repeat(40);
24
+ const OTHER_SHA = 'b'.repeat(40);
25
+
26
+ const projects: ProjectTargets[] = [
27
+ { project: 'api', root: 'packages/api', targets: ['build', 'test'] },
28
+ { project: 'desktop', root: 'packages/desktop', targets: ['build-macos', 'package-macos', 'test'] },
29
+ { project: 'mobile', root: 'packages/mobile', targets: ['build-ios'] },
30
+ ];
31
+
32
+ describe('GitHub CI Nx target expansion', () => {
33
+ it('groups an exact target with only projects that own it', () => {
34
+ const expanded = expandNxTargetRuns(projects, { targets: 'test' });
35
+
36
+ expect(expanded.unmatchedGlobs).toEqual([]);
37
+ expect(expanded.runs.map((run) => [run.target, run.projects.map((project) => project.project)])).toEqual([
38
+ ['test', ['api', 'desktop']],
39
+ ]);
40
+ expect(expanded.runs.map((run) => nxRunManyArgs(run))).toEqual([
41
+ ['run-many', '-t', 'test', '--projects=api,desktop', '--parallel=5'],
42
+ ]);
43
+ });
44
+
45
+ it('expands target globs across heterogeneous projects', () => {
46
+ const expanded = expandNxTargetRuns(projects, { targets: '*-macos,*-ios' });
47
+
48
+ expect(expanded.unmatchedGlobs).toEqual([]);
49
+ expect(expanded.runs.map((run) => [run.target, run.projects.map((project) => project.project)])).toEqual([
50
+ ['build-macos', ['desktop']],
51
+ ['package-macos', ['desktop']],
52
+ ['build-ios', ['mobile']],
53
+ ]);
54
+ });
55
+
56
+ it('reports a zero-match glob as an empty run set', () => {
57
+ expect(expandNxTargetRuns(projects, { targets: '*-windows' })).toEqual({
58
+ runs: [],
59
+ unmatchedGlobs: ['*-windows'],
60
+ });
61
+ });
62
+
63
+ it('passes an unknown exact target to Nx so Nx retains failure behavior', () => {
64
+ const expanded = expandNxTargetRuns(projects, { targets: 'missing', projects: 'api' });
65
+
66
+ expect(expanded.unmatchedGlobs).toEqual([]);
67
+ expect(expanded.runs.map((run) => nxRunManyArgs(run))).toEqual([
68
+ ['run-many', '-t', 'missing', '--projects=api', '--parallel=5'],
69
+ ]);
70
+ });
71
+
72
+ it('scopes glob candidates to selected projects and rejects empty project selections', () => {
73
+ expect(expandNxTargetRuns(projects, { targets: '*-macos', projects: 'api' })).toEqual({
74
+ runs: [],
75
+ unmatchedGlobs: ['*-macos'],
76
+ });
77
+ expect(() => expandNxTargetRuns(projects, { targets: 'test', projects: 'missing-*' })).toThrow(
78
+ 'No Nx projects matched',
79
+ );
80
+ expect(expandNxTargetRuns([], { targets: 'missing' })).toEqual({ runs: [], unmatchedGlobs: [] });
81
+ expect(expandNxTargetRuns(projects, { targets: '*-macos', projects: '', allowEmptyProjects: true })).toEqual({
82
+ runs: [],
83
+ unmatchedGlobs: ['*-macos'],
84
+ });
85
+ expect(() => nxRunManyArgs({ target: 'test', projects: [] })).toThrow('has no selected projects');
86
+ });
87
+
88
+ it('expands same-project target dependencies in dependency-first order exactly once', () => {
89
+ const project: ProjectTargets = {
90
+ project: 'native',
91
+ root: 'packages/native',
92
+ targets: ['build', 'compile-linux', 'package-linux'],
93
+ targetDependencies: new Map([
94
+ ['build', ['compile-*', 'compile-linux']],
95
+ ['compile-linux', ['package-linux', '^build']],
96
+ ]),
97
+ targetOutputs: new Map([
98
+ ['build', ['{projectRoot}/dist']],
99
+ ['compile-linux', ['{projectRoot}/dist/**/*.bin']],
100
+ ['package-linux', ['{projectRoot}/dist/**/*.tar']],
101
+ ]),
102
+ };
103
+
104
+ expect(
105
+ expandNxTargetDependencyRuns([{ target: 'build', projects: [project] }]).map(
106
+ (run) => `${run.projects.map((owner) => owner.project).join(',')}:${run.target}`,
107
+ ),
108
+ ).toEqual(['native:package-linux', 'native:compile-linux', 'native:build']);
109
+ });
110
+
111
+ it('adds the generic target skip tag only to nx-smart', () => {
112
+ expect(nxSmartArgs('test', 'affected')).toEqual([
113
+ 'affected',
114
+ '-t',
115
+ 'test',
116
+ '--exclude=tag:ci:skip:test',
117
+ '--parallel=5',
118
+ ]);
119
+ expect(nxRunManyArgs({ target: 'test', projects: projects.slice(0, 1) })).not.toContain(
120
+ '--exclude=tag:ci:skip:test',
121
+ );
122
+ });
123
+ });
124
+
125
+ describe('collected Nx outputs', () => {
126
+ it('derives artifact provenance from the checked-out HEAD', async () => {
127
+ const root = await mkdtemp(join(tmpdir(), 'smoo-output-head-'));
128
+ try {
129
+ await $`git init -q`.cwd(root).quiet();
130
+ await writeFile(join(root, 'artifact-source.txt'), 'release candidate');
131
+ await $`git add artifact-source.txt`.cwd(root).quiet();
132
+ await $`git -c user.name=Test -c user.email=test@example.com commit -q -m initial`.cwd(root).quiet();
133
+ const expected = decode((await $`git rev-parse HEAD`.cwd(root).quiet()).stdout).trim();
134
+
135
+ expect(await readGitHeadSha(root)).toBe(expected);
136
+ } finally {
137
+ await rm(root, { recursive: true, force: true });
138
+ }
139
+ });
140
+
141
+ it('collects an empty artifact when selected projects have no matching target', async () => {
142
+ await withNxRunManyFixture(async ({ root, artifact }) => {
143
+ await githubCiNxRunMany(root, {
144
+ targets: '*-linux',
145
+ projects: 'app',
146
+ collectOutputs: artifact,
147
+ });
148
+
149
+ const sourceSha = await readGitHeadSha(root);
150
+ expect(JSON.parse(await readFile(join(artifact, 'manifest.json'), 'utf8'))).toEqual({
151
+ version: 1,
152
+ sourceSha,
153
+ files: [],
154
+ });
155
+ });
156
+ });
157
+
158
+ it('applies an empty manifest after artifact transport omits its empty workspace directory', async () => {
159
+ await withOutputFixture(async ({ root, artifact }) => {
160
+ const manifest = await collectNxOutputs(root, artifact, [], SOURCE_SHA);
161
+
162
+ expect(manifest).toEqual({ version: 1, sourceSha: SOURCE_SHA, files: [] });
163
+ await rm(join(artifact, 'workspace'), { recursive: true });
164
+ await expect(applyCollectedOutputs(root, [artifact], SOURCE_SHA, [])).resolves.toBeUndefined();
165
+ });
166
+ });
167
+
168
+ it('collects aggregate same-project dependency outputs once with dependency ownership', async () => {
169
+ await withOutputFixture(async ({ root, artifact, outputProject }) => {
170
+ outputProject.targets = ['build', 'compile-linux', 'package-linux'];
171
+ outputProject.targetDependencies = new Map([
172
+ ['build', ['compile-linux']],
173
+ ['compile-linux', ['package-linux']],
174
+ ]);
175
+ outputProject.targetOutputs = new Map([
176
+ ['build', ['{projectRoot}/dist']],
177
+ ['compile-linux', ['{projectRoot}/dist/**/*.bin']],
178
+ ['package-linux', ['{projectRoot}/dist/package/**/*.tar']],
179
+ ]);
180
+ await mkdir(join(root, 'packages/app/dist/package'), { recursive: true });
181
+ await writeFile(join(root, 'packages/app/dist/result.bin'), 'linux binary');
182
+ await writeFile(join(root, 'packages/app/dist/package/release.tar'), 'linux package');
183
+
184
+ const runs = expandNxTargetDependencyRuns([{ target: 'build', projects: [outputProject] }]);
185
+ const manifest = await collectNxOutputs(root, artifact, runs, SOURCE_SHA);
186
+
187
+ expect(manifest.files.map((file) => `${file.project}:${file.target}:${file.path}`)).toEqual([
188
+ 'app:package-linux:packages/app/dist/package/release.tar',
189
+ 'app:compile-linux:packages/app/dist/result.bin',
190
+ ]);
191
+ });
192
+ });
193
+
194
+ it('collects brace-expanded TypeScript outputs without accepting unresolved Nx placeholders', async () => {
195
+ await withOutputFixture(async ({ root, artifact, outputProject }) => {
196
+ const declaredOutput = '{projectRoot}/dist/**/*.{js,cjs,mjs,jsx,d.ts,d.cts,d.mts}{,.map}';
197
+ outputProject.targetOutputs = new Map([['build-macos', [declaredOutput]]]);
198
+ await writeFile(join(root, 'packages/app/dist/index.js'), 'javascript');
199
+ await writeFile(join(root, 'packages/app/dist/index.d.ts'), 'declaration');
200
+ await writeFile(join(root, 'packages/app/dist/index.d.ts.map'), 'source map');
201
+ await writeFile(join(root, 'packages/app/dist/index.css'), 'not declared');
202
+
203
+ const manifest = await collectNxOutputs(
204
+ root,
205
+ artifact,
206
+ [{ target: 'build-macos', projects: [outputProject] }],
207
+ SOURCE_SHA,
208
+ );
209
+
210
+ expect(resolveDeclaredOutput(declaredOutput, outputProject)).toBe(
211
+ 'packages/app/dist/**/*.{js,cjs,mjs,jsx,d.ts,d.cts,d.mts}{,.map}',
212
+ );
213
+ expect(manifest.files.map((file) => file.path)).toEqual([
214
+ 'packages/app/dist/index.d.ts',
215
+ 'packages/app/dist/index.d.ts.map',
216
+ 'packages/app/dist/index.js',
217
+ ]);
218
+ expect(() => resolveDeclaredOutput('{options.outputPath}', outputProject)).toThrow(
219
+ 'Unsupported Nx output placeholder',
220
+ );
221
+ });
222
+ });
223
+
224
+ it('rejects overlapping outputs from unrelated targets', async () => {
225
+ await withOutputFixture(async ({ root, artifact, outputProject }) => {
226
+ outputProject.targets = ['first-linux', 'second-linux'];
227
+ outputProject.targetOutputs = new Map([
228
+ ['first-linux', ['{projectRoot}/dist/**/*.bin']],
229
+ ['second-linux', ['{projectRoot}/dist/**/*.bin']],
230
+ ]);
231
+ await writeFile(join(root, 'packages/app/dist/result.bin'), 'collision');
232
+
233
+ await expect(
234
+ collectNxOutputs(
235
+ root,
236
+ artifact,
237
+ [
238
+ { target: 'first-linux', projects: [outputProject] },
239
+ { target: 'second-linux', projects: [outputProject] },
240
+ ],
241
+ SOURCE_SHA,
242
+ ),
243
+ ).rejects.toThrow('Output collision');
244
+ });
245
+ });
246
+
247
+ it('collects declared files and applies a verified overlay', async () => {
248
+ await withOutputFixture(async ({ root, artifact, outputProject }) => {
249
+ const source = join(root, 'packages/app/dist/result.bin');
250
+ await writeFile(source, 'native artifact');
251
+
252
+ const manifest = await collectNxOutputs(
253
+ root,
254
+ artifact,
255
+ [{ target: 'build-macos', projects: [outputProject] }],
256
+ SOURCE_SHA,
257
+ );
258
+ expect(manifest).toMatchObject({
259
+ version: 1,
260
+ sourceSha: SOURCE_SHA,
261
+ files: [
262
+ {
263
+ project: 'app',
264
+ target: 'build-macos',
265
+ output: '{projectRoot}/dist/**/*.bin',
266
+ path: 'packages/app/dist/result.bin',
267
+ size: 15,
268
+ },
269
+ ],
270
+ });
271
+ expect(await readFile(join(artifact, 'workspace/packages/app/dist/result.bin'), 'utf8')).toBe('native artifact');
272
+
273
+ await rm(join(root, 'packages/app/dist'), { recursive: true, force: true });
274
+ await applyCollectedOutputs(root, [artifact], SOURCE_SHA, [outputProject]);
275
+ expect(await readFile(source, 'utf8')).toBe('native artifact');
276
+ });
277
+ });
278
+
279
+ it('rejects missing, escaping, and symlinked declared outputs', async () => {
280
+ await withOutputFixture(async ({ root, artifact, outputProject, temp }) => {
281
+ await expect(
282
+ collectNxOutputs(root, artifact, [{ target: 'build-macos', projects: [outputProject] }], SOURCE_SHA),
283
+ ).rejects.toThrow('is missing');
284
+
285
+ outputProject.targetOutputs = new Map([['build-macos', ['../outside']]]);
286
+ await expect(
287
+ collectNxOutputs(root, artifact, [{ target: 'build-macos', projects: [outputProject] }], SOURCE_SHA),
288
+ ).rejects.toThrow('escapes the workspace');
289
+
290
+ const outside = join(temp, 'outside-output');
291
+ await mkdir(join(outside, 'dist'), { recursive: true });
292
+ await writeFile(join(outside, 'dist/result.bin'), 'outside');
293
+ await rm(join(root, 'packages/app'), { recursive: true, force: true });
294
+ await symlink(outside, join(root, 'packages/app'), 'dir');
295
+ outputProject.targetOutputs = new Map([['build-macos', ['{projectRoot}/dist/**/*.bin']]]);
296
+ await expect(
297
+ collectNxOutputs(root, artifact, [{ target: 'build-macos', projects: [outputProject] }], SOURCE_SHA),
298
+ ).rejects.toThrow('symbolic link');
299
+ });
300
+ });
301
+
302
+ it('rejects checksum corruption, source mismatches, and undeclared staged files', async () => {
303
+ await withOutputFixture(async ({ root, artifact, outputProject }) => {
304
+ await writeFile(join(root, 'packages/app/dist/result.bin'), 'native artifact');
305
+ await collectNxOutputs(root, artifact, [{ target: 'build-macos', projects: [outputProject] }], SOURCE_SHA);
306
+
307
+ await expect(applyCollectedOutputs(root, [artifact], OTHER_SHA, [outputProject])).rejects.toThrow(
308
+ 'Source SHA mismatch',
309
+ );
310
+
311
+ const staged = join(artifact, 'workspace/packages/app/dist/result.bin');
312
+ await writeFile(staged, 'corrupt artifact');
313
+ await expect(applyCollectedOutputs(root, [artifact], SOURCE_SHA, [outputProject])).rejects.toThrow(/mismatch/);
314
+
315
+ await writeFile(staged, 'native artifact');
316
+ await writeFile(join(artifact, 'workspace/undeclared.txt'), 'extra');
317
+ await expect(applyCollectedOutputs(root, [artifact], SOURCE_SHA, [outputProject])).rejects.toThrow(
318
+ 'Undeclared staged output',
319
+ );
320
+ });
321
+ });
322
+
323
+ it('rejects path injection, exact-shape violations, and collisions before overlaying', async () => {
324
+ await withOutputFixture(async ({ root, artifact, outputProject, temp }) => {
325
+ await writeFile(join(root, 'packages/app/dist/result.bin'), 'native artifact');
326
+ const manifest = await collectNxOutputs(
327
+ root,
328
+ artifact,
329
+ [{ target: 'build-macos', projects: [outputProject] }],
330
+ SOURCE_SHA,
331
+ );
332
+
333
+ const malicious: CollectedOutputsManifest = {
334
+ ...manifest,
335
+ files: manifest.files.map((file, index) => (index === 0 ? { ...file, path: '../escape.bin' } : file)),
336
+ };
337
+ await writeFile(join(artifact, 'manifest.json'), `${JSON.stringify(malicious)}\n`);
338
+ await expect(applyCollectedOutputs(root, [artifact], SOURCE_SHA, [outputProject])).rejects.toThrow(
339
+ 'escapes the workspace',
340
+ );
341
+
342
+ await writeFile(join(artifact, 'manifest.json'), `${JSON.stringify({ ...manifest, unexpected: true })}\n`);
343
+ await expect(applyCollectedOutputs(root, [artifact], SOURCE_SHA, [outputProject])).rejects.toThrow(
344
+ 'Invalid collected output manifest',
345
+ );
346
+
347
+ await writeFile(
348
+ join(artifact, 'manifest.json'),
349
+ `${JSON.stringify({
350
+ ...manifest,
351
+ files: manifest.files.map((file) => ({ ...file, output: '{workspaceRoot}/**/*' })),
352
+ })}\n`,
353
+ );
354
+ await expect(applyCollectedOutputs(root, [artifact], SOURCE_SHA, [outputProject])).rejects.toThrow(
355
+ 'is not declared by Nx target',
356
+ );
357
+
358
+ await writeFile(
359
+ join(artifact, 'manifest.json'),
360
+ `${JSON.stringify({
361
+ ...manifest,
362
+ files: manifest.files.map((file) => ({ ...file, project: 'unknown-project' })),
363
+ })}\n`,
364
+ );
365
+ await expect(applyCollectedOutputs(root, [artifact], SOURCE_SHA, [outputProject])).rejects.toThrow(
366
+ 'unknown Nx project',
367
+ );
368
+
369
+ await writeFile(
370
+ join(artifact, 'manifest.json'),
371
+ `${JSON.stringify({
372
+ ...manifest,
373
+ files: manifest.files.map((file) => ({ ...file, target: '*-macos' })),
374
+ })}\n`,
375
+ );
376
+ await expect(applyCollectedOutputs(root, [artifact], SOURCE_SHA, [outputProject])).rejects.toThrow(
377
+ 'must be an exact Nx name',
378
+ );
379
+
380
+ await writeFile(join(artifact, 'manifest.json'), `${JSON.stringify(manifest)}\n`);
381
+ const secondArtifact = join(temp, 'artifact-two');
382
+ await mkdir(secondArtifact, { recursive: true });
383
+ await Bun.write(join(secondArtifact, 'manifest.json'), JSON.stringify(manifest));
384
+ await mkdir(join(secondArtifact, 'workspace/packages/app/dist'), { recursive: true });
385
+ await Bun.write(join(secondArtifact, 'workspace/packages/app/dist/result.bin'), 'native artifact');
386
+ await expect(
387
+ applyCollectedOutputs(root, [artifact, secondArtifact], SOURCE_SHA, [outputProject]),
388
+ ).rejects.toThrow('Output collision across collected trees');
389
+ });
390
+ });
391
+
392
+ it('rejects non-hex source SHAs', async () => {
393
+ await withOutputFixture(async ({ root, artifact, outputProject }) => {
394
+ await expect(
395
+ collectNxOutputs(root, artifact, [{ target: 'build-macos', projects: [outputProject] }], 'not-a-git-sha'),
396
+ ).rejects.toThrow('40- or 64-character hexadecimal Git SHA');
397
+ await expect(applyCollectedOutputs(root, [artifact], 'not-a-git-sha', [outputProject])).rejects.toThrow(
398
+ '40- or 64-character hexadecimal Git SHA',
399
+ );
400
+ });
401
+ });
402
+
403
+ it('rejects symlinks in destination ancestors and destination files', async () => {
404
+ await withOutputFixture(async ({ root, artifact, outputProject, temp }) => {
405
+ await writeFile(join(root, 'packages/app/dist/result.bin'), 'native artifact');
406
+ await collectNxOutputs(root, artifact, [{ target: 'build-macos', projects: [outputProject] }], SOURCE_SHA);
407
+
408
+ const outside = join(temp, 'outside');
409
+ await mkdir(outside);
410
+ await rm(join(root, 'packages/app'), { recursive: true, force: true });
411
+ await symlink(outside, join(root, 'packages/app'), 'dir');
412
+
413
+ await expect(applyCollectedOutputs(root, [artifact], SOURCE_SHA, [outputProject])).rejects.toThrow(
414
+ 'symbolic link',
415
+ );
416
+ expect(await Bun.file(join(outside, 'dist/result.bin')).exists()).toBe(false);
417
+ });
418
+
419
+ await withOutputFixture(async ({ root, artifact, outputProject, temp }) => {
420
+ const output = join(root, 'packages/app/dist/result.bin');
421
+ await writeFile(output, 'native artifact');
422
+ await collectNxOutputs(root, artifact, [{ target: 'build-macos', projects: [outputProject] }], SOURCE_SHA);
423
+
424
+ const outside = join(temp, 'outside.bin');
425
+ await writeFile(outside, 'must remain unchanged');
426
+ await rm(output);
427
+ await symlink(outside, output, 'file');
428
+
429
+ await expect(applyCollectedOutputs(root, [artifact], SOURCE_SHA, [outputProject])).rejects.toThrow(
430
+ 'symbolic link',
431
+ );
432
+ expect(await readFile(outside, 'utf8')).toBe('must remain unchanged');
433
+ });
434
+ });
435
+ });
436
+
437
+ async function withOutputFixture(
438
+ run: (fixture: { root: string; artifact: string; outputProject: ProjectTargets; temp: string }) => Promise<void>,
439
+ ): Promise<void> {
440
+ const temp = await mkdtemp(join(tmpdir(), 'smoo-platform-output-'));
441
+ const root = join(temp, 'repo');
442
+ const artifact = join(temp, 'artifact');
443
+ const outputProject: ProjectTargets = {
444
+ project: 'app',
445
+ root: 'packages/app',
446
+ targets: ['build-macos'],
447
+ targetOutputs: new Map([['build-macos', ['{projectRoot}/dist/**/*.bin']]]),
448
+ };
449
+ try {
450
+ await mkdir(join(root, 'packages/app/dist'), { recursive: true });
451
+ await run({ root, artifact, outputProject, temp });
452
+ } finally {
453
+ await rm(temp, { recursive: true, force: true });
454
+ }
455
+ }
456
+
457
+ async function withNxRunManyFixture(
458
+ run: (fixture: { root: string; artifact: string }) => Promise<void>,
459
+ ): Promise<void> {
460
+ const temp = await mkdtemp(join(tmpdir(), 'smoo-empty-platform-output-'));
461
+ const root = join(temp, 'repo');
462
+ const artifact = join(temp, 'artifact');
463
+ try {
464
+ await mkdir(join(root, 'packages/app'), { recursive: true });
465
+ await symlink(join(import.meta.dir, '../../../../node_modules'), join(root, 'node_modules'), 'dir');
466
+ await writeFile(
467
+ join(root, 'package.json'),
468
+ JSON.stringify({ name: '@fixture/root', private: true, workspaces: ['packages/*'] }),
469
+ );
470
+ await writeFile(join(root, 'nx.json'), '{}');
471
+ await writeFile(
472
+ join(root, 'packages/app/package.json'),
473
+ JSON.stringify({
474
+ name: '@fixture/app',
475
+ nx: { name: 'app', targets: { build: { executor: 'nx:noop' } } },
476
+ }),
477
+ );
478
+ await $`git init --quiet`.cwd(root);
479
+ await $`git add .`.cwd(root).quiet();
480
+ await $`git -c user.name=Test -c user.email=test@example.com commit --quiet -m fixture`.cwd(root);
481
+
482
+ await run({ root, artifact });
483
+ } finally {
484
+ await rm(temp, { recursive: true, force: true });
485
+ }
486
+ }