@ashlr/stack-core 0.1.1 → 0.3.0

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 (111) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +26 -15
  3. package/package.json +1 -1
  4. package/src/__tests__/_harness.ts +67 -7
  5. package/src/__tests__/agent-onboard.test.ts +279 -0
  6. package/src/__tests__/api-key-providers.test.ts +6 -2
  7. package/src/__tests__/base-urls.test.ts +208 -0
  8. package/src/__tests__/catalog-index.test.ts +14 -20
  9. package/src/__tests__/catalog.test.ts +31 -0
  10. package/src/__tests__/cloudflare-provider.test.ts +32 -10
  11. package/src/__tests__/completion-ci.test.ts +239 -23
  12. package/src/__tests__/config-own-services.test.ts +183 -0
  13. package/src/__tests__/config-split.test.ts +272 -2
  14. package/src/__tests__/config.test.ts +94 -4
  15. package/src/__tests__/credential-staging.test.ts +660 -0
  16. package/src/__tests__/deps.test.ts +2 -1
  17. package/src/__tests__/detect-source.test.ts +183 -4
  18. package/src/__tests__/fixtures/stack-manifest.v1.json +10 -0
  19. package/src/__tests__/github-provider.test.ts +3 -2
  20. package/src/__tests__/helpers.test.ts +24 -4
  21. package/src/__tests__/http.test.ts +2 -2
  22. package/src/__tests__/import-integration.test.ts +18 -4
  23. package/src/__tests__/inference.test.ts +57 -13
  24. package/src/__tests__/interactive-prompt.test.ts +106 -0
  25. package/src/__tests__/mcp-mirror.test.ts +116 -0
  26. package/src/__tests__/neon-provider.test.ts +136 -3
  27. package/src/__tests__/new-providers.test.ts +470 -0
  28. package/src/__tests__/no-orphan-modules.test.ts +114 -0
  29. package/src/__tests__/oauth-port.test.ts +84 -0
  30. package/src/__tests__/openai-provider.test.ts +1 -1
  31. package/src/__tests__/phantom-contract.test.ts +177 -0
  32. package/src/__tests__/phantom-harness.test.ts +226 -8
  33. package/src/__tests__/phantom-process.test.ts +135 -0
  34. package/src/__tests__/phantom-project-preflight.test.ts +118 -0
  35. package/src/__tests__/phantom-wire.test.ts +403 -22
  36. package/src/__tests__/pipeline-integration.test.ts +33 -1
  37. package/src/__tests__/pipeline-recovery.test.ts +29 -28
  38. package/src/__tests__/pipeline-rollback.test.ts +509 -0
  39. package/src/__tests__/pipeline.test.ts +3 -1
  40. package/src/__tests__/pm-detect.test.ts +113 -0
  41. package/src/__tests__/provider-deprovision.test.ts +269 -0
  42. package/src/__tests__/provider-parity.test.ts +46 -0
  43. package/src/__tests__/publish-script.test.ts +704 -0
  44. package/src/__tests__/recipe.test.ts +27 -2
  45. package/src/__tests__/recommend-apply-e2e.test.ts +29 -27
  46. package/src/__tests__/registry.test.ts +11 -2
  47. package/src/__tests__/release-artifacts.test.ts +55 -0
  48. package/src/__tests__/scan-missing-dir.test.ts +45 -0
  49. package/src/__tests__/secret-redaction.test.ts +10 -10
  50. package/src/__tests__/security.test.ts +9 -10
  51. package/src/__tests__/stack-manifest.test.ts +38 -0
  52. package/src/__tests__/supabase-provider.test.ts +6 -4
  53. package/src/__tests__/swap.test.ts +114 -0
  54. package/src/__tests__/telemetry.test.ts +354 -0
  55. package/src/__tests__/template-litellm-gateway.test.ts +70 -0
  56. package/src/__tests__/template-temporal-worker-phantom.test.ts +45 -0
  57. package/src/__tests__/templates.test.ts +1 -1
  58. package/src/__tests__/turso-provider.test.ts +2 -5
  59. package/src/agent-onboard.ts +346 -0
  60. package/src/ai/catalog-index.ts +68 -10
  61. package/src/ai/circuit-breaker.ts +3 -6
  62. package/src/ai/cost-tracker.ts +6 -15
  63. package/src/ai/inference.ts +19 -11
  64. package/src/ai/phantom-wire.ts +73 -58
  65. package/src/ai/recipe.ts +69 -0
  66. package/src/base-urls.ts +71 -0
  67. package/src/catalog.ts +281 -2
  68. package/src/config.ts +31 -18
  69. package/src/detect-source.ts +50 -9
  70. package/src/detect.ts +13 -5
  71. package/src/errors.ts +6 -0
  72. package/src/http.ts +6 -5
  73. package/src/index.ts +16 -0
  74. package/src/mcp-writer.ts +97 -2
  75. package/src/oauth.ts +85 -53
  76. package/src/phantom-process.ts +72 -0
  77. package/src/phantom.ts +319 -30
  78. package/src/pipeline.ts +177 -68
  79. package/src/pm-detect.ts +56 -0
  80. package/src/providers/_api-key.ts +18 -17
  81. package/src/providers/_base.ts +28 -1
  82. package/src/providers/_credential-stage.ts +237 -0
  83. package/src/providers/_helpers.ts +55 -9
  84. package/src/providers/anthropic.ts +2 -1
  85. package/src/providers/auth0.ts +27 -0
  86. package/src/providers/aws.ts +32 -33
  87. package/src/providers/cloudflare.ts +18 -37
  88. package/src/providers/convex.ts +1 -2
  89. package/src/providers/datadog.ts +29 -0
  90. package/src/providers/digitalocean.ts +30 -0
  91. package/src/providers/gcp.ts +36 -0
  92. package/src/providers/github.ts +37 -35
  93. package/src/providers/grafana.ts +29 -0
  94. package/src/providers/hetzner.ts +29 -0
  95. package/src/providers/index.ts +13 -1
  96. package/src/providers/launchdarkly.ts +33 -0
  97. package/src/providers/mixpanel.ts +37 -0
  98. package/src/providers/neon.ts +50 -26
  99. package/src/providers/openai.ts +2 -1
  100. package/src/providers/plausible.ts +26 -0
  101. package/src/providers/sentry.ts +17 -17
  102. package/src/providers/stripe.ts +1 -2
  103. package/src/providers/supabase.ts +66 -37
  104. package/src/providers/turso.ts +49 -38
  105. package/src/providers/vercel.ts +50 -20
  106. package/src/providers/workos.ts +27 -0
  107. package/src/registry.ts +7 -11
  108. package/src/stack-manifest.ts +39 -0
  109. package/src/swap.ts +108 -0
  110. package/src/telemetry.ts +267 -0
  111. package/src/templates.ts +6 -2
