@ashlr/stack-core 0.3.1 → 0.3.6

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 (54) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/_harness.ts +65 -39
  3. package/src/__tests__/apply-placeholder-history.test.ts +55 -0
  4. package/src/__tests__/apply-placeholder-operator-checklist.test.ts +28 -0
  5. package/src/__tests__/apply-terminology-examples.test.ts +46 -0
  6. package/src/__tests__/config.test.ts +36 -0
  7. package/src/__tests__/credential-staging.test.ts +7 -6
  8. package/src/__tests__/deps.test.ts +35 -0
  9. package/src/__tests__/detect-source.test.ts +97 -0
  10. package/src/__tests__/fixtures/stack-manifest.v1.json +3 -3
  11. package/src/__tests__/phantom-process.test.ts +21 -3
  12. package/src/__tests__/phantom-wire-failing-subprocess.test.ts +126 -0
  13. package/src/__tests__/phantom-wire-repeated-placeholders.test.ts +84 -0
  14. package/src/__tests__/phantom-wire-unicode-combining.test.ts +100 -0
  15. package/src/__tests__/phantom-wire.test.ts +6329 -363
  16. package/src/__tests__/pipeline-integration.test.ts +26 -2
  17. package/src/__tests__/recommend-apply-e2e.test.ts +2 -2
  18. package/src/__tests__/secret-redaction.test.ts +13 -3
  19. package/src/ai/phantom-wire-adjacent-different-providers.test.ts +78 -0
  20. package/src/ai/phantom-wire-adjacent-known-placeholders.test.ts +266 -0
  21. package/src/ai/phantom-wire-args.test.ts +63 -0
  22. package/src/ai/phantom-wire-blank-lines.test.ts +174 -0
  23. package/src/ai/phantom-wire-carriage-return.test.ts +125 -0
  24. package/src/ai/phantom-wire-case-changed-placeholder.test.ts +209 -0
  25. package/src/ai/phantom-wire-closing-brace.test.ts +16 -0
  26. package/src/ai/phantom-wire-crlf.test.ts +48 -0
  27. package/src/ai/phantom-wire-dollar-amp.test.ts +128 -0
  28. package/src/ai/phantom-wire-double-quote.test.ts +135 -0
  29. package/src/ai/phantom-wire-empty-replacement.test.ts +122 -0
  30. package/src/ai/phantom-wire-empty-stderr.test.ts +145 -0
  31. package/src/ai/phantom-wire-empty-template.test.ts +81 -0
  32. package/src/ai/phantom-wire-empty-values.test.ts +129 -0
  33. package/src/ai/phantom-wire-errors.test.ts +296 -0
  34. package/src/ai/phantom-wire-known-placeholder-adjacent-punctuation.test.ts +188 -0
  35. package/src/ai/phantom-wire-known-placeholder-at-end.test.ts +160 -0
  36. package/src/ai/phantom-wire-known-placeholder.test.ts +170 -0
  37. package/src/ai/phantom-wire-malformed-json.test.ts +227 -0
  38. package/src/ai/phantom-wire-missing.test.ts +123 -0
  39. package/src/ai/phantom-wire-mixed-placeholders.test.ts +183 -0
  40. package/src/ai/phantom-wire-nested-placeholder.test.ts +149 -0
  41. package/src/ai/phantom-wire-newline.test.ts +136 -0
  42. package/src/ai/phantom-wire-order.test.ts +173 -0
  43. package/src/ai/phantom-wire-placeholder-two-lines.test.ts +196 -0
  44. package/src/ai/phantom-wire-repeated-known-placeholder.test.ts +241 -0
  45. package/src/ai/phantom-wire-repeated-unknown-placeholder.test.ts +55 -0
  46. package/src/ai/phantom-wire-replacement-dollar.test.ts +170 -0
  47. package/src/ai/phantom-wire-single-quote.test.ts +22 -0
  48. package/src/ai/phantom-wire-tab.test.ts +42 -0
  49. package/src/ai/phantom-wire-unknown-placeholder.test.ts +189 -0
  50. package/src/ai/phantom-wire-vault-name-case.test.ts +46 -0
  51. package/src/ai/phantom-wire.test.ts +144 -0
  52. package/src/ai/phantom-wire.ts +11 -4
  53. package/src/phantom.ts +10 -1
  54. package/src/stack-manifest.ts +3 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashlr/stack-core",
