@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.
- 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 +217 -0
- package/src/__tests__/api-key-providers.test.ts +6 -2
- package/src/__tests__/base-urls.test.ts +162 -0
- package/src/__tests__/catalog-index.test.ts +14 -20
- package/src/__tests__/cloudflare-provider.test.ts +9 -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 +206 -2
- package/src/__tests__/config.test.ts +2 -1
- package/src/__tests__/credential-staging.test.ts +659 -0
- package/src/__tests__/deps.test.ts +2 -1
- package/src/__tests__/detect-source.test.ts +1 -1
- 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__/inference.test.ts +19 -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__/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 +125 -8
- package/src/__tests__/phantom-project-preflight.test.ts +118 -0
- package/src/__tests__/phantom-wire.test.ts +31 -20
- package/src/__tests__/pipeline-integration.test.ts +1 -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 +659 -0
- package/src/__tests__/recipe.test.ts +4 -2
- package/src/__tests__/recommend-apply-e2e.test.ts +14 -27
- package/src/__tests__/registry.test.ts +11 -2
- package/src/__tests__/release-artifacts.test.ts +55 -0
- package/src/__tests__/secret-redaction.test.ts +10 -10
- package/src/__tests__/security.test.ts +9 -10
- 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 +41 -0
- package/src/__tests__/template-temporal-worker-phantom.test.ts +31 -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 +29 -52
- 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 +36 -8
- package/src/detect.ts +13 -5
- package/src/http.ts +6 -5
- package/src/index.ts +15 -0
- package/src/mcp-writer.ts +97 -2
- package/src/oauth.ts +85 -53
- package/src/phantom.ts +193 -15
- 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/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,217 @@
|
|
|
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
|
+
});
|
|
@@ -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);
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { LocalSLMBackend, defaultOllamaEndpoint } from "../ai/inference.ts";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_ANTHROPIC_BASE_URL,
|
|
5
|
+
DEFAULT_OLLAMA_BASE_URL,
|
|
6
|
+
DEFAULT_OPENAI_BASE_URL,
|
|
7
|
+
anthropicBaseUrl,
|
|
8
|
+
ollamaBaseUrl,
|
|
9
|
+
openaiBaseUrl,
|
|
10
|
+
} from "../base-urls.ts";
|
|
11
|
+
import type { ProviderContext } from "../providers/_base.ts";
|
|
12
|
+
import anthropic from "../providers/anthropic.ts";
|
|
13
|
+
import openai from "../providers/openai.ts";
|
|
14
|
+
import { type Harness, setupFakePhantom } from "./_harness.ts";
|
|
15
|
+
|
|
16
|
+
describe("defaults are unchanged", () => {
|
|
17
|
+
test("hosts match what Stack always called", () => {
|
|
18
|
+
expect(DEFAULT_ANTHROPIC_BASE_URL).toBe("https://api.anthropic.com");
|
|
19
|
+
expect(DEFAULT_OPENAI_BASE_URL).toBe("https://api.openai.com/v1");
|
|
20
|
+
expect(DEFAULT_OLLAMA_BASE_URL).toBe("http://localhost:11434");
|
|
21
|
+
expect(defaultOllamaEndpoint({}).baseUrl).toBe("http://localhost:11434/v1");
|
|
22
|
+
});
|
|
23
|
+
test("unset, empty and whitespace-only values fall back", () => {
|
|
24
|
+
for (const v of [undefined, "", " ", "/", " // "]) {
|
|
25
|
+
expect(anthropicBaseUrl({ ANTHROPIC_BASE_URL: v })).toBe(DEFAULT_ANTHROPIC_BASE_URL);
|
|
26
|
+
expect(openaiBaseUrl({ OPENAI_BASE_URL: v })).toBe(DEFAULT_OPENAI_BASE_URL);
|
|
27
|
+
expect(ollamaBaseUrl({ OLLAMA_HOST: v })).toBe(DEFAULT_OLLAMA_BASE_URL);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("overrides are honored", () => {
|
|
33
|
+
test("anthropic and openai: used as given, trailing slashes trimmed", () => {
|
|
34
|
+
expect(anthropicBaseUrl({ ANTHROPIC_BASE_URL: "http://127.0.0.1:4000" })).toBe(
|
|
35
|
+
"http://127.0.0.1:4000",
|
|
36
|
+
);
|
|
37
|
+
expect(anthropicBaseUrl({ ANTHROPIC_BASE_URL: " https://gw.example/anthropic/// " })).toBe(
|
|
38
|
+
"https://gw.example/anthropic",
|
|
39
|
+
);
|
|
40
|
+
expect(openaiBaseUrl({ OPENAI_BASE_URL: "http://127.0.0.1:4000/v1/" })).toBe(
|
|
41
|
+
"http://127.0.0.1:4000/v1",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
test("ollama host forms", () => {
|
|
45
|
+
expect(ollamaBaseUrl({ OLLAMA_HOST: "192.168.1.5:9999" })).toBe("http://192.168.1.5:9999");
|
|
46
|
+
expect(ollamaBaseUrl({ OLLAMA_HOST: "gpu-box:11435/" })).toBe("http://gpu-box:11435");
|
|
47
|
+
expect(ollamaBaseUrl({ OLLAMA_HOST: "gpu-box" })).toBe("http://gpu-box:11434");
|
|
48
|
+
expect(ollamaBaseUrl({ OLLAMA_HOST: ":8080" })).toBe("http://localhost:8080");
|
|
49
|
+
expect(ollamaBaseUrl({ OLLAMA_HOST: "[::1]:11434" })).toBe("http://[::1]:11434");
|
|
50
|
+
expect(ollamaBaseUrl({ OLLAMA_HOST: "[::1]" })).toBe("http://[::1]:11434");
|
|
51
|
+
expect(ollamaBaseUrl({ OLLAMA_HOST: "https://ollama.example.com/" })).toBe(
|
|
52
|
+
"https://ollama.example.com",
|
|
53
|
+
);
|
|
54
|
+
expect(defaultOllamaEndpoint({ OLLAMA_HOST: "gpu-box" }).baseUrl).toBe(
|
|
55
|
+
"http://gpu-box:11434/v1",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("callers read the variables at call time", () => {
|
|
61
|
+
const keys = ["ANTHROPIC_BASE_URL", "OPENAI_BASE_URL", "OLLAMA_HOST"] as const;
|
|
62
|
+
const saved = Object.fromEntries(keys.map((k) => [k, process.env[k]]));
|
|
63
|
+
let h: Harness;
|
|
64
|
+
let realFetch: typeof fetch;
|
|
65
|
+
let urls: string[];
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
// Exercises the HTTP call after a key is read; real Phantom 0.7.9 refuses headless reveal.
|
|
69
|
+
h = setupFakePhantom(
|
|
70
|
+
{ ANTHROPIC_API_KEY: "sk-ant-fake", OPENAI_API_KEY: "sk-fake" },
|
|
71
|
+
{ legacyHeadlessReveal: true },
|
|
72
|
+
);
|
|
73
|
+
realFetch = globalThis.fetch;
|
|
74
|
+
urls = [];
|
|
75
|
+
globalThis.fetch = (async (u: string | URL | Request) => {
|
|
76
|
+
urls.push(String(u));
|
|
77
|
+
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
|
78
|
+
}) as unknown as typeof fetch;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
globalThis.fetch = realFetch;
|
|
83
|
+
h.cleanup();
|
|
84
|
+
for (const k of keys) {
|
|
85
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
86
|
+
else process.env[k] = saved[k];
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const ctx: ProviderContext = { cwd: process.cwd(), interactive: false, log: () => {} };
|
|
91
|
+
const entry = (provider: string, secret: string) => ({
|
|
92
|
+
provider,
|
|
93
|
+
secrets: [secret],
|
|
94
|
+
created_at: new Date().toISOString(),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("key checks hit the default hosts when unset", async () => {
|
|
98
|
+
for (const k of keys) delete process.env[k];
|
|
99
|
+
expect((await anthropic.healthcheck!(ctx, entry("anthropic", "ANTHROPIC_API_KEY"))).kind).toBe(
|
|
100
|
+
"ok",
|
|
101
|
+
);
|
|
102
|
+
expect((await openai.healthcheck!(ctx, entry("openai", "OPENAI_API_KEY"))).kind).toBe("ok");
|
|
103
|
+
expect(urls).toEqual([
|
|
104
|
+
"https://api.anthropic.com/v1/models",
|
|
105
|
+
"https://api.openai.com/v1/models",
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("key checks follow ANTHROPIC_BASE_URL and OPENAI_BASE_URL", async () => {
|
|
110
|
+
process.env.ANTHROPIC_BASE_URL = "http://127.0.0.1:4000/";
|
|
111
|
+
process.env.OPENAI_BASE_URL = "http://127.0.0.1:4000/v1";
|
|
112
|
+
await anthropic.healthcheck!(ctx, entry("anthropic", "ANTHROPIC_API_KEY"));
|
|
113
|
+
await openai.healthcheck!(ctx, entry("openai", "OPENAI_API_KEY"));
|
|
114
|
+
expect(urls).toEqual(["http://127.0.0.1:4000/v1/models", "http://127.0.0.1:4000/v1/models"]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("the local SLM backend picks up OLLAMA_HOST when constructed", async () => {
|
|
118
|
+
process.env.OLLAMA_HOST = "gpu-box:11435";
|
|
119
|
+
const seen: string[] = [];
|
|
120
|
+
const down = (async (u: string | URL | Request) => {
|
|
121
|
+
seen.push(String(u));
|
|
122
|
+
return new Response("down", { status: 503 });
|
|
123
|
+
}) as unknown as typeof fetch;
|
|
124
|
+
expect(await new LocalSLMBackend({ fetchImpl: down }).healthy()).toBe(false);
|
|
125
|
+
expect(seen).toContain("http://gpu-box:11435/v1/models");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("gateway rule for keyed providers", () => {
|
|
130
|
+
test("https anywhere and http to loopback literals are allowed", () => {
|
|
131
|
+
expect(anthropicBaseUrl({ ANTHROPIC_BASE_URL: "https://gw.example" })).toBe(
|
|
132
|
+
"https://gw.example",
|
|
133
|
+
);
|
|
134
|
+
expect(openaiBaseUrl({ OPENAI_BASE_URL: "http://[::1]:4000/v1" })).toBe("http://[::1]:4000/v1");
|
|
135
|
+
});
|
|
136
|
+
test.each([
|
|
137
|
+
"http://gw.example",
|
|
138
|
+
"http://localhost:4000",
|
|
139
|
+
"http://10.0.0.5:4000",
|
|
140
|
+
"ftp://127.0.0.1",
|
|
141
|
+
"not a url",
|
|
142
|
+
])("%s is refused without echoing the URL", (v) => {
|
|
143
|
+
for (const [fn, name] of [
|
|
144
|
+
[anthropicBaseUrl, "ANTHROPIC_BASE_URL"],
|
|
145
|
+
[openaiBaseUrl, "OPENAI_BASE_URL"],
|
|
146
|
+
] as const) {
|
|
147
|
+
let msg = "";
|
|
148
|
+
try {
|
|
149
|
+
fn({ [name]: v });
|
|
150
|
+
} catch (e) {
|
|
151
|
+
msg = (e as Error).message;
|
|
152
|
+
}
|
|
153
|
+
expect(msg).toContain(name);
|
|
154
|
+
expect(msg).not.toContain(v);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
test("credentials in a refused URL are not echoed", () => {
|
|
158
|
+
expect(() => openaiBaseUrl({ OPENAI_BASE_URL: "http://user:sk-secret@gw.example" })).toThrow(
|
|
159
|
+
/^OPENAI_BASE_URL must use https/,
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
});
|