@@ -0,0 +1,704 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { spawn } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import {
5
+ chmodSync,
6
+ lstatSync,
7
+ mkdirSync,
8
+ mkdtempSync,
9
+ readFileSync,
10
+ readlinkSync,
11
+ realpathSync,
12
+ rmSync,
13
+ symlinkSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join, resolve } from "node:path";
18
+
19
+ const sourceScript = resolve(import.meta.dir, "../../../../scripts/publish.sh");
20
+ const sourceWorkflow = resolve(import.meta.dir, "../../../../.github/workflows/release.yml");
21
+ // Resolve the runner's existing Node before installing the private effect-trap PATH.
22
+ const nodePath = Bun.which("node");
23
+ if (!nodePath) throw new Error("Node is required for copied read-only release preflight tests");
24
+ const realNode = realpathSync(nodePath);
25
+ const validatorProgram = readFileSync(sourceScript, "utf8")
26
+ .split("<<'STACK_RELEASE_SOURCE'\n")[1]
27
+ ?.split("\nSTACK_RELEASE_SOURCE")[0];
28
+ if (!validatorProgram) throw new Error("copied release validator is missing");
29
+ const validatorHash = createHash("sha256").update(`${validatorProgram}\n`).digest("hex");
30
+
31
+ function workflowBlock(name: string) {
32
+ const source = readFileSync(sourceWorkflow, "utf8");
33
+ const marker = `name: ${name}\n`;
34
+ const start = source.indexOf(marker);
35
+ if (start < 0 || source.indexOf(marker, start + 1) >= 0)
36
+ throw new Error("ambiguous fixture step");
37
+ const tail = source.slice(start + marker.length);
38
+ const run = tail.indexOf(" run: |\n");
39
+ if (run < 0) throw new Error("fixture step has no run block");
40
+ const lines = tail.slice(run + " run: |\n".length).split("\n");
41
+ const block: string[] = [];
42
+ for (const line of lines) {
43
+ if (line && !line.startsWith(" ")) break;
44
+ block.push(line.slice(10));
45
+ }
46
+ return block.join("\n");
47
+ }
48
+
49
+ const mutatingCommands = ["npm", "node", "git", "cp", "mv"];
50
+
51
+ // Bun 1.2's sync facade mislabels nonzero exits and ignores its timeout/buffer
52
+ // options. Own both bounds and await close so each copied fixture is reaped.
53
+ function runFixture(command: string, args: string[], cwd: string, env: Record<string, string>) {
54
+ return new Promise<{
55
+ status: number | null;
56
+ signal: NodeJS.Signals | null;
57
+ error?: Error;
58
+ stdout: string;
59
+ stderr: string;
60
+ timedOut: boolean;
61
+ outputLimitExceeded: boolean;
62
+ }>((resolve) => {
63
+ let child: ReturnType<typeof spawn>;
64
+ try {
65
+ child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
66
+ } catch (error) {
67
+ // Bun 1.2 throws launch errors synchronously; newer runtimes emit error.
68
+ resolve({
69
+ status: null,
70
+ signal: null,
71
+ error: error instanceof Error ? error : new Error("fixture launch failed"),
72
+ stdout: "",
73
+ stderr: "",
74
+ timedOut: false,
75
+ outputLimitExceeded: false,
76
+ });
77
+ return;
78
+ }
79
+ const stdout: Buffer[] = [];
80
+ const stderr: Buffer[] = [];
81
+ let bytes = 0;
82
+ let timedOut = false;
83
+ let outputLimitExceeded = false;
84
+ let error: Error | undefined;
85
+ const timer = setTimeout(() => {
86
+ timedOut = true;
87
+ child.kill("SIGKILL");
88
+ }, 1500);
89
+ function capture(chunks: Buffer[], chunk: Buffer) {
90
+ if (outputLimitExceeded) return;
91
+ const remaining = 64 * 1024 - bytes;
92
+ const retained = chunk.subarray(0, remaining);
93
+ chunks.push(Buffer.from(retained));
94
+ bytes += retained.length;
95
+ if (chunk.length > remaining) {
96
+ outputLimitExceeded = true;
97
+ child.kill("SIGKILL");
98
+ }
99
+ }
100
+ child.stdout?.on("data", (chunk: Buffer) => capture(stdout, chunk));
101
+ child.stderr?.on("data", (chunk: Buffer) => capture(stderr, chunk));
102
+ child.once("error", (failure) => {
103
+ error = failure;
104
+ if (child.pid) child.kill("SIGKILL");
105
+ });
106
+ child.once("close", (status, signal) => {
107
+ clearTimeout(timer);
108
+ resolve({
109
+ status,
110
+ signal,
111
+ error,
112
+ stdout: Buffer.concat(stdout).toString("utf8"),
113
+ stderr: Buffer.concat(stderr).toString("utf8"),
114
+ timedOut,
115
+ outputLimitExceeded,
116
+ });
117
+ });
118
+ });
119
+ }
120
+
121
+ describe("publish script early parser (copied script, inert commands)", () => {
122
+ let root: string;
123
+ let script: string;
124
+ let trace: string;
125
+ let env: Record<string, string>;
126
+ let preserved: Array<{ path: string; bytes: Buffer }>;
127
+
128
+ beforeEach(() => {
129
+ root = mkdtempSync(join(tmpdir(), "stack-publish-parser-"));
130
+ const bin = join(root, "bin");
131
+ const scripts = join(root, "scripts");
132
+ const home = join(root, "home");
133
+ const temp = join(root, "tmp");
134
+ for (const directory of [bin, scripts, home, temp]) mkdirSync(directory, { mode: 0o700 });
135
+ script = join(scripts, "publish.sh");
136
+ writeFileSync(script, readFileSync(sourceScript), { mode: 0o700 });
137
+ trace = join(root, "operations.log");
138
+ writeFileSync(trace, "", { mode: 0o600 });
139
+ for (const command of mutatingCommands) {
140
+ const trap = join(bin, command);
141
+ writeFileSync(
142
+ trap,
143
+ `#!/bin/bash\nprintf '%s\\n' '${command}' >> "$SCRIPT_FIXTURE_TRACE"\nexit 97\n`,
144
+ { mode: 0o700 },
145
+ );
146
+ chmodSync(trap, 0o700);
147
+ }
148
+ // The only real Node program allowed is the exact copied, read-only validator.
149
+ // This is a test binding, not a sandbox or publisher/account attestation.
150
+ writeFileSync(
151
+ join(bin, "node-reader.cjs"),
152
+ `const {readFileSync, appendFileSync} = require('node:fs');
153
+ const {createHash} = require('node:crypto');
154
+ const input = readFileSync(0);
155
+ const args = process.argv.slice(2);
156
+ if (args.length !== 3 || args[0] !== '--input-type=module' || args[1] !== '-'
157
+ || createHash('sha256').update(input).digest('hex') !== '${validatorHash}') {
158
+ appendFileSync(process.env.SCRIPT_FIXTURE_TRACE, 'node\\n'); process.exit(97);
159
+ }
160
+ appendFileSync(process.env.VALIDATOR_TRACE, 'read\\n');
161
+ // Run the hash-checked module in this Node process: a second Node startup
162
+ // can exhaust the outer 1500ms fixture bound under coverage instrumentation.
163
+ process.argv = [process.execPath, '-', args[2]];
164
+ const timer = setTimeout(() => process.exit(98), 1000);
165
+ import('data:text/javascript;base64,' + input.toString('base64')).then(
166
+ () => clearTimeout(timer),
167
+ () => process.exit(98),
168
+ );
169
+ `,
170
+ { mode: 0o700 },
171
+ );
172
+ // A Bash wrapper quotes Node paths containing spaces; a shebang cannot.
173
+ writeFileSync(
174
+ join(bin, "node"),
175
+ `#!/bin/bash\nexec ${JSON.stringify(realNode)} ${JSON.stringify(join(bin, "node-reader.cjs"))} "$@"\n`,
176
+ { mode: 0o700 },
177
+ );
178
+ symlinkSync("/usr/bin/dirname", join(bin, "dirname"));
179
+ symlinkSync("/bin/cat", join(bin, "cat"));
180
+ // No inherited startup files, loader options, registry credentials or auth environment.
181
+ env = {
182
+ HOME: home,
183
+ TMPDIR: temp,
184
+ PATH: bin,
185
+ LANG: "C",
186
+ LC_ALL: "C",
187
+ SCRIPT_FIXTURE_TRACE: trace,
188
+ VALIDATOR_TRACE: join(root, "validator.log"),
189
+ };
190
+ writeFileSync(env.VALIDATOR_TRACE, "", { mode: 0o600 });
191
+ preserved = [];
192
+ for (const name of ["core", "mcp", "cli"]) {
193
+ const directory = join(root, "packages", name);
194
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
195
+ for (const suffix of ["", ".bak"]) {
196
+ const path = join(directory, `package.json${suffix}`);
197
+ const bytes = Buffer.from(
198
+ JSON.stringify({
199
+ name: `synthetic-${name}`,
200
+ version: "0.2.1",
201
+ suffix,
202
+ dependencies: { core: "workspace:*" },
203
+ }),
204
+ );
205
+ writeFileSync(path, bytes, { mode: 0o600 });
206
+ preserved.push({ path, bytes });
207
+ }
208
+ }
209
+ mkdirSync(join(root, "packages/cli/src"), { recursive: true, mode: 0o700 });
210
+ writeFileSync(join(root, "packages/cli/src/index.ts"), 'const VERSION = "0.2.1";\n', {
211
+ mode: 0o600,
212
+ });
213
+ writeFileSync(
214
+ join(root, "packages/mcp/server.json"),
215
+ JSON.stringify({
216
+ version: "0.2.1",
217
+ packages: [{ registryType: "npm", identifier: "synthetic-mcp", version: "0.2.1" }],
218
+ }),
219
+ { mode: 0o600 },
220
+ );
221
+ });
222
+
223
+ afterEach(() => {
224
+ rmSync(root, { recursive: true, force: true });
225
+ });
226
+
227
+ async function run(args: string[]) {
228
+ const result = await runFixture(
229
+ "/bin/bash",
230
+ ["--noprofile", "--norc", script, ...args],
231
+ root,
232
+ env,
233
+ );
234
+ expect(result.error).toBeUndefined();
235
+ expect(result.timedOut).toBe(false);
236
+ expect(result.outputLimitExceeded).toBe(false);
237
+ expect(result.signal).toBeNull();
238
+ expect(readFileSync(trace, "utf8")).toBe("");
239
+ for (const file of preserved) expect(readFileSync(file.path).equals(file.bytes)).toBe(true);
240
+ return {
241
+ ...result,
242
+ output: `${result.stdout}${result.stderr}`,
243
+ };
244
+ }
245
+
246
+ const unknownCases = [
247
+ {
248
+ name: "unsupported OTP",
249
+ args: ["--otp=synthetic-otp-output-sentinel"],
250
+ sentinel: "synthetic-otp-output-sentinel",
251
+ },
252
+ {
253
+ name: "generic secret-shaped flag",
254
+ args: ["--unknown=synthetic-password-output-sentinel"],
255
+ sentinel: "synthetic-password-output-sentinel",
256
+ },
257
+ {
258
+ name: "version and yes before unsupported OTP",
259
+ args: ["--version", "0.2.1", "--yes", "--otp=synthetic-known-output-sentinel"],
260
+ sentinel: "synthetic-known-output-sentinel",
261
+ },
262
+ {
263
+ name: "version and short yes before unknown flag",
264
+ args: ["--version", "0.2.1", "-y", "--unknown=synthetic-short-output-sentinel"],
265
+ sentinel: "synthetic-short-output-sentinel",
266
+ },
267
+ ];
268
+
269
+ for (const fixture of unknownCases) {
270
+ test(`${fixture.name} refuses safely before any operation`, async () => {
271
+ const result = await run(fixture.args);
272
+ expect(result.status).toBe(1);
273
+ expect(result.output).not.toContain(fixture.sentinel);
274
+ expect(result.output).toContain("unknown argument (try --help)");
275
+ });
276
+ }
277
+
278
+ test("help exits successfully before any operation", async () => {
279
+ const result = await run(["--help"]);
280
+ expect(result.status).toBe(0);
281
+ expect(result.output).toContain("Usage: scripts/publish.sh");
282
+ expect(result.output).toContain("--version");
283
+ expect(result.output).toContain("--yes");
284
+ });
285
+
286
+ test("known flags followed by short help retain early success", async () => {
287
+ const result = await run(["--version", "0.2.1", "--yes", "-h"]);
288
+ expect(result.status).toBe(0);
289
+ expect(result.output).toContain("Usage: scripts/publish.sh");
290
+ });
291
+
292
+ function removeFixtureBackups() {
293
+ for (const name of ["core", "mcp", "cli"]) {
294
+ rmSync(join(root, "packages", name, "package.json.bak"), { recursive: true, force: true });
295
+ }
296
+ }
297
+
298
+ function backupSnapshot(path: string) {
299
+ const stat = lstatSync(path, { throwIfNoEntry: false });
300
+ if (!stat) return null;
301
+ const identity = {
302
+ dev: stat.dev,
303
+ ino: stat.ino,
304
+ mode: stat.mode,
305
+ size: stat.size,
306
+ mtimeMs: stat.mtimeMs,
307
+ };
308
+ if (stat.isSymbolicLink()) return { ...identity, linkTarget: readlinkSync(path) };
309
+ if (stat.isDirectory()) {
310
+ return {
311
+ ...identity,
312
+ retainedBytes: readFileSync(join(path, "retained.txt")).toString("hex"),
313
+ };
314
+ }
315
+ return { ...identity, bytes: readFileSync(path).toString("hex") };
316
+ }
317
+
318
+ async function runBackupCase() {
319
+ const result = await runFixture(
320
+ "/bin/bash",
321
+ ["--noprofile", "--norc", script, "--version", "0.2.1", "--yes"],
322
+ root,
323
+ env,
324
+ );
325
+ expect(result.error).toBeUndefined();
326
+ expect(result.timedOut).toBe(false);
327
+ expect(result.outputLimitExceeded).toBe(false);
328
+ expect(result.signal).toBeNull();
329
+ for (const file of preserved.filter((file) => file.path.endsWith("package.json"))) {
330
+ expect(readFileSync(file.path).equals(file.bytes)).toBe(true);
331
+ }
332
+ return { ...result, output: `${result.stdout}${result.stderr}` };
333
+ }
334
+
335
+ const backupKinds = ["regular", "directory", "symlink", "dangling symlink"] as const;
336
+ for (const name of ["core", "mcp", "cli"]) {
337
+ for (const kind of backupKinds) {
338
+ test(`pre-existing ${name} ${kind} backup refuses before any operation`, async () => {
339
+ // Only this position has a backup, so an earlier-package check cannot mask it.
340
+ removeFixtureBackups();
341
+ const backup = join(root, "packages", name, "package.json.bak");
342
+ const target = join(root, "backup-target");
343
+ if (kind === "regular") {
344
+ writeFileSync(backup, "synthetic prior backup", { mode: 0o600 });
345
+ } else if (kind === "directory") {
346
+ mkdirSync(backup, { mode: 0o700 });
347
+ writeFileSync(join(backup, "retained.txt"), "synthetic directory payload", {
348
+ mode: 0o600,
349
+ });
350
+ } else {
351
+ if (kind === "symlink")
352
+ writeFileSync(target, "synthetic linked payload", { mode: 0o600 });
353
+ symlinkSync(target, backup);
354
+ }
355
+ for (const other of ["core", "mcp", "cli"].filter((other) => other !== name)) {
356
+ expect(
357
+ lstatSync(join(root, "packages", other, "package.json.bak"), { throwIfNoEntry: false }),
358
+ ).toBeUndefined();
359
+ }
360
+ const before = backupSnapshot(backup);
361
+ const targetBefore = backupSnapshot(target);
362
+ const result = await runBackupCase();
363
+ expect(result.status).toBe(1);
364
+ expect(result.output).toContain("existing package backup; refusing to overwrite");
365
+ expect(readFileSync(trace, "utf8")).toBe("");
366
+ expect(backupSnapshot(backup)).toEqual(before);
367
+ expect(backupSnapshot(target)).toEqual(targetBefore);
368
+ });
369
+ }
370
+ }
371
+
372
+ test("all three existing backups remain intact on refusal", async () => {
373
+ const backups = ["core", "mcp", "cli"].map((name) =>
374
+ join(root, "packages", name, "package.json.bak"),
375
+ );
376
+ const before = backups.map(backupSnapshot);
377
+ const result = await runBackupCase();
378
+ expect(result.status).toBe(1);
379
+ expect(result.output).toContain("existing package backup; refusing to overwrite");
380
+ expect(readFileSync(trace, "utf8")).toBe("");
381
+ expect(backups.map(backupSnapshot)).toEqual(before);
382
+ });
383
+
384
+ test("without backups a valid version reaches the original inert copy boundary", async () => {
385
+ removeFixtureBackups();
386
+ // A natural first-copy failure has the same final exit on supported Bash versions.
387
+ writeFileSync(
388
+ join(root, "bin", "cp"),
389
+ `#!/bin/bash\nprintf '%s\\n' 'cp' >> "$SCRIPT_FIXTURE_TRACE"\nexit 1\n`,
390
+ { mode: 0o700 },
391
+ );
392
+ const result = await runBackupCase();
393
+ expect(result.status).toBe(1);
394
+ expect(readFileSync(trace, "utf8")).toBe("cp\n");
395
+ expect(result.output).not.toContain("existing package backup; refusing to overwrite");
396
+ for (const name of ["core", "mcp", "cli"]) {
397
+ expect(
398
+ lstatSync(join(root, "packages", name, "package.json.bak"), { throwIfNoEntry: false }),
399
+ ).toBeUndefined();
400
+ }
401
+ });
402
+ for (const alteration of ["source", "arguments"] as const) {
403
+ test(`validator wrapper refuses altered ${alteration} before evaluation`, async () => {
404
+ removeFixtureBackups();
405
+ const original = readFileSync(script, "utf8");
406
+ const altered =
407
+ alteration === "source"
408
+ ? original.replace(
409
+ "import { lstatSync, readFileSync }",
410
+ "/* changed */ import { lstatSync, readFileSync }",
411
+ )
412
+ : original.replace(
413
+ 'node --input-type=module - "$VERSION"',
414
+ 'node --input-type=module - "$VERSION" extra',
415
+ );
416
+ expect(altered).not.toBe(original);
417
+ writeFileSync(script, altered);
418
+ const result = await runBackupCase();
419
+ expect(result.status).toBe(1);
420
+ expect(result.output).toContain("source version validation failed");
421
+ expect(readFileSync(trace, "utf8")).toBe("node\n");
422
+ expect(readFileSync(env.VALIDATOR_TRACE, "utf8")).toBe("");
423
+ for (const name of ["core", "mcp", "cli"]) {
424
+ expect(
425
+ lstatSync(join(root, "packages", name, "package.json.bak"), {
426
+ throwIfNoEntry: false,
427
+ }),
428
+ ).toBeUndefined();
429
+ }
430
+ });
431
+ }
432
+
433
+ function inputs() {
434
+ return [
435
+ ...preserved.map((file) => file.path),
436
+ join(root, "packages/cli/src/index.ts"),
437
+ join(root, "packages/mcp/server.json"),
438
+ ].map((path) => ({ path, snapshot: backupSnapshot(path) }));
439
+ }
440
+
441
+ async function refuse(args: string[], message: string, sentinel?: string) {
442
+ const before = inputs();
443
+ const result = await runFixture(
444
+ "/bin/bash",
445
+ ["--noprofile", "--norc", script, ...args],
446
+ root,
447
+ env,
448
+ );
449
+ expect(result.error).toBeUndefined();
450
+ expect(result.signal).toBeNull();
451
+ expect(result.timedOut).toBe(false);
452
+ expect(result.outputLimitExceeded).toBe(false);
453
+ expect(result.status).toBe(1);
454
+ const output = `${result.stdout}${result.stderr}`;
455
+ expect(output).toContain(message);
456
+ if (sentinel) expect(output).not.toContain(sentinel);
457
+ if (message === "source version validation failed")
458
+ expect(readFileSync(env.VALIDATOR_TRACE, "utf8")).toBe("read\n");
459
+ expect(readFileSync(trace, "utf8")).toBe("");
460
+ expect(inputs()).toEqual(before);
461
+ return result;
462
+ }
463
+
464
+ test("missing operand and closed-input prompt refuse without effects", async () => {
465
+ await refuse(["--version"], "version is required");
466
+ await refuse([], "version is required");
467
+ });
468
+
469
+ test("invalid version never reflects its payload", async () => {
470
+ await refuse(
471
+ ["--version", "synthetic-version-secret\n$(not-run)"],
472
+ "invalid version",
473
+ "synthetic-version-secret",
474
+ );
475
+ });
476
+
477
+ test("valid mismatched version refuses before any copy", async () => {
478
+ removeFixtureBackups();
479
+ await refuse(["--version", "0.2.2", "--yes"], "source version validation failed");
480
+ expect(readFileSync(env.VALIDATOR_TRACE, "utf8")).toBe("read\n");
481
+ });
482
+
483
+ for (const name of ["core", "mcp", "cli"]) {
484
+ for (const invalid of ['{"version":"0.2.2"}', "not-json", '{"version":21}']) {
485
+ test(`${name} manifest refuses invalid source data ${invalid}`, async () => {
486
+ removeFixtureBackups();
487
+ writeFileSync(join(root, "packages", name, "package.json"), invalid);
488
+ await refuse(["--version", "0.2.1"], "source version validation failed");
489
+ });
490
+ }
491
+ }
492
+
493
+ for (const descriptor of [
494
+ {
495
+ version: "0.2.2",
496
+ packages: [{ registryType: "npm", identifier: "synthetic-mcp", version: "0.2.1" }],
497
+ },
498
+ {
499
+ version: "0.2.1",
500
+ packages: [{ registryType: "npm", identifier: "synthetic-mcp", version: "0.2.2" }],
501
+ },
502
+ {
503
+ version: "0.2.1",
504
+ packages: [{ registryType: "npm", identifier: "wrong", version: "0.2.1" }],
505
+ },
506
+ {
507
+ version: "0.2.1",
508
+ packages: Array.from({ length: 2 }, () => ({
509
+ registryType: "npm",
510
+ identifier: "synthetic-mcp",
511
+ version: "0.2.1",
512
+ })),
513
+ },
514
+ ]) {
515
+ test(`descriptor mismatch refuses ${JSON.stringify(descriptor)}`, async () => {
516
+ removeFixtureBackups();
517
+ writeFileSync(join(root, "packages/mcp/server.json"), JSON.stringify(descriptor));
518
+ await refuse(["--version", "0.2.1"], "source version validation failed");
519
+ });
520
+ }
521
+
522
+ for (const cli of [
523
+ 'const VERSION = "0.2.2";',
524
+ '// const VERSION = "0.2.1";',
525
+ 'const VERSION = "0.2.1";\nconst VERSION = "0.2.1";',
526
+ "const VERSION = getVersion();",
527
+ ]) {
528
+ test(`CLI source must have one matching literal ${cli}`, async () => {
529
+ removeFixtureBackups();
530
+ writeFileSync(join(root, "packages/cli/src/index.ts"), cli);
531
+ await refuse(["--version", "0.2.1"], "source version validation failed");
532
+ });
533
+ }
534
+
535
+ for (const kind of ["missing", "symlink", "directory"]) {
536
+ test(`source reader refuses ${kind} inputs`, async () => {
537
+ removeFixtureBackups();
538
+ const path = join(root, "packages/mcp/server.json");
539
+ rmSync(path);
540
+ if (kind === "directory") mkdirSync(path);
541
+ if (kind === "symlink") symlinkSync(join(root, "packages/core/package.json"), path);
542
+ // Directory snapshot uses its existing retained fixture convention.
543
+ if (kind === "directory") writeFileSync(join(path, "retained.txt"), "retained");
544
+ const before = backupSnapshot(path);
545
+ const result = await runFixture(
546
+ "/bin/bash",
547
+ ["--noprofile", "--norc", script, "--version", "0.2.1"],
548
+ root,
549
+ env,
550
+ );
551
+ expect(result.error).toBeUndefined();
552
+ expect(result.signal).toBeNull();
553
+ expect(result.timedOut).toBe(false);
554
+ expect(result.outputLimitExceeded).toBe(false);
555
+ expect(result.status).toBe(1);
556
+ expect(result.stderr).toContain("source version validation failed");
557
+ expect(readFileSync(env.VALIDATOR_TRACE, "utf8")).toBe("read\n");
558
+ expect(readFileSync(trace, "utf8")).toBe("");
559
+ expect(backupSnapshot(path)).toEqual(before);
560
+ });
561
+ }
562
+
563
+ function cannedGit(tag: string, head: string, fail = false) {
564
+ writeFileSync(
565
+ join(root, "bin/git"),
566
+ `#!/bin/bash
567
+ if [[ "$*" == 'rev-parse --verify refs/tags/v0.2.1^{commit}' ]]; then
568
+ ${fail ? "exit 1" : `printf '%s\\n' '${tag}'`}
569
+ elif [[ "$*" == 'rev-parse --verify HEAD' ]]; then
570
+ printf '%s\\n' '${head}'
571
+ else
572
+ printf 'git\\n' >> "$SCRIPT_FIXTURE_TRACE"; exit 97
573
+ fi
574
+ `,
575
+ { mode: 0o700 },
576
+ );
577
+ }
578
+
579
+ for (const kind of ["version", "commit", "missing"]) {
580
+ test(`selected tag ${kind} mismatch refuses without mutation`, async () => {
581
+ removeFixtureBackups();
582
+ env.GITHUB_REF = kind === "version" ? "refs/tags/v0.2.2" : "refs/tags/v0.2.1";
583
+ cannedGit("tag", "head", kind === "missing");
584
+ await refuse(["--version", "0.2.1"], "selected tag validation failed");
585
+ });
586
+ }
587
+
588
+ test("matching selected tag reaches only the inert original copy boundary", async () => {
589
+ removeFixtureBackups();
590
+ env.GITHUB_REF = "refs/tags/v0.2.1";
591
+ cannedGit("same", "same");
592
+ writeFileSync(
593
+ join(root, "bin/cp"),
594
+ '#!/bin/bash\nprintf "cp\\n" >> "$SCRIPT_FIXTURE_TRACE"\nexit 1\n',
595
+ { mode: 0o700 },
596
+ );
597
+ const result = await runBackupCase();
598
+ expect(result.status).toBe(1);
599
+ expect(readFileSync(trace, "utf8")).toBe("cp\n");
600
+ expect(readFileSync(env.VALIDATOR_TRACE, "utf8")).toBe("read\n");
601
+ });
602
+
603
+ async function preflight(
604
+ name: string,
605
+ tag: string,
606
+ otp: string,
607
+ ref = "refs/heads/main",
608
+ event = "workflow_dispatch",
609
+ ) {
610
+ const block = workflowBlock(name);
611
+ // Workflow-level RELEASE_TAG: the pushed tag, or the dispatch tag input.
612
+ env.RELEASE_TAG = tag;
613
+ env.RELEASE_INPUT_OTP = otp;
614
+ env.GITHUB_REF = ref;
615
+ env.GITHUB_EVENT_NAME = event;
616
+ env.GITHUB_OUTPUT = join(root, "github-output");
617
+ writeFileSync(env.GITHUB_OUTPUT, "", { mode: 0o600 });
618
+ const before = inputs();
619
+ const result = await runFixture("/bin/bash", ["--noprofile", "--norc", "-c", block], root, env);
620
+ expect(result.error).toBeUndefined();
621
+ expect(result.signal).toBeNull();
622
+ expect(result.timedOut).toBe(false);
623
+ expect(result.outputLimitExceeded).toBe(false);
624
+ expect(readFileSync(trace, "utf8")).toBe("");
625
+ expect(inputs()).toEqual(before);
626
+ return { ...result, output: `${result.stdout}${result.stderr}` };
627
+ }
628
+
629
+ for (const name of [
630
+ "Validate release source before producers",
631
+ "Validate release source before installs",
632
+ ]) {
633
+ for (const otp of ["synthetic-otp-secret", " ", "$(synthetic-otp-secret)"]) {
634
+ test(`${name} refuses nonempty unsupported OTP before subprocesses ${JSON.stringify(otp)}`, async () => {
635
+ const result = await preflight(name, "v0.2.1", otp);
636
+ expect(result.status).toBe(1);
637
+ expect(result.output).toContain("release input/source validation failed");
638
+ expect(result.output).not.toContain(otp.trim() || "OTP");
639
+ expect(readFileSync(env.VALIDATOR_TRACE, "utf8")).toBe("");
640
+ expect(readFileSync(env.GITHUB_OUTPUT, "utf8")).toBe("");
641
+ });
642
+ }
643
+ test(`${name} validates dispatched tag source and exports only its version`, async () => {
644
+ cannedGit("same", "same");
645
+ const result = await preflight(name, "v0.2.1", "");
646
+ expect(result.status).toBe(0);
647
+ expect(readFileSync(env.VALIDATOR_TRACE, "utf8")).toBe("read\n");
648
+ expect(readFileSync(env.GITHUB_OUTPUT, "utf8")).toBe("version=0.2.1\n");
649
+ });
650
+ test(`${name} refuses absent, malformed and mismatched dispatch tags`, async () => {
651
+ cannedGit("same", "same");
652
+ for (const tag of ["", "vsynthetic-version-secret", "v0.2.2"]) {
653
+ const result = await preflight(name, tag, "");
654
+ expect(result.status).toBe(1);
655
+ expect(result.output).not.toContain("synthetic-version-secret");
656
+ expect(readFileSync(env.GITHUB_OUTPUT, "utf8")).toBe("");
657
+ }
658
+ });
659
+ test(`${name} refuses a dispatched tag that is not the checked-out HEAD`, async () => {
660
+ cannedGit("tag", "head");
661
+ const result = await preflight(name, "v0.2.1", "");
662
+ expect(result.status).toBe(1);
663
+ // Source validation ran, so the refusal is the tag/HEAD check.
664
+ expect(readFileSync(env.VALIDATOR_TRACE, "utf8")).toBe("read\n");
665
+ expect(readFileSync(env.GITHUB_OUTPUT, "utf8")).toBe("");
666
+ });
667
+ test(`${name} validates actual selected-tag coherence`, async () => {
668
+ cannedGit("same", "same");
669
+ const matching = await preflight(name, "v0.2.1", "", "refs/tags/v0.2.1", "push");
670
+ expect(matching.status).toBe(0);
671
+ const mismatch = await preflight(name, "v0.2.2", "", "refs/tags/v0.2.1", "push");
672
+ expect(mismatch.status).toBe(1);
673
+ expect(readFileSync(env.GITHUB_OUTPUT, "utf8")).toBe("");
674
+ });
675
+ }
676
+
677
+ test("actual npm caller quotes one version, supplies no OTP and propagates failure", async () => {
678
+ const block = workflowBlock("Publish @ashlr/stack-core + @ashlr/stack + ashlr-stack-mcp");
679
+ const argv = join(root, "caller-argv");
680
+ writeFileSync(
681
+ join(root, "bin/bash"),
682
+ `#!/bin/bash
683
+ printf '%s\\n' "$@" > "${argv}"
684
+ exit 29
685
+ `,
686
+ { mode: 0o700 },
687
+ );
688
+ env.RELEASE_VERSION = "synthetic version ; $(not-run)";
689
+ const result = await runFixture("/bin/bash", ["--noprofile", "--norc", "-c", block], root, env);
690
+ expect(result.error).toBeUndefined();
691
+ expect(result.signal).toBeNull();
692
+ expect(result.timedOut).toBe(false);
693
+ expect(result.outputLimitExceeded).toBe(false);
694
+ expect(result.status).toBe(29);
695
+ expect(readFileSync(argv, "utf8").split("\n")).toEqual([
696
+ "scripts/publish.sh",
697
+ "--version",
698
+ env.RELEASE_VERSION,
699
+ "--yes",
700
+ "",
701
+ ]);
702
+ expect(readFileSync(trace, "utf8")).toBe("");
703
+ });
704
+ });