3
- "version": "0.3.1",
3
+ "version": "0.3.6",
4
4
  "description": "Ashlr Stack core — provider adapters, .stack.toml config, Phantom integration. Shared logic behind the Stack CLI and MCP server.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -1,4 +1,4 @@
1
- import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { __resetPhantomCache } from "../phantom.ts";
@@ -53,19 +53,60 @@ export function setupFakePhantom(
53
53
  writeFileSync(vaultPath, JSON.stringify(initial));
54
54
  writeFileSync(logPath, "");
55
55
 
56
- // The vault/log paths are trusted (we created them), but ALL args ($1..$N)
57
- // come from tests that may contain user-shaped secret values with quotes,
58
- // newlines, backslashes, etc. Those must NEVER be interpolated into Python
59
- // source — instead we pass them via sys.argv so the shell + Python handle
60
- // quoting for us.
56
+ // Each fake-phantom call used to spawn python3 twice; interpreter startup
57
+ // dominated the suite. One tiny Bun helper (same runtime as the tests) does
58
+ // the JSON work instead. ALL args ($1..$N) may contain user-shaped values
59
+ // with quotes, newlines or backslashes, so they reach the helper only through
60
+ // argv and are never interpolated into source.
61
+ const helperPath = join(dir, "helper.mjs");
62
+ writeFileSync(
63
+ helperPath,
64
+ `import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
65
+ const [op, vault, ...rest] = process.argv.slice(2);
66
+ const load = () => JSON.parse(readFileSync(vault, "utf8"));
67
+ if (op === "log") {
68
+ appendFileSync(vault, JSON.stringify({ cmd: rest[0] ?? "", args: rest.slice(1) }) + "\\n");
69
+ } else if (op === "add") {
70
+ const v = load();
71
+ if (rest[0] in v) {
72
+ process.stderr.write("Error: Secret '" + rest[0] + "' is already protected. phantom add creates new names only and refuses replacement before reading a value.\\n");
73
+ process.exit(1);
74
+ }
75
+ const value = readFileSync(0, "utf8").split("\\n")[0].replace(/\\r+$/, "");
76
+ if (!value) {
77
+ process.stderr.write("Error: Received empty value on stdin\\n");
78
+ process.exit(1);
79
+ }
80
+ v[rest[0]] = value;
81
+ writeFileSync(vault, JSON.stringify(v));
82
+ } else if (op === "remove") {
83
+ const v = load();
84
+ delete v[rest[0]];
85
+ writeFileSync(vault, JSON.stringify(v));
86
+ } else if (op === "list") {
87
+ const v = load();
88
+ const names = Object.keys(v);
89
+ if (rest.includes("--json")) {
90
+ console.log(JSON.stringify(names.map((name) => ({ name, detected_service: null })), null, 2));
91
+ } else {
92
+ console.log("-> " + names.length + " secret(s) in vault (encrypted-file):\\n");
93
+ for (const k of names) console.log(" - " + k);
94
+ console.log("\\nnote Values are never displayed. Use phantom add/remove to manage.");
95
+ }
96
+ } else if (op === "reveal") {
97
+ console.log(load()[rest[0]] ?? "");
98
+ }
99
+ `,
100
+ );
61
101
  const script = `#!/usr/bin/env bash
62
102
  set -e
63
103
  VAULT=${JSON.stringify(vaultPath)}
64
104
  LOG=${JSON.stringify(logPath)}
105
+ H() { ${JSON.stringify(process.execPath)} ${JSON.stringify(helperPath)} "$@"; }
65
106
  LEGACY_HEADLESS_REVEAL=${options.legacyHeadlessReveal ? 1 : 0}
66
107
 
67
108
  # Log the invocation as a single JSON line (args passed via argv, never interpolated).
68
- python3 -c 'import json,sys; open(sys.argv[1], "a").write(json.dumps({"cmd": sys.argv[2] if len(sys.argv) > 2 else "", "args": sys.argv[3:]}) + "\\n")' "$LOG" "$@" 2>/dev/null || true
109
+ H log "$LOG" "$@" 2>/dev/null || true
69
110
 
70
111
  case "\${1:-}" in
71
112
  --version)
@@ -90,18 +131,7 @@ case "\${1:-}" in
90
131
  echo "Error: Positional secret values are disabled because command-line arguments can be exposed by process inspection. Omit the value for a hidden terminal prompt, or use --stdin." >&2
91
132
  exit 1
92
133
  fi
93
- python3 -c 'import json,sys
94
- with open(sys.argv[1]) as f: v = json.load(f)
95
- if sys.argv[2] in v:
96
- sys.stderr.write("Error: Secret %r is already protected. phantom add creates new names only and refuses replacement before reading a value.\\n" % sys.argv[2])
97
- sys.exit(1)
98
- value = sys.stdin.readline().rstrip("\\r\\n")
99
- if not value:
100
- sys.stderr.write("Error: Received empty value on stdin\\n")
101
- sys.exit(1)
102
- v[sys.argv[2]] = value
103
- with open(sys.argv[1], "w") as f: json.dump(v, f)
104
- ' "$VAULT" "$2"
134
+ H add "$VAULT" "$2"
105
135
  ;;
106
136
  remove)
107
137
  # Phantom Secrets >= 0.7.3: typed confirmation in an attached terminal.
@@ -109,23 +139,11 @@ with open(sys.argv[1], "w") as f: json.dump(v, f)
109
139
  echo "Error: phantom remove requires attached stdin, stdout, and stderr terminals and cannot run headlessly." >&2
110
140
  exit 1
111
141
  fi
112
- python3 -c 'import json,sys
113
- with open(sys.argv[1]) as f: v = json.load(f)
114
- v.pop(sys.argv[2], None)
115
- with open(sys.argv[1], "w") as f: json.dump(v, f)
116
- ' "$VAULT" "$2"
142
+ H remove "$VAULT" "$2"
117
143
  ;;
118
144
  list)
119
145
  # Real Phantom: \`list --json\` prints [{"name": ...}, ...] (names only).
120
- python3 -c 'import json,sys
121
- with open(sys.argv[1]) as f: v = json.load(f)
122
- if "--json" in sys.argv[2:]:
123
- print(json.dumps([{"name": k, "detected_service": None} for k in v], indent=2))
124
- else:
125
- print("-> %d secret(s) in vault (encrypted-file):\\n" % len(v))
126
- for k in v: print(" - " + k)
127
- print("\\nnote Values are never displayed. Use phantom add/remove to manage.")
128
- ' "$VAULT" "\${@:2}"
146
+ H list "$VAULT" "\${@:2}"
129
147
  ;;
130
148
  reveal)
131
149
  # Phantom Secrets >= 0.7.3: plaintext only into an attached terminal.
@@ -133,10 +151,7 @@ else:
133
151
  echo "Error: Refusing to reveal a secret without attached stdin and stderr terminals" >&2
134
152
  exit 1
135
153
  fi
136
- python3 -c 'import json,sys
137
- with open(sys.argv[1]) as f: v = json.load(f)
138
- print(v.get(sys.argv[2], ""))
139
- ' "$VAULT" "$2"
154
+ H reveal "$VAULT" "$2"
140
155
  ;;
141
156
  init)
142
157
  # no-op for the fake — the vault file already exists
@@ -149,8 +164,19 @@ print(v.get(sys.argv[2], ""))
149
164
  esac
150
165
  `;
151
166
  const binPath = join(dir, "phantom");
152
- writeFileSync(binPath, script);
153
- chmodSync(binPath, 0o755);
167
+ // Sandboxed runners and macOS Gatekeeper's provenance policy refuse to
168
+ // exec an extensionless file written by a non-platform process, while a
169
+ // `*.sh` target passes the check. Write the shim as `phantom.sh` and link
170
+ // `phantom` at it; fall back to the direct file where symlinks fail.
171
+ const realPath = join(dir, "phantom.sh");
172
+ writeFileSync(realPath, script);
173
+ chmodSync(realPath, 0o755);
174
+ try {
175
+ symlinkSync(realPath, binPath);
176
+ } catch {
177
+ writeFileSync(binPath, script);
178
+ chmodSync(binPath, 0o755);
179
+ }
154
180
 
155
181
  const previousPath = process.env.PATH ?? "";
156
182
  process.env.PATH = `${dir}:${previousPath}`;
@@ -0,0 +1,55 @@
1
+ import { expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ const docUrl = new URL("../../../../docs/apply-placeholder-history.md", import.meta.url);
5
+ const history = readFileSync(docUrl, "utf8");
6
+ const location = readFileSync(
7
+ new URL("../../../../docs/apply-history-location.md", import.meta.url),
8
+ "utf8",
9
+ );
10
+
11
+ test.each([
12
+ [
13
+ "../CHANGELOG.md",
14
+ "010--2026-04-19-superseded-by-011",
15
+ "## 0.1.0 — 2026-04-19 (superseded by 0.1.1)",
16
+ "- **`stack apply [recipe-id]`**",
17
+ ],
18
+ [
19
+ "../packages/site/src/data/CHANGELOG.md",
20
+ "010--2026-04-19-superseded-by-011",
21
+ "## 0.1.0 — 2026-04-19 (superseded by 0.1.1)",
22
+ "- **`stack apply [recipe-id]`**",
23
+ ],
24
+ [
25
+ "../CHANGELOG.md",
26
+ "021-2026-10-16",
27
+ "## 0.2.1 (2026-10-16)",
28
+ "- **`stack apply` stops attempting to write placeholder secrets.**",
29
+ ],
30
+ [
31
+ "../packages/site/src/data/CHANGELOG.md",
32
+ "021-2026-10-16",
33
+ "## 0.2.1 (2026-10-16)",
34
+ "- **`stack apply` stops attempting to write placeholder secrets.**",
35
+ ],
36
+ ])("history links to the relevant entry in %s#%s", (path, anchor, heading, entryPrefix) => {
37
+ expect(history).toContain(`](${path}#${anchor})`);
38
+ expect(location).toContain(`](${path}#${anchor})`);
39
+ const changelog = readFileSync(new URL(path, docUrl), "utf8");
40
+ const release = changelog.split(`${heading}\n`)[1]?.split("\n## ")[0];
41
+ expect(release).toBeDefined();
42
+ const entry = release?.split("\n").find((line) => line.startsWith(entryPrefix));
43
+ expect(entry).toContain("TODO_STACK_APPLY");
44
+ expect(entry).toContain("failed");
45
+ expect(entry).toMatch(
46
+ /(?:does not write|never writes) to the vault or fills? (?:in )?missing values/,
47
+ );
48
+ });
49
+
50
+ test("location distinguishes failed historical attempts from the fix", () => {
51
+ expect(location).toContain("Phantom rejected those attempts");
52
+ expect(location).toContain("The 0.2.1 fix removed the attempts");
53
+ expect(location).toContain("provider setup may separately store real");
54
+ expect(location).toContain("](../CHANGELOG.md#022-2026-10-25)");
55
+ });
@@ -0,0 +1,28 @@
1
+ import { expect, it } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ const checklist = readFileSync(
5
+ new URL("../../../../docs/apply-placeholder-operator-checklist.md", import.meta.url),
6
+ "utf8",
7
+ );
8
+ const changelog = readFileSync(new URL("../../../../CHANGELOG.md", import.meta.url), "utf8");
9
+
10
+ it("identifies the historical marker as a failed attempt, never an operator value", () => {
11
+ const marker = "TODO_STACK_APPLY";
12
+ expect(changelog).toContain(marker);
13
+ expect(checklist).toContain(marker);
14
+ expect(checklist).toContain("attempts failed");
15
+ expect(checklist).toContain("Do not use the marker as a secret value");
16
+ for (const block of checklist.matchAll(/```[^\n]*\n([\s\S]*?)```/g)) {
17
+ expect(block[1]).not.toContain(marker);
18
+ }
19
+ });
20
+
21
+ it("directs operators to enter real values and treats incomplete inventory as unknown", () => {
22
+ expect(checklist).toContain("`phantom add <NAME>`");
23
+ expect(checklist).toContain("`phantom add OPENAI_API_KEY`");
24
+ expect(checklist).toContain("enter the real value when prompted");
25
+ expect(checklist).toContain("do not put the value in command");
26
+ expect(checklist).toContain("`skipped`, not missing");
27
+ expect(checklist).toContain("`--noWire` skips both name checks and webhook");
28
+ });
@@ -0,0 +1,46 @@
1
+ import { afterEach, beforeEach, expect, it, setDefaultTimeout } from "bun:test";
2
+ import { readFileSync, writeFileSync } from "node:fs";
3
+ import { wirePhantomForRecipe } from "../ai/phantom-wire.ts";
4
+ import type { Recipe } from "../ai/recipe.ts";
5
+ import { type Harness, setupFakePhantom } from "./_harness.ts";
6
+
7
+ setDefaultTimeout(30_000);
8
+
9
+ const doc = readFileSync(
10
+ new URL("../../../../docs/apply-terminology-examples.md", import.meta.url),
11
+ "utf-8",
12
+ );
13
+ const examples = [...doc.matchAll(/```json\n([^\n]+)\n```/g)].map((match) => JSON.parse(match[1]));
14
+ const recipe: Recipe = {
15
+ id: "example",
16
+ query: "example",
17
+ createdAt: "2026-04-18T00:00:00.000Z",
18
+ providers: [{ name: "openai" }],
19
+ };
20
+
21
+ let harness: Harness;
22
+ beforeEach(() => {
23
+ harness = setupFakePhantom({ OPENAI_API_KEY: "test-value" });
24
+ });
25
+ afterEach(() => harness.cleanup());
26
+
27
+ it("documents the readable inventory examples without writing placeholders", async () => {
28
+ expect(examples).toHaveLength(3);
29
+ expect(await wirePhantomForRecipe(recipe, { cwd: harness.dir })).toEqual(examples[0]);
30
+ writeFileSync(harness.vaultPath, "{}");
31
+ expect(await wirePhantomForRecipe(recipe, { cwd: harness.dir })).toEqual(examples[1]);
32
+ expect(harness.callsTo("add")).toEqual([]);
33
+ });
34
+
35
+ it("documents an unreadable inventory alongside the generated Stripe stub", async () => {
36
+ writeFileSync(harness.vaultPath, "not json");
37
+ const result = await wirePhantomForRecipe(
38
+ { ...recipe, providers: [{ name: "stripe" }] },
39
+ { cwd: harness.dir },
40
+ );
41
+ expect({
42
+ ...result,
43
+ webhooks: result.webhooks.map((path) => path.replace(harness.dir, "<project>")),
44
+ }).toEqual(examples[2]);
45
+ expect(harness.callsTo("add")).toEqual([]);
46
+ });
@@ -68,6 +68,42 @@ describe("config", () => {
68
68
  expect(roundTripped.services.supabase).toEqual(original.services.supabase);
69
69
  });
70
70
 
71
+ test("a full configuration survives serialize → parse with every value preserved", async () => {
72
+ const cwd = freshCwd();
73
+ const original = emptyConfig("saas");
74
+ original.services.supabase = {
75
+ provider: "supabase",
76
+ resource_id: "abcd1234",
77
+ region: "us-east-1",
78
+ secrets: ["SUPABASE_URL", "SUPABASE_ANON_KEY"],
79
+ mcp: "supabase",
80
+ meta: { plan: "free", replicas: 2, nested: { ok: true, tags: ["a", "b"] } },
81
+ created_at: "2026-04-17T05:00:00Z",
82
+ created_by: "test",
83
+ };
84
+ original.services.sentry = {
85
+ provider: "sentry",
86
+ secrets: [],
87
+ created_at: "2026-04-18T05:00:00Z",
88
+ };
89
+ original.environments = [
90
+ { name: "dev", default: true },
91
+ { name: "prod", overrides: { SUPABASE_URL: "SUPABASE_URL_PROD" } },
92
+ ];
93
+
94
+ await writeConfig(original, cwd);
95
+ const parsed = await readConfig(cwd);
96
+ expect(parsed).toEqual(original);
97
+
98
+ // Re-serializing the parsed config is stable: the files do not drift.
99
+ const shapeBefore = readFileSync(join(cwd, CONFIG_FILENAME), "utf-8");
100
+ const localBefore = readFileSync(join(cwd, LOCAL_FILENAME), "utf-8");
101
+ await writeConfig(parsed, cwd);
102
+ expect(readFileSync(join(cwd, CONFIG_FILENAME), "utf-8")).toBe(shapeBefore);
103
+ expect(readFileSync(join(cwd, LOCAL_FILENAME), "utf-8")).toBe(localBefore);
104
+ expect(await readConfig(cwd)).toEqual(original);
105
+ });
106
+
71
107
  test("readConfig throws ConfigNotFoundError when file is missing", async () => {
72
108
  const cwd = freshCwd();
73
109
  await expect(readConfig(cwd)).rejects.toThrow(/No .stack.toml at/);
@@ -242,12 +242,13 @@ describe("operation-owned credential persistence", () => {
242
242
  });
243
243
  test("reads/writes stay at captured absolute cwd despite ctx and opts mutation", async () => {
244
244
  // Add cwd metadata to the inert producer log, never secret input values.
245
- const bin = join(h.dir, "phantom");
245
+ const helper = join(h.dir, "helper.mjs");
246
246
  writeFileSync(
247
- bin,
248
- readFileSync(bin, "utf8")
249
- .replace("import json,sys; open", "import json,sys,os; open")
250
- .replace('{"cmd": sys.argv[2]', '{"cwd": os.getcwd(), "cmd": sys.argv[2]'),
247
+ helper,
248
+ readFileSync(helper, "utf8").replace(
249
+ "JSON.stringify({ cmd: rest[0]",
250
+ "JSON.stringify({ cwd: process.cwd(), cmd: rest[0]",
251
+ ),
251
252
  );
252
253
  const opts = { providerName: "stagefixture", cwd, interactive: true };
253
254
  const p = factory();
@@ -267,7 +268,7 @@ describe("operation-owned credential persistence", () => {
267
268
  .trim()
268
269
  .split("\n")
269
270
  .map((line) => JSON.parse(line));
270
- // Python getcwd resolves filesystem aliases such as macOS /var.
271
+ // process.cwd() resolves filesystem aliases such as macOS /var.
271
272
  for (const row of rows.filter((r) => ["status", "reveal", "add"].includes(r.cmd)))
272
273
  expect(row.cwd).toBe(realpathSync(cwd));
273
274
  expect((await readConfig(cwd)).services.stagefixture).toBeDefined();
@@ -2,9 +2,22 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { buildDepsReport } from "../../../cli/src/commands/deps.ts";
5
6
  import { emptyConfig, writeConfig } from "../config.ts";
6
7
  import { getProvider, listProviderNames } from "../providers/index.ts";
7
8
 
9
+ test("an empty service list resolves to an empty dependency report", async () => {
10
+ const config = emptyConfig();
11
+ expect(await buildDepsReport(config)).toEqual({
12
+ schema: "ashlr.stack.deps/v1",
13
+ project_id: config.stack.project_id,
14
+ template: null,
15
+ categories: [],
16
+ unresolved: [],
17
+ totals: { services: 0, secrets: 0, mcp: 0 },
18
+ });
19
+ });
20
+
8
21
  describe("provider loading — wave 9 additions", () => {
9
22
  test("render + firebase are in the catalog", () => {
10
23
  const names = listProviderNames();
@@ -56,4 +69,26 @@ describe("config carrying 21+ provider entries still round-trips", () => {
56
69
  const roundtrip = await readConfig(cwd);
57
70
  expect(Object.keys(roundtrip.services).length).toBe(listProviderNames().length);
58
71
  });
72
+ test("duplicate secret entries are preserved verbatim (no dedupe on round-trip)", async () => {
73
+ const config = emptyConfig("dupes");
74
+ config.services.render = {
75
+ provider: "render",
76
+ secrets: ["RENDER_API_KEY", "RENDER_API_KEY"],
77
+ created_at: new Date().toISOString(),
78
+ };
79
+ await writeConfig(config, cwd);
80
+ const { readConfig } = await import("../config.ts");
81
+ const roundtrip = await readConfig(cwd);
82
+ expect(roundtrip.services.render?.secrets).toEqual(["RENDER_API_KEY", "RENDER_API_KEY"]);
83
+ });
84
+
85
+ test("a duplicated [services.x] table in .stack.toml is rejected, not merged", async () => {
86
+ const { readConfig, CONFIG_FILENAME } = await import("../config.ts");
87
+ const toml =
88
+ '[stack]\nversion = "1"\n\n' +
89
+ '[services.render]\nprovider = "render"\nsecrets = ["A"]\n\n' +
90
+ '[services.render]\nprovider = "render"\nsecrets = ["B"]\n';
91
+ await Bun.write(join(cwd, CONFIG_FILENAME), toml);
92
+ await expect(readConfig(cwd)).rejects.toThrow();
93
+ });
59
94
  });
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync
3
3
  import { tmpdir } from "node:os";
4
4
  import { delimiter, dirname, join } from "node:path";
5
5
  import { scanSource } from "../detect-source.ts";
6
+ import { SourceDirectoryNotFoundError } from "../errors.ts";
6
7
 
7
8
  const projects: string[] = [];
8
9
  afterEach(() => {
@@ -152,6 +153,26 @@ require (
152
153
  });
153
154
  });
154
155
 
156
+ test.each([
157
+ ["dependencies", "openai"],
158
+ ["dependencies", ["openai"]],
159
+ ["devDependencies", "openai"],
160
+ ["devDependencies", ["openai"]],
161
+ ] as const)("invalid %s section %j preserves a valid sibling", async (section, invalid) => {
162
+ const sibling = section === "dependencies" ? "devDependencies" : "dependencies";
163
+ const hits = await scanSource(
164
+ makeProject({
165
+ "package.json": JSON.stringify({
166
+ [section]: invalid,
167
+ [sibling]: { openai: "^4.0.0" },
168
+ }),
169
+ }),
170
+ );
171
+ expect(hits).toEqual([
172
+ { provider: "openai", confidence: "high", signals: ["package.json:openai"] },
173
+ ]);
174
+ });
175
+
155
176
  test.each(["{broken", "null", "{}"])(
156
177
  "malformed or empty package %s does not hide other evidence",
157
178
  async (raw) => {
@@ -218,6 +239,36 @@ test("deduplicates dependency evidence and upgrades confidence without losing si
218
239
  ]);
219
240
  });
220
241
 
242
+ test("repeated scans of an unchanged project return the same unique detections", async () => {
243
+ const cwd = makeProject({
244
+ "package.json": JSON.stringify({
245
+ dependencies: { openai: "1", "@vercel/kv": "1" },
246
+ devDependencies: { openai: "1" },
247
+ }),
248
+ "requirements.txt": "openai==1\nopenai>=1\n",
249
+ "vercel.json": "{}",
250
+ ".env.example": "OPENAI_API_KEY=\nOPENAI_API_KEY=\nVERCEL_TOKEN=",
251
+ });
252
+ const expected: Awaited<ReturnType<typeof scanSource>> = [
253
+ {
254
+ provider: "openai",
255
+ confidence: "high",
256
+ signals: ["package.json:openai", "requirements.txt:openai", ".env.example:OPENAI_API_KEY"],
257
+ },
258
+ {
259
+ provider: "vercel",
260
+ confidence: "high",
261
+ signals: ["package.json:@vercel/kv", "vercel.json", ".env.example:VERCEL_TOKEN"],
262
+ },
263
+ ];
264
+
265
+ const first = await scanSource(cwd);
266
+ const second = await scanSource(cwd);
267
+
268
+ expect(first).toEqual(expected);
269
+ expect(second).toEqual(expected);
270
+ });
271
+
221
272
  test("scans a project directory containing spaces and reports relative file paths", async () => {
222
273
  const root = makeProject({
223
274
  "project with spaces/package.json": JSON.stringify({ dependencies: { openai: "1" } }),
@@ -279,6 +330,32 @@ test("empty project has no detections", async () => {
279
330
  expect(await scanSource(makeProject({}))).toEqual([]);
280
331
  });
281
332
 
333
+ test("unrelated text file with provider-like content has no detections", async () => {
334
+ const cwd = makeProject({
335
+ "notes.txt": 'openai\nOPENAI_API_KEY=\n{"dependencies":{"@supabase/supabase-js":"1"}}',
336
+ });
337
+
338
+ expect(await scanSource(cwd)).toEqual([]);
339
+ });
340
+
341
+ test("an empty scan root ignores provider markers in its parent and stays empty", async () => {
342
+ const parent = makeProject({
343
+ "package.json": JSON.stringify({ dependencies: { openai: "1" } }),
344
+ "vercel.json": "{}",
345
+ ".env.example": "ANTHROPIC_API_KEY=",
346
+ });
347
+ const root = join(parent, "empty project");
348
+ mkdirSync(root);
349
+
350
+ expect((await scanSource(parent)).map((hit) => hit.provider)).toEqual([
351
+ "openai",
352
+ "vercel",
353
+ "anthropic",
354
+ ]);
355
+ expect(await scanSource(root)).toEqual([]);
356
+ expect(readdirSync(root)).toEqual([]);
357
+ });
358
+
282
359
  test.each([["--json"], ["--yes"]])(
283
360
  "scan %s rejects a nonexistent project without creating files",
284
361
  async (flag) => {
@@ -324,3 +401,23 @@ test.each([
324
401
  ] as const)("empty dependency manifest %s produces no detections", async (file, content) => {
325
402
  expect(await scanSource(makeProject({ [file]: content }))).toEqual([]);
326
403
  });
404
+
405
+ describe("source scanner — missing project directory", () => {
406
+ test("fails clearly and does not create the directory", async () => {
407
+ const parent = makeProject({});
408
+ const missing = join(parent, "no-such-project");
409
+ const err = await scanSource(missing).catch((e: unknown) => e);
410
+ expect(err).toBeInstanceOf(SourceDirectoryNotFoundError);
411
+ expect(err).toMatchObject({ code: "SOURCE_DIRECTORY_NOT_FOUND" });
412
+ expect((err as Error).message).toContain(missing);
413
+ expect((err as Error).message).toBe(`Source directory not found: ${missing}`);
414
+ expect(existsSync(missing)).toBe(false);
415
+ });
416
+
417
+ test("rejects a path that exists but is not a directory", async () => {
418
+ const cwd = makeProject({ "package.json": "{}" });
419
+ const file = join(cwd, "package.json");
420
+ await expect(scanSource(file)).rejects.toThrow(SourceDirectoryNotFoundError);
421
+ expect(existsSync(file)).toBe(true);
422
+ });
423
+ });
@@ -2,9 +2,9 @@
2
2
  "schema_version": 1,
3
3
  "kind": "ashlr-stack-manifest",
4
4
  "components": [
5
- { "id": "core", "name": "@ashlr/stack-core", "version": "0.3.1", "distribution": "npm" },
6
- { "id": "cli", "name": "@ashlr/stack", "version": "0.3.1", "distribution": "npm" },
7
- { "id": "mcp", "name": "ashlr-stack-mcp", "version": "0.3.1", "distribution": "npm" },
5
+ { "id": "core", "name": "@ashlr/stack-core", "version": "0.3.6", "distribution": "npm" },
6
+ { "id": "cli", "name": "@ashlr/stack", "version": "0.3.6", "distribution": "npm" },
7
+ { "id": "mcp", "name": "ashlr-stack-mcp", "version": "0.3.6", "distribution": "npm" },
8
8
  { "id": "plugin", "name": "stack", "version": "0.1.0", "distribution": "claude-code-plugin" }
9
9
  ]
10
10
  }
@@ -1,6 +1,14 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { spawnSync } from "node:child_process";
3
- import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
+ import {
4
+ chmodSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ realpathSync,
8
+ rmSync,
9
+ symlinkSync,
10
+ writeFileSync,
11
+ } from "node:fs";
4
12
  import { tmpdir } from "node:os";
5
13
  import { delimiter, join, resolve } from "node:path";
6
14
  import { PhantomNotInstalledError } from "../errors.ts";
@@ -18,8 +26,18 @@ afterEach(() => {
18
26
  const name = process.platform === "win32" ? "phantom.exe" : "phantom";
19
27
  function binary(dir: string, script = "#!/bin/sh\nexit 0\n"): string {
20
28
  const path = join(dir, name);
21
- writeFileSync(path, script);
22
- chmodSync(path, 0o755);
29
+ // Sandboxed runners and macOS Gatekeeper's provenance policy refuse to
30
+ // exec an extensionless file written by a non-platform process, while a
31
+ // `*.sh` target passes the check: point `phantom` at a `phantom.sh` shim.
32
+ const real = join(dir, `${name}.sh`);
33
+ writeFileSync(real, script);
34
+ chmodSync(real, 0o755);
35
+ try {
36
+ symlinkSync(real, path);
37
+ } catch {
38
+ writeFileSync(path, script);
39
+ chmodSync(path, 0o755);
40
+ }
23
41
  return path;
24
42
  }
25
43