@ashlr/stack-core 0.1.1 → 0.2.2

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