@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.
- package/LICENSE +1 -1
- package/README.md +26 -15
- package/package.json +1 -1
- package/src/__tests__/_harness.ts +67 -7
- package/src/__tests__/agent-onboard.test.ts +279 -0
- package/src/__tests__/api-key-providers.test.ts +6 -2
- package/src/__tests__/base-urls.test.ts +208 -0
- package/src/__tests__/catalog-index.test.ts +14 -20
- package/src/__tests__/catalog.test.ts +31 -0
- package/src/__tests__/cloudflare-provider.test.ts +32 -10
- package/src/__tests__/completion-ci.test.ts +239 -23
- package/src/__tests__/config-own-services.test.ts +183 -0
- package/src/__tests__/config-split.test.ts +272 -2
- package/src/__tests__/config.test.ts +94 -4
- package/src/__tests__/credential-staging.test.ts +660 -0
- package/src/__tests__/deps.test.ts +2 -1
- package/src/__tests__/detect-source.test.ts +183 -4
- package/src/__tests__/fixtures/stack-manifest.v1.json +10 -0
- package/src/__tests__/github-provider.test.ts +3 -2
- package/src/__tests__/helpers.test.ts +24 -4
- package/src/__tests__/http.test.ts +2 -2
- package/src/__tests__/import-integration.test.ts +18 -4
- package/src/__tests__/inference.test.ts +57 -13
- package/src/__tests__/interactive-prompt.test.ts +106 -0
- package/src/__tests__/mcp-mirror.test.ts +116 -0
- package/src/__tests__/neon-provider.test.ts +136 -3
- package/src/__tests__/new-providers.test.ts +470 -0
- package/src/__tests__/no-orphan-modules.test.ts +114 -0
- package/src/__tests__/oauth-port.test.ts +84 -0
- package/src/__tests__/openai-provider.test.ts +1 -1
- package/src/__tests__/phantom-contract.test.ts +177 -0
- package/src/__tests__/phantom-harness.test.ts +226 -8
- package/src/__tests__/phantom-process.test.ts +135 -0
- package/src/__tests__/phantom-project-preflight.test.ts +118 -0
- package/src/__tests__/phantom-wire.test.ts +403 -22
- package/src/__tests__/pipeline-integration.test.ts +33 -1
- package/src/__tests__/pipeline-recovery.test.ts +29 -28
- package/src/__tests__/pipeline-rollback.test.ts +509 -0
- package/src/__tests__/pipeline.test.ts +3 -1
- package/src/__tests__/pm-detect.test.ts +113 -0
- package/src/__tests__/provider-deprovision.test.ts +269 -0
- package/src/__tests__/provider-parity.test.ts +46 -0
- package/src/__tests__/publish-script.test.ts +704 -0
- package/src/__tests__/recipe.test.ts +27 -2
- package/src/__tests__/recommend-apply-e2e.test.ts +29 -27
- package/src/__tests__/registry.test.ts +11 -2
- package/src/__tests__/release-artifacts.test.ts +55 -0
- package/src/__tests__/scan-missing-dir.test.ts +45 -0
- package/src/__tests__/secret-redaction.test.ts +10 -10
- package/src/__tests__/security.test.ts +9 -10
- package/src/__tests__/stack-manifest.test.ts +38 -0
- package/src/__tests__/supabase-provider.test.ts +6 -4
- package/src/__tests__/swap.test.ts +114 -0
- package/src/__tests__/telemetry.test.ts +354 -0
- package/src/__tests__/template-litellm-gateway.test.ts +70 -0
- package/src/__tests__/template-temporal-worker-phantom.test.ts +45 -0
- package/src/__tests__/templates.test.ts +1 -1
- package/src/__tests__/turso-provider.test.ts +2 -5
- package/src/agent-onboard.ts +346 -0
- package/src/ai/catalog-index.ts +68 -10
- package/src/ai/circuit-breaker.ts +3 -6
- package/src/ai/cost-tracker.ts +6 -15
- package/src/ai/inference.ts +19 -11
- package/src/ai/phantom-wire.ts +73 -58
- package/src/ai/recipe.ts +69 -0
- package/src/base-urls.ts +71 -0
- package/src/catalog.ts +281 -2
- package/src/config.ts +31 -18
- package/src/detect-source.ts +50 -9
- package/src/detect.ts +13 -5
- package/src/errors.ts +6 -0
- package/src/http.ts +6 -5
- package/src/index.ts +16 -0
- package/src/mcp-writer.ts +97 -2
- package/src/oauth.ts +85 -53
- package/src/phantom-process.ts +72 -0
- package/src/phantom.ts +319 -30
- package/src/pipeline.ts +177 -68
- package/src/pm-detect.ts +56 -0
- package/src/providers/_api-key.ts +18 -17
- package/src/providers/_base.ts +28 -1
- package/src/providers/_credential-stage.ts +237 -0
- package/src/providers/_helpers.ts +55 -9
- package/src/providers/anthropic.ts +2 -1
- package/src/providers/auth0.ts +27 -0
- package/src/providers/aws.ts +32 -33
- package/src/providers/cloudflare.ts +18 -37
- package/src/providers/convex.ts +1 -2
- package/src/providers/datadog.ts +29 -0
- package/src/providers/digitalocean.ts +30 -0
- package/src/providers/gcp.ts +36 -0
- package/src/providers/github.ts +37 -35
- package/src/providers/grafana.ts +29 -0
- package/src/providers/hetzner.ts +29 -0
- package/src/providers/index.ts +13 -1
- package/src/providers/launchdarkly.ts +33 -0
- package/src/providers/mixpanel.ts +37 -0
- package/src/providers/neon.ts +50 -26
- package/src/providers/openai.ts +2 -1
- package/src/providers/plausible.ts +26 -0
- package/src/providers/sentry.ts +17 -17
- package/src/providers/stripe.ts +1 -2
- package/src/providers/supabase.ts +66 -37
- package/src/providers/turso.ts +49 -38
- package/src/providers/vercel.ts +50 -20
- package/src/providers/workos.ts +27 -0
- package/src/registry.ts +7 -11
- package/src/stack-manifest.ts +39 -0
- package/src/swap.ts +108 -0
- package/src/telemetry.ts +267 -0
- package/src/templates.ts +6 -2
package/LICENSE
CHANGED
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
|
-
|
|
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
|
-
##
|
|
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
|
|
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(
|
|
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
|
|
24
|
-
| `scanSource(
|
|
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
|
-
//
|
|
39
|
-
//
|
|
40
|
-
await addService({
|
|
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
|
|
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(
|
|
47
|
-
console.log(
|
|
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](
|
|
68
|
+
MIT. See [LICENSE](../../LICENSE).
|
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
74
|
-
|
|
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)
|
|
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
|
-
|
|
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 = {
|
|
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);
|