@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
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Ashlr / Mason Wyatt
3
+ Copyright (c) 2026 AshlrAI, Inc.
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -2,26 +2,30 @@
2
2
 
3
3
  > Shared library behind the [Ashlr Stack](https://stack.ashlr.ai) CLI and MCP server. Provider adapters, `.stack.toml` config, Phantom integration, source/env detection.
4
4
 
5
- **You probably don't want to install this directly.** If you want the CLI, install [`@ashlr/stack`](https://www.npmjs.com/package/@ashlr/stack). This package exists so the CLI and the [`ashlr-stack-mcp`](https://www.npmjs.com/package/ashlr-stack-mcp) server can share a single implementation.
5
+ This workspace package shares source between the [`@ashlr/stack`](https://www.npmjs.com/package/@ashlr/stack) CLI and [`ashlr-stack-mcp`](https://www.npmjs.com/package/ashlr-stack-mcp) server. Its export points at TypeScript source. The examples below target the reviewed workspace; they do not establish a separately published core package or Node-only execution support.
6
6
 
7
- ## Install
7
+ ## Use in this workspace
8
+
9
+ From the repository root, prepare the workspace dependencies with Bun 1.2+:
8
10
 
9
11
  ```bash
10
- bun add @ashlr/stack-core
11
- # or
12
- npm i @ashlr/stack-core
12
+ bun install
13
13
  ```
14
14
 
15
+ Use the selected CLI/MCP artifact and its documented runtime when installing
16
+ those products. Source version `0.2.1` is distinct from recorded public
17
+ CLI/MCP `0.1.1` packages; verify actual package contents separately.
18
+
15
19
  ## Public API
16
20
 
17
21
  Selected exports — see `src/index.ts` for the full surface.
18
22
 
19
23
  | Export | Purpose |
20
24
  | --- | --- |
21
- | `addService(opts)` | End-to-end pipeline: login → provision → materialize → persist secrets + MCP entry |
25
+ | `addService({ providerName, ... })` | Attempt login → create/attach → materialize → named-secret/MCP/config writes; inspect failures and partial state |
22
26
  | `readConfig(cwd)` / `writeConfig(cfg, cwd)` | Read/write `.stack.toml` + `.stack.local.toml` |
23
- | `listProviderNames()` / `getProvider(name)` | Introspect the built-in 23-provider registry |
24
- | `scanSource(opts)` | Detect providers from `package.json`, configs, `.env.example` |
27
+ | `listProviderNames()` / `getProvider(name)` | Introspect the built-in 39-provider registry (`getProvider` is async) |
28
+ | `scanSource(cwd)` | Detect providers from `package.json`, configs, `.env.example` |
25
29
  | `detectProvider(envName)` | Map an env-var name back to a known provider |
26
30
  | `parseEnv(text)` | Parse `.env`-style text into `{ key, value }` pairs |
27
31
 
@@ -35,18 +39,25 @@ import {
35
39
  scanSource,
36
40
  } from "@ashlr/stack-core";
37
41
 
38
- // Pipeline: provisions a new Supabase project, writes secrets through Phantom,
39
- // merges the MCP entry, and updates .stack.toml + .stack.local.toml.
40
- await addService({ provider: "supabase", cwd: process.cwd() });
42
+ // With explicit user approval for provider and credential effects, attempt
43
+ // Supabase setup, named-secret/MCP writes, and split-config persistence.
44
+ await addService({ providerName: "supabase", cwd: process.cwd() });
41
45
 
42
46
  const cfg = await readConfig();
43
- console.log(cfg.services.map((s) => s.name));
47
+ console.log(Object.keys(cfg.services));
44
48
 
45
49
  // Scan an existing checkout for providers already wired up.
46
- const detections = await scanSource({ cwd: process.cwd() });
47
- console.log(listProviderNames().length); // 23
50
+ const detections = await scanSource(process.cwd());
51
+ console.log(detections);
52
+ console.log(listProviderNames().length); // 39 in this source snapshot
48
53
  ```
49
54
 
55
+ The setup path can contact providers and write credentials, MCP entries and
56
+ split config. It handles values in memory; writes and rollback are not an
57
+ atomic transaction. Examples describe reviewed source and require the selected
58
+ artifact, runtime, project directory, user authorization and provider access.
59
+ A package installation does not establish those later steps.
60
+
50
61
  ## Links
51
62
 
52
63
  - Full CLI-level docs — [stack.ashlr.ai/docs/cli](https://stack.ashlr.ai/docs/cli)
@@ -54,4 +65,4 @@ console.log(listProviderNames().length); // 23
54
65
 
55
66
  ## License
56
67
 
57
- MIT. See [LICENSE](./LICENSE).
68
+ MIT. See [LICENSE](../../LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashlr/stack-core",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
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": {
@@ -10,6 +10,11 @@ import { __resetPhantomCache } from "../phantom.ts";
10
10
  *
11
11
  * Subset of the real Phantom CLI: add/remove/list/reveal/status/--version.
12
12
  * Enough for Stack's core paths; doesn't model cloud sync or the proxy.
13
+ *
14
+ * Behaviour follows Phantom Secrets 0.7.9 (checked against the real binary by
15
+ * `phantom-contract.test.ts`): `add` takes values only via `--stdin` and never
16
+ * replaces a name; `reveal` and `remove` refuse without an attached terminal;
17
+ * `list` prints a decorated table unless `--json` is passed.
13
18
  */
14
19
 
15
20
  export interface Harness {
@@ -21,7 +26,22 @@ export interface Harness {
21
26
  cleanup(): void;
22
27
  }
23
28
 
24
- export function setupFakePhantom(initial: Record<string, string> = {}): Harness {
29
+ export interface HarnessOptions {
30
+ /** Synthetic config observation; never claims vault readiness or authority. */
31
+ initialized?: boolean;
32
+ /**
33
+ * Let `reveal` print values to a pipe, as Phantom did before 0.7.3. Real
34
+ * Phantom Secrets 0.7.9 refuses this, so production Stack can never read a
35
+ * stored token back. Only provider tests that exercise HTTP logic *after* a
36
+ * token is read should opt in.
37
+ */
38
+ legacyHeadlessReveal?: boolean;
39
+ }
40
+
41
+ export function setupFakePhantom(
42
+ initial: Record<string, string> = {},
43
+ options: HarnessOptions = {},
44
+ ): Harness {
25
45
  const dir = mkdtempSync(join(tmpdir(), "stack-phantom-"));
26
46
  const vaultPath = join(dir, "vault.json");
27
47
  const logPath = join(dir, "calls.log");
@@ -42,6 +62,7 @@ export function setupFakePhantom(initial: Record<string, string> = {}): Harness
42
62
  set -e
43
63
  VAULT=${JSON.stringify(vaultPath)}
44
64
  LOG=${JSON.stringify(logPath)}
65
+ LEGACY_HEADLESS_REVEAL=${options.legacyHeadlessReveal ? 1 : 0}
45
66
 
46
67
  # Log the invocation as a single JSON line (args passed via argv, never interpolated).
47
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
@@ -51,16 +72,43 @@ case "\${1:-}" in
51
72
  echo "phantom fake 0.0.1"
52
73
  ;;
53
74
  status)
54
- echo "ok"
75
+ if [ "\${2:-}" = "--json" ]; then
76
+ echo '{"schema_version":1,"initialized":${options.initialized !== false},"inspection":"metadata-only","managed_dotenv":{"inspected":false},"vault":{"inspected":false},"proxy":{"lifecycle_lock":"not-inspected","listener_authenticated":false},"issues":[]}'
77
+ else
78
+ echo "ok"
79
+ fi
55
80
  ;;
56
81
  add)
82
+ # Phantom Secrets >= 0.7.3: values only via --stdin, create-only names.
83
+ if [ "\${3:-}" != "--stdin" ] || [ "$#" -ne 3 ]; then
84
+ for a in "$@"; do
85
+ if [ "$a" = "--rotate" ]; then
86
+ echo "error: unexpected argument '--rotate' found" >&2
87
+ exit 2
88
+ fi
89
+ done
90
+ 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
+ exit 1
92
+ fi
57
93
  python3 -c 'import json,sys
58
94
  with open(sys.argv[1]) as f: v = json.load(f)
59
- v[sys.argv[2]] = sys.argv[3]
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
60
103
  with open(sys.argv[1], "w") as f: json.dump(v, f)
61
- ' "$VAULT" "$2" "$3"
104
+ ' "$VAULT" "$2"
62
105
  ;;
63
106
  remove)
107
+ # Phantom Secrets >= 0.7.3: typed confirmation in an attached terminal.
108
+ if [ ! -t 0 ] || [ ! -t 1 ] || [ ! -t 2 ]; then
109
+ echo "Error: phantom remove requires attached stdin, stdout, and stderr terminals and cannot run headlessly." >&2
110
+ exit 1
111
+ fi
64
112
  python3 -c 'import json,sys
65
113
  with open(sys.argv[1]) as f: v = json.load(f)
66
114
  v.pop(sys.argv[2], None)
@@ -68,12 +116,23 @@ with open(sys.argv[1], "w") as f: json.dump(v, f)
68
116
  ' "$VAULT" "$2"
69
117
  ;;
70
118
  list)
119
+ # Real Phantom: \`list --json\` prints [{"name": ...}, ...] (names only).
71
120
  python3 -c 'import json,sys
72
121
  with open(sys.argv[1]) as f: v = json.load(f)
73
- for k in v: print(k)
74
- ' "$VAULT"
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}"
75
129
  ;;
76
130
  reveal)
131
+ # Phantom Secrets >= 0.7.3: plaintext only into an attached terminal.
132
+ if { [ ! -t 0 ] || [ ! -t 2 ]; } && [ "$LEGACY_HEADLESS_REVEAL" != 1 ]; then
133
+ echo "Error: Refusing to reveal a secret without attached stdin and stderr terminals" >&2
134
+ exit 1
135
+ fi
77
136
  python3 -c 'import json,sys
78
137
  with open(sys.argv[1]) as f: v = json.load(f)
79
138
  print(v.get(sys.argv[2], ""))
@@ -109,7 +168,8 @@ esac
109
168
  },
110
169
  cleanup() {
111
170
  process.env.PATH = previousPath;
112
- if (previousRegistryDir === undefined) delete process.env.STACK_REGISTRY_DIR;
171
+ if (previousRegistryDir === undefined)
172
+ Reflect.deleteProperty(process.env, "STACK_REGISTRY_DIR");
113
173
  else process.env.STACK_REGISTRY_DIR = previousRegistryDir;
114
174
  __resetPhantomCache();
115
175
  try {
@@ -0,0 +1,279 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import {
14
+ applyOnboarding,
15
+ configPathFor,
16
+ planOnboarding,
17
+ undoOnboarding,
18
+ } from "../agent-onboard.ts";
19
+
20
+ let home: string;
21
+ let emptyBin: string;
22
+ beforeEach(() => {
23
+ home = mkdtempSync(join(tmpdir(), "stack-onboard-"));
24
+ emptyBin = join(home, "empty-bin");
25
+ mkdirSync(emptyBin);
26
+ });
27
+ afterEach(() => rmSync(home, { recursive: true, force: true }));
28
+
29
+ const opts = () => ({ home, pathEnv: emptyBin });
30
+
31
+ function tree(dir: string): string {
32
+ const out: string[] = [];
33
+ const walk = (d: string) => {
34
+ for (const n of readdirSync(d).sort()) {
35
+ const f = join(d, n);
36
+ const st = readdirSyncSafe(f);
37
+ if (st) walk(f);
38
+ else out.push(`${f.slice(dir.length)}:${readFileSync(f, "utf-8")}`);
39
+ }
40
+ };
41
+ walk(dir);
42
+ return out.join("\n");
43
+ }
44
+ function readdirSyncSafe(p: string): boolean {
45
+ try {
46
+ readdirSync(p);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ describe("planOnboarding", () => {
54
+ it("reports every client not-detected on a clean home and writes nothing", () => {
55
+ const before = tree(home);
56
+ const plan = planOnboarding(opts());
57
+ expect(plan.map((p) => [p.client, p.state, p.willWrite])).toEqual([
58
+ ["claude", "not-detected", false],
59
+ ["codex", "not-detected", false],
60
+ ["cursor", "not-detected", false],
61
+ ["gemini", "not-detected", false],
62
+ ]);
63
+ expect(tree(home)).toBe(before);
64
+ });
65
+
66
+ it("detects by config directory and by binary on PATH", () => {
67
+ mkdirSync(join(home, ".gemini"));
68
+ writeFileSync(join(emptyBin, "codex"), "#!/bin/sh\n");
69
+ const plan = planOnboarding(opts());
70
+ const by = Object.fromEntries(plan.map((p) => [p.client, p]));
71
+ expect(by.gemini?.detected).toBe(true);
72
+ expect(by.gemini?.state).toBe("not-wired");
73
+ expect(by.codex?.detected).toBe(true);
74
+ expect(by.cursor?.detected).toBe(false);
75
+ });
76
+
77
+ it("classifies wired, differs and error without touching files", () => {
78
+ mkdirSync(join(home, ".cursor"));
79
+ mkdirSync(join(home, ".gemini"));
80
+ writeFileSync(
81
+ configPathFor("cursor", home),
82
+ JSON.stringify({ mcpServers: { "ashlr-hub": { command: "ashlr", args: ["mcp"] } } }),
83
+ );
84
+ writeFileSync(
85
+ configPathFor("gemini", home),
86
+ JSON.stringify({ mcpServers: { "ashlr-hub": { command: "other", args: ["mcp"] } } }),
87
+ );
88
+ mkdirSync(join(home, ".codex"));
89
+ writeFileSync(
90
+ configPathFor("codex", home),
91
+ '[mcp_servers.ashlr-hub]\ncommand = "ashlr"\nargs = ["mcp"]\n',
92
+ );
93
+ writeFileSync(join(home, ".claude.json"), "{ not json");
94
+ const by = Object.fromEntries(planOnboarding(opts()).map((p) => [p.client, p]));
95
+ expect(by.cursor?.state).toBe("wired");
96
+ expect(by.gemini?.state).toBe("differs");
97
+ expect(by.gemini?.willWrite).toBe(false);
98
+ expect(by.codex?.state).toBe("wired");
99
+ expect(by.claude?.state).toBe("error");
100
+ expect(by.claude?.willWrite).toBe(false);
101
+ });
102
+ });
103
+
104
+ describe("applyOnboarding and undoOnboarding", () => {
105
+ function seed() {
106
+ mkdirSync(join(home, ".cursor"));
107
+ mkdirSync(join(home, ".gemini"));
108
+ mkdirSync(join(home, ".codex"));
109
+ mkdirSync(join(home, ".claude"));
110
+ writeFileSync(
111
+ configPathFor("cursor", home),
112
+ `${JSON.stringify({ mcpServers: { other: { command: "x" } } }, null, 4)}\n`,
113
+ );
114
+ writeFileSync(
115
+ configPathFor("codex", home),
116
+ 'model = "x"\n\n[mcp_servers.other]\ncommand = "y"\n',
117
+ );
118
+ // gemini settings with unrelated keys; claude has no config file at all
119
+ writeFileSync(configPathFor("gemini", home), JSON.stringify({ theme: "dark" }));
120
+ }
121
+
122
+ it("takes all detected clients from not wired to wired, then undo restores byte for byte", async () => {
123
+ seed();
124
+ const res = await applyOnboarding(opts());
125
+ expect(res.written.map((w) => w.client).sort()).toEqual([
126
+ "claude",
127
+ "codex",
128
+ "cursor",
129
+ "gemini",
130
+ ]);
131
+ expect(res.verified.every((p) => p.state === "wired")).toBe(true);
132
+
133
+ const cursor = JSON.parse(readFileSync(configPathFor("cursor", home), "utf-8"));
134
+ expect(cursor.mcpServers.other).toEqual({ command: "x" });
135
+ expect(cursor.mcpServers["ashlr-hub"]).toEqual({ command: "ashlr", args: ["mcp"] });
136
+ const gemini = JSON.parse(readFileSync(configPathFor("gemini", home), "utf-8"));
137
+ expect(gemini.theme).toBe("dark");
138
+ const claude = JSON.parse(readFileSync(join(home, ".claude.json"), "utf-8"));
139
+ expect(claude.mcpServers["ashlr-hub"]).toEqual({
140
+ type: "stdio",
141
+ command: "ashlr",
142
+ args: ["mcp"],
143
+ env: {},
144
+ });
145
+ const toml = readFileSync(configPathFor("codex", home), "utf-8");
146
+ expect(toml).toContain('model = "x"');
147
+ expect(toml).toContain("[mcp_servers.other]");
148
+ expect(toml).toContain('[mcp_servers.ashlr-hub]\ncommand = "ashlr"\nargs = ["mcp"]');
149
+
150
+ // idempotent: a second apply writes nothing
151
+ const again = await applyOnboarding(opts());
152
+ expect(again.written).toEqual([]);
153
+
154
+ const undo = await undoOnboarding({ home });
155
+ expect(undo.refused).toEqual([]);
156
+ expect(undo.restored).toHaveLength(4);
157
+ // claude config did not exist before, so undo removes it; backups and manifest remain/clean
158
+ expect(existsSync(join(home, ".claude.json"))).toBe(false);
159
+ for (const c of ["cursor", "codex", "gemini"] as const) {
160
+ expect(readFileSync(configPathFor(c, home), "utf-8")).toBe(
161
+ c === "cursor"
162
+ ? `${JSON.stringify({ mcpServers: { other: { command: "x" } } }, null, 4)}\n`
163
+ : c === "codex"
164
+ ? 'model = "x"\n\n[mcp_servers.other]\ncommand = "y"\n'
165
+ : JSON.stringify({ theme: "dark" }),
166
+ );
167
+ }
168
+ expect(existsSync(join(home, ".ashlr-stack", "onboard-manifest.json"))).toBe(false);
169
+ });
170
+
171
+ it("never overwrites a differing entry", async () => {
172
+ mkdirSync(join(home, ".cursor"));
173
+ const differing = JSON.stringify({
174
+ mcpServers: { "ashlr-hub": { command: "custom", args: [] } },
175
+ });
176
+ writeFileSync(configPathFor("cursor", home), differing);
177
+ const res = await applyOnboarding({ ...opts(), clients: ["cursor"] });
178
+ expect(res.written).toEqual([]);
179
+ expect(readFileSync(configPathFor("cursor", home), "utf-8")).toBe(differing);
180
+ expect(res.verified[0]?.state).toBe("differs");
181
+ });
182
+
183
+ it("refuses to undo a file that changed after onboarding", async () => {
184
+ mkdirSync(join(home, ".cursor"));
185
+ writeFileSync(configPathFor("cursor", home), "{}");
186
+ await applyOnboarding({ ...opts(), clients: ["cursor"] });
187
+ const edited = `${readFileSync(configPathFor("cursor", home), "utf-8")}\n`;
188
+ writeFileSync(configPathFor("cursor", home), edited);
189
+ const undo = await undoOnboarding({ home });
190
+ expect(undo.restored).toEqual([]);
191
+ expect(undo.refused[0]?.reason).toContain("changed");
192
+ expect(readFileSync(configPathFor("cursor", home), "utf-8")).toBe(edited);
193
+ });
194
+
195
+ it("errors on undo with no manifest", async () => {
196
+ await expect(undoOnboarding({ home })).rejects.toThrow("nothing to undo");
197
+ });
198
+
199
+ it("skips undetected clients unless includeUndetected is set", async () => {
200
+ const res = await applyOnboarding({ ...opts(), clients: ["gemini"] });
201
+ expect(res.written).toEqual([]);
202
+ expect(existsSync(configPathFor("gemini", home))).toBe(false);
203
+ const forced = await applyOnboarding({
204
+ ...opts(),
205
+ clients: ["gemini"],
206
+ includeUndetected: true,
207
+ });
208
+ expect(forced.written).toHaveLength(1);
209
+ expect(existsSync(configPathFor("gemini", home))).toBe(true);
210
+ });
211
+
212
+ it("does not put secret-looking values into the gateway entry", async () => {
213
+ mkdirSync(join(home, ".gemini"));
214
+ await applyOnboarding({ ...opts(), clients: ["gemini"] });
215
+ expect(readFileSync(configPathFor("gemini", home), "utf-8")).not.toMatch(/key|token|secret/i);
216
+ });
217
+ });
218
+
219
+ it.each(["null", "[]", "42", '{"mcpServers":null}', '{"mcpServers":42}', "{broken"])(
220
+ "refuses malformed JSON config %s without writing",
221
+ async (raw) => {
222
+ const path = configPathFor("claude", home);
223
+ writeFileSync(path, raw);
224
+ const result = await applyOnboarding({ ...opts(), clients: ["claude"] });
225
+ expect(result.written).toEqual([]);
226
+ expect(result.manifestPath).toBeNull();
227
+ expect(result.skipped[0]?.state).toBe("error");
228
+ expect(result.skipped[0]?.error).toBeTruthy();
229
+ expect(readFileSync(path, "utf-8")).toBe(raw);
230
+ },
231
+ );
232
+
233
+ it.each([
234
+ ["", "not-wired"],
235
+ ['[mcp_servers.other]\ncommand = "ashlr"', "not-wired"],
236
+ [
237
+ '[mcp_servers."ashlr-hub"] # gateway\r\ncommand = "ashlr" # command\r\nargs = ["mcp"]\r\n[other]\r\ncommand = "different"',
238
+ "wired",
239
+ ],
240
+ ['[mcp_servers.ashlr-hub]\ncommand = "ashlr"\nargs = []', "differs"],
241
+ ['[mcp_servers.ashlr-hub]\nargs = ["mcp"]', "differs"],
242
+ ] as const)("inspects TOML %s", (raw, state) => {
243
+ const path = configPathFor("codex", home);
244
+ mkdirSync(join(home, ".codex"));
245
+ writeFileSync(path, raw);
246
+ expect(planOnboarding({ ...opts(), clients: ["codex"] })[0]?.state).toBe(state);
247
+ expect(readFileSync(path, "utf-8")).toBe(raw);
248
+ });
249
+
250
+ it.each(["missing config", "missing backup"])(
251
+ "undo retains manifest for %s and permits retry",
252
+ async (mode) => {
253
+ const path = configPathFor("claude", home);
254
+ writeFileSync(path, "{}");
255
+ const applied = await applyOnboarding({
256
+ ...opts(),
257
+ clients: ["claude"],
258
+ includeUndetected: true,
259
+ });
260
+ const entry = applied.written[0]!;
261
+ const target = mode === "missing config" ? path : entry.backupPath!;
262
+ const original = readFileSync(target, "utf-8");
263
+ rmSync(target);
264
+ const result = await undoOnboarding({ home });
265
+ expect(result.restored).toEqual([]);
266
+ expect(result.refused).toEqual([
267
+ {
268
+ entry,
269
+ reason:
270
+ mode === "missing config" ? "config file no longer exists" : "backup file is missing",
271
+ },
272
+ ]);
273
+ expect(JSON.parse(readFileSync(applied.manifestPath!, "utf-8")).entries).toEqual([entry]);
274
+ writeFileSync(target, original);
275
+ expect((await undoOnboarding({ home })).restored).toEqual([entry]);
276
+ expect(readFileSync(path, "utf-8")).toBe("{}");
277
+ expect(existsSync(applied.manifestPath!)).toBe(false);
278
+ },
279
+ );
@@ -163,7 +163,8 @@ describe("api-key providers — verify + materialize", () => {
163
163
  let realFetch: typeof fetch;
164
164
 
165
165
  beforeEach(() => {
166
- h = setupFakePhantom();
166
+ // Provider logic after a token is read; real Phantom 0.7.9 refuses headless reveal.
167
+ h = setupFakePhantom({}, { legacyHeadlessReveal: true });
167
168
  realFetch = globalThis.fetch;
168
169
  });
169
170
 
@@ -219,7 +220,10 @@ describe("api-key providers — verify + materialize", () => {
219
220
  const convex = (await import("../providers/convex.ts")).default;
220
221
  const validKey = "prod:my-team:my-project|encoded-token-bytes";
221
222
  const ctx: ProviderContext = { cwd: process.cwd(), interactive: false, log: () => {} };
222
- const auth = { token: validKey, identity: { environment: "prod", team: "my-team", project: "my-project" } };
223
+ const auth = {
224
+ token: validKey,
225
+ identity: { environment: "prod", team: "my-team", project: "my-project" },
226
+ };
223
227
  const resource = await convex.provision(ctx, auth, {});
224
228
  const materialized = await convex.materialize(ctx, resource, auth);
225
229
  expect(materialized.secrets.CONVEX_DEPLOY_KEY).toBe(validKey);