@ashlr/stack-core 0.1.1
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 +21 -0
- package/README.md +57 -0
- package/package.json +57 -0
- package/src/__tests__/_harness.ts +146 -0
- package/src/__tests__/api-key-providers.test.ts +237 -0
- package/src/__tests__/catalog-index.test.ts +103 -0
- package/src/__tests__/circuit-breaker.test.ts +112 -0
- package/src/__tests__/clone-url-validation.test.ts +47 -0
- package/src/__tests__/cloudflare-provider.test.ts +74 -0
- package/src/__tests__/completion-ci.test.ts +62 -0
- package/src/__tests__/config-split.test.ts +138 -0
- package/src/__tests__/config.test.ts +66 -0
- package/src/__tests__/deps.test.ts +58 -0
- package/src/__tests__/detect-source.test.ts +147 -0
- package/src/__tests__/detect.test.ts +67 -0
- package/src/__tests__/github-provider.test.ts +65 -0
- package/src/__tests__/helpers.test.ts +38 -0
- package/src/__tests__/http.test.ts +235 -0
- package/src/__tests__/import-integration.test.ts +87 -0
- package/src/__tests__/inference.test.ts +326 -0
- package/src/__tests__/neon-provider.test.ts +79 -0
- package/src/__tests__/openai-provider.test.ts +54 -0
- package/src/__tests__/phantom-harness.test.ts +44 -0
- package/src/__tests__/phantom-wire.test.ts +85 -0
- package/src/__tests__/pipeline-integration.test.ts +95 -0
- package/src/__tests__/pipeline-recovery.test.ts +106 -0
- package/src/__tests__/pipeline.test.ts +92 -0
- package/src/__tests__/recipe.test.ts +164 -0
- package/src/__tests__/recommend-apply-e2e.test.ts +222 -0
- package/src/__tests__/registry.test.ts +78 -0
- package/src/__tests__/secret-redaction.test.ts +170 -0
- package/src/__tests__/security.test.ts +80 -0
- package/src/__tests__/supabase-provider.test.ts +108 -0
- package/src/__tests__/templates.test.ts +26 -0
- package/src/__tests__/turso-provider.test.ts +71 -0
- package/src/ai/catalog-index.ts +256 -0
- package/src/ai/circuit-breaker.ts +116 -0
- package/src/ai/cost-tracker.ts +103 -0
- package/src/ai/inference.ts +470 -0
- package/src/ai/phantom-wire.ts +184 -0
- package/src/ai/recipe.ts +178 -0
- package/src/catalog.ts +430 -0
- package/src/config.ts +293 -0
- package/src/detect-source.ts +344 -0
- package/src/detect.ts +207 -0
- package/src/env.ts +35 -0
- package/src/errors.ts +29 -0
- package/src/http.ts +212 -0
- package/src/index.ts +61 -0
- package/src/mcp-writer.ts +50 -0
- package/src/oauth.ts +184 -0
- package/src/phantom.ts +156 -0
- package/src/pipeline.ts +131 -0
- package/src/providers/_api-key.ts +122 -0
- package/src/providers/_base.ts +96 -0
- package/src/providers/_helpers.ts +81 -0
- package/src/providers/anthropic.ts +27 -0
- package/src/providers/aws.ts +198 -0
- package/src/providers/braintrust.ts +33 -0
- package/src/providers/clerk.ts +23 -0
- package/src/providers/cloudflare.ts +146 -0
- package/src/providers/convex.ts +28 -0
- package/src/providers/deepseek.ts +24 -0
- package/src/providers/firebase.ts +35 -0
- package/src/providers/fly.ts +24 -0
- package/src/providers/github.ts +180 -0
- package/src/providers/index.ts +59 -0
- package/src/providers/linear.ts +46 -0
- package/src/providers/mailgun.ts +26 -0
- package/src/providers/modal.ts +27 -0
- package/src/providers/neon.ts +175 -0
- package/src/providers/openai.ts +24 -0
- package/src/providers/posthog.ts +30 -0
- package/src/providers/postmark.ts +27 -0
- package/src/providers/railway.ts +35 -0
- package/src/providers/render.ts +31 -0
- package/src/providers/replicate.ts +30 -0
- package/src/providers/resend.ts +24 -0
- package/src/providers/sendgrid.ts +24 -0
- package/src/providers/sentry.ts +183 -0
- package/src/providers/stripe.ts +33 -0
- package/src/providers/supabase.ts +309 -0
- package/src/providers/turso.ts +197 -0
- package/src/providers/upstash.ts +32 -0
- package/src/providers/vercel.ts +134 -0
- package/src/providers/xai.ts +24 -0
- package/src/registry.ts +114 -0
- package/src/templates.ts +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ashlr / Mason Wyatt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# @ashlr/stack-core
|
|
2
|
+
|
|
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
|
+
|
|
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.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add @ashlr/stack-core
|
|
11
|
+
# or
|
|
12
|
+
npm i @ashlr/stack-core
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Public API
|
|
16
|
+
|
|
17
|
+
Selected exports — see `src/index.ts` for the full surface.
|
|
18
|
+
|
|
19
|
+
| Export | Purpose |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| `addService(opts)` | End-to-end pipeline: login → provision → materialize → persist secrets + MCP entry |
|
|
22
|
+
| `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` |
|
|
25
|
+
| `detectProvider(envName)` | Map an env-var name back to a known provider |
|
|
26
|
+
| `parseEnv(text)` | Parse `.env`-style text into `{ key, value }` pairs |
|
|
27
|
+
|
|
28
|
+
### Example
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import {
|
|
32
|
+
addService,
|
|
33
|
+
readConfig,
|
|
34
|
+
listProviderNames,
|
|
35
|
+
scanSource,
|
|
36
|
+
} from "@ashlr/stack-core";
|
|
37
|
+
|
|
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() });
|
|
41
|
+
|
|
42
|
+
const cfg = await readConfig();
|
|
43
|
+
console.log(cfg.services.map((s) => s.name));
|
|
44
|
+
|
|
45
|
+
// Scan an existing checkout for providers already wired up.
|
|
46
|
+
const detections = await scanSource({ cwd: process.cwd() });
|
|
47
|
+
console.log(listProviderNames().length); // 23
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Links
|
|
51
|
+
|
|
52
|
+
- Full CLI-level docs — [stack.ashlr.ai/docs/cli](https://stack.ashlr.ai/docs/cli)
|
|
53
|
+
- Repo — [github.com/ashlrai/ashlr-stack](https://github.com/ashlrai/ashlr-stack)
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT. See [LICENSE](./LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ashlr/stack-core",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Ashlr Stack core — provider adapters, .stack.toml config, Phantom integration. Shared logic behind the Stack CLI and MCP server.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Mason Wyatt",
|
|
8
|
+
"url": "https://github.com/ashlrai"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://stack.ashlr.ai",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/ashlrai/ashlr-stack.git",
|
|
14
|
+
"directory": "packages/core"
|
|
15
|
+
},
|
|
16
|
+
"bugs": "https://github.com/ashlrai/ashlr-stack/issues",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"ashlr",
|
|
19
|
+
"stack",
|
|
20
|
+
"provider",
|
|
21
|
+
"adapter",
|
|
22
|
+
"mcp",
|
|
23
|
+
"phantom-secrets",
|
|
24
|
+
"config",
|
|
25
|
+
"oauth",
|
|
26
|
+
"pkce"
|
|
27
|
+
],
|
|
28
|
+
"type": "module",
|
|
29
|
+
"main": "./src/index.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": "./src/index.ts",
|
|
32
|
+
"./providers": "./src/providers/index.ts",
|
|
33
|
+
"./phantom": "./src/phantom.ts",
|
|
34
|
+
"./config": "./src/config.ts",
|
|
35
|
+
"./mcp-writer": "./src/mcp-writer.ts"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"src/**/*",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public",
|
|
44
|
+
"registry": "https://registry.npmjs.org/"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"bun": ">=1.2.0",
|
|
48
|
+
"node": ">=20"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"smol-toml": "^1.3.1"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/bun": "latest",
|
|
55
|
+
"typescript": "^5.7.0"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { __resetPhantomCache } from "../phantom.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Fake `phantom` binary for integration tests. Writes a bash script into a
|
|
8
|
+
* tmpdir, prepends that tmpdir to PATH, and exposes helpers to inspect the
|
|
9
|
+
* resulting "vault" (a JSON file) and the call log.
|
|
10
|
+
*
|
|
11
|
+
* Subset of the real Phantom CLI: add/remove/list/reveal/status/--version.
|
|
12
|
+
* Enough for Stack's core paths; doesn't model cloud sync or the proxy.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface Harness {
|
|
16
|
+
dir: string;
|
|
17
|
+
vaultPath: string;
|
|
18
|
+
logPath: string;
|
|
19
|
+
calls(): Array<{ cmd: string; args: string[] }>;
|
|
20
|
+
callsTo(cmd: string): Array<{ cmd: string; args: string[] }>;
|
|
21
|
+
cleanup(): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function setupFakePhantom(initial: Record<string, string> = {}): Harness {
|
|
25
|
+
const dir = mkdtempSync(join(tmpdir(), "stack-phantom-"));
|
|
26
|
+
const vaultPath = join(dir, "vault.json");
|
|
27
|
+
const logPath = join(dir, "calls.log");
|
|
28
|
+
|
|
29
|
+
// Also isolate the project registry so tests don't pollute ~/.stack/.
|
|
30
|
+
const registryDir = mkdtempSync(join(tmpdir(), "stack-registry-"));
|
|
31
|
+
const previousRegistryDir = process.env.STACK_REGISTRY_DIR;
|
|
32
|
+
process.env.STACK_REGISTRY_DIR = registryDir;
|
|
33
|
+
writeFileSync(vaultPath, JSON.stringify(initial));
|
|
34
|
+
writeFileSync(logPath, "");
|
|
35
|
+
|
|
36
|
+
// The vault/log paths are trusted (we created them), but ALL args ($1..$N)
|
|
37
|
+
// come from tests that may contain user-shaped secret values with quotes,
|
|
38
|
+
// newlines, backslashes, etc. Those must NEVER be interpolated into Python
|
|
39
|
+
// source — instead we pass them via sys.argv so the shell + Python handle
|
|
40
|
+
// quoting for us.
|
|
41
|
+
const script = `#!/usr/bin/env bash
|
|
42
|
+
set -e
|
|
43
|
+
VAULT=${JSON.stringify(vaultPath)}
|
|
44
|
+
LOG=${JSON.stringify(logPath)}
|
|
45
|
+
|
|
46
|
+
# Log the invocation as a single JSON line (args passed via argv, never interpolated).
|
|
47
|
+
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
|
|
48
|
+
|
|
49
|
+
case "\${1:-}" in
|
|
50
|
+
--version)
|
|
51
|
+
echo "phantom fake 0.0.1"
|
|
52
|
+
;;
|
|
53
|
+
status)
|
|
54
|
+
echo "ok"
|
|
55
|
+
;;
|
|
56
|
+
add)
|
|
57
|
+
python3 -c 'import json,sys
|
|
58
|
+
with open(sys.argv[1]) as f: v = json.load(f)
|
|
59
|
+
v[sys.argv[2]] = sys.argv[3]
|
|
60
|
+
with open(sys.argv[1], "w") as f: json.dump(v, f)
|
|
61
|
+
' "$VAULT" "$2" "$3"
|
|
62
|
+
;;
|
|
63
|
+
remove)
|
|
64
|
+
python3 -c 'import json,sys
|
|
65
|
+
with open(sys.argv[1]) as f: v = json.load(f)
|
|
66
|
+
v.pop(sys.argv[2], None)
|
|
67
|
+
with open(sys.argv[1], "w") as f: json.dump(v, f)
|
|
68
|
+
' "$VAULT" "$2"
|
|
69
|
+
;;
|
|
70
|
+
list)
|
|
71
|
+
python3 -c 'import json,sys
|
|
72
|
+
with open(sys.argv[1]) as f: v = json.load(f)
|
|
73
|
+
for k in v: print(k)
|
|
74
|
+
' "$VAULT"
|
|
75
|
+
;;
|
|
76
|
+
reveal)
|
|
77
|
+
python3 -c 'import json,sys
|
|
78
|
+
with open(sys.argv[1]) as f: v = json.load(f)
|
|
79
|
+
print(v.get(sys.argv[2], ""))
|
|
80
|
+
' "$VAULT" "$2"
|
|
81
|
+
;;
|
|
82
|
+
init)
|
|
83
|
+
# no-op for the fake — the vault file already exists
|
|
84
|
+
echo "initialized"
|
|
85
|
+
;;
|
|
86
|
+
*)
|
|
87
|
+
echo "fake phantom: unknown command: \${1:-}" >&2
|
|
88
|
+
exit 0
|
|
89
|
+
;;
|
|
90
|
+
esac
|
|
91
|
+
`;
|
|
92
|
+
const binPath = join(dir, "phantom");
|
|
93
|
+
writeFileSync(binPath, script);
|
|
94
|
+
chmodSync(binPath, 0o755);
|
|
95
|
+
|
|
96
|
+
const previousPath = process.env.PATH ?? "";
|
|
97
|
+
process.env.PATH = `${dir}:${previousPath}`;
|
|
98
|
+
__resetPhantomCache();
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
dir,
|
|
102
|
+
vaultPath,
|
|
103
|
+
logPath,
|
|
104
|
+
calls() {
|
|
105
|
+
return readCalls(logPath);
|
|
106
|
+
},
|
|
107
|
+
callsTo(cmd: string) {
|
|
108
|
+
return readCalls(logPath).filter((c) => c.cmd === cmd);
|
|
109
|
+
},
|
|
110
|
+
cleanup() {
|
|
111
|
+
process.env.PATH = previousPath;
|
|
112
|
+
if (previousRegistryDir === undefined) delete process.env.STACK_REGISTRY_DIR;
|
|
113
|
+
else process.env.STACK_REGISTRY_DIR = previousRegistryDir;
|
|
114
|
+
__resetPhantomCache();
|
|
115
|
+
try {
|
|
116
|
+
rmSync(dir, { recursive: true, force: true });
|
|
117
|
+
rmSync(registryDir, { recursive: true, force: true });
|
|
118
|
+
} catch {
|
|
119
|
+
/* best-effort */
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function readVault(dir: string): Promise<Record<string, string>> {
|
|
126
|
+
const content = readFileSync(join(dir, "vault.json"), "utf-8");
|
|
127
|
+
return JSON.parse(content) as Record<string, string>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function readCalls(logPath: string): Array<{ cmd: string; args: string[] }> {
|
|
131
|
+
try {
|
|
132
|
+
const text = readFileSync(logPath, "utf-8");
|
|
133
|
+
return text
|
|
134
|
+
.split("\n")
|
|
135
|
+
.filter((line) => line.trim().length > 0)
|
|
136
|
+
.map((line) => {
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(line) as { cmd: string; args: string[] };
|
|
139
|
+
} catch {
|
|
140
|
+
return { cmd: "", args: [] };
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
} catch {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ProviderContext } from "../providers/_base.ts";
|
|
3
|
+
import { type Harness, readVault, setupFakePhantom } from "./_harness.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parameterized integration tests for every `makeApiKeyProvider`-based
|
|
7
|
+
* provider. Each spec declares:
|
|
8
|
+
* - module name (provider file)
|
|
9
|
+
* - expected secret name (the slot stored in Phantom)
|
|
10
|
+
* - a mocked HTTP response shape that `verify` should accept
|
|
11
|
+
* - a mocked HTTP response that `verify` should reject (unauthorized)
|
|
12
|
+
*
|
|
13
|
+
* This guards against API-shape drift — if, say, OpenAI changes `/v1/models`
|
|
14
|
+
* to return a different JSON shape, this test catches it immediately.
|
|
15
|
+
*
|
|
16
|
+
* Covers: openai, anthropic, xai, deepseek, resend, clerk, upstash, railway,
|
|
17
|
+
* fly, posthog, linear, stripe, convex, render, firebase.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
interface Spec {
|
|
21
|
+
modulePath: string;
|
|
22
|
+
providerName: string;
|
|
23
|
+
secretName: string;
|
|
24
|
+
/** A pasted-key shape the provider should accept on `.verify()`. */
|
|
25
|
+
validKey: string;
|
|
26
|
+
/** Mocked fetch response body for the verify success path. */
|
|
27
|
+
validBody: unknown;
|
|
28
|
+
/** Mocked fetch response status for the verify failure path (e.g. 401). */
|
|
29
|
+
invalidStatus?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SPECS: Spec[] = [
|
|
33
|
+
{
|
|
34
|
+
modulePath: "../providers/openai.ts",
|
|
35
|
+
providerName: "openai",
|
|
36
|
+
secretName: "OPENAI_API_KEY",
|
|
37
|
+
validKey: "sk-fake-openai",
|
|
38
|
+
validBody: { data: [{ id: "gpt-4o" }] },
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
modulePath: "../providers/anthropic.ts",
|
|
42
|
+
providerName: "anthropic",
|
|
43
|
+
secretName: "ANTHROPIC_API_KEY",
|
|
44
|
+
validKey: "sk-ant-fake",
|
|
45
|
+
validBody: { data: [{ id: "claude-opus" }] },
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
modulePath: "../providers/xai.ts",
|
|
49
|
+
providerName: "xai",
|
|
50
|
+
secretName: "XAI_API_KEY",
|
|
51
|
+
validKey: "xai-fake",
|
|
52
|
+
validBody: { data: [{ id: "grok-4" }] },
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
modulePath: "../providers/deepseek.ts",
|
|
56
|
+
providerName: "deepseek",
|
|
57
|
+
secretName: "DEEPSEEK_API_KEY",
|
|
58
|
+
validKey: "sk-deepseek-fake",
|
|
59
|
+
validBody: { data: [{ id: "deepseek-chat" }] },
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
modulePath: "../providers/resend.ts",
|
|
63
|
+
providerName: "resend",
|
|
64
|
+
secretName: "RESEND_API_KEY",
|
|
65
|
+
validKey: "re_fake",
|
|
66
|
+
validBody: { data: [{ id: "dom-1" }] },
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
modulePath: "../providers/clerk.ts",
|
|
70
|
+
providerName: "clerk",
|
|
71
|
+
secretName: "CLERK_SECRET_KEY",
|
|
72
|
+
validKey: "sk_test_fake",
|
|
73
|
+
validBody: { keys: [] },
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
modulePath: "../providers/upstash.ts",
|
|
77
|
+
providerName: "upstash",
|
|
78
|
+
secretName: "UPSTASH_MANAGEMENT_TOKEN",
|
|
79
|
+
validKey: "email@example.com:token",
|
|
80
|
+
validBody: [],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
modulePath: "../providers/railway.ts",
|
|
84
|
+
providerName: "railway",
|
|
85
|
+
secretName: "RAILWAY_TOKEN",
|
|
86
|
+
validKey: "railway-fake",
|
|
87
|
+
validBody: { data: { me: { id: "u1", email: "me@example.com" } } },
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
modulePath: "../providers/fly.ts",
|
|
91
|
+
providerName: "fly",
|
|
92
|
+
secretName: "FLY_API_TOKEN",
|
|
93
|
+
validKey: "fm2_fake",
|
|
94
|
+
validBody: { apps: [{ name: "a1" }] },
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
modulePath: "../providers/posthog.ts",
|
|
98
|
+
providerName: "posthog",
|
|
99
|
+
secretName: "POSTHOG_PERSONAL_API_KEY",
|
|
100
|
+
validKey: "phx_fake",
|
|
101
|
+
validBody: { results: [{ id: 1, name: "Default" }] },
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
modulePath: "../providers/linear.ts",
|
|
105
|
+
providerName: "linear",
|
|
106
|
+
secretName: "LINEAR_API_KEY",
|
|
107
|
+
validKey: "lin_api_fake",
|
|
108
|
+
validBody: { data: { viewer: { id: "u1", name: "Me", email: "me@example.com" } } },
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
modulePath: "../providers/stripe.ts",
|
|
112
|
+
providerName: "stripe",
|
|
113
|
+
secretName: "STRIPE_SECRET_KEY",
|
|
114
|
+
validKey: "sk_test_stripe",
|
|
115
|
+
validBody: { id: "acct_fake", type: "standard" },
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
modulePath: "../providers/render.ts",
|
|
119
|
+
providerName: "render",
|
|
120
|
+
secretName: "RENDER_API_KEY",
|
|
121
|
+
validKey: "rnd_fake",
|
|
122
|
+
validBody: [{ owner: { id: "own_1", name: "Me" } }],
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
modulePath: "../providers/replicate.ts",
|
|
126
|
+
providerName: "replicate",
|
|
127
|
+
secretName: "REPLICATE_API_TOKEN",
|
|
128
|
+
validKey: "r8_fake",
|
|
129
|
+
validBody: { username: "mason", type: "user" },
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
modulePath: "../providers/braintrust.ts",
|
|
133
|
+
providerName: "braintrust",
|
|
134
|
+
secretName: "BRAINTRUST_API_KEY",
|
|
135
|
+
validKey: "sk-bt-fake",
|
|
136
|
+
validBody: { objects: [{ id: "org_1", name: "My Org" }] },
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
modulePath: "../providers/sendgrid.ts",
|
|
140
|
+
providerName: "sendgrid",
|
|
141
|
+
secretName: "SENDGRID_API_KEY",
|
|
142
|
+
validKey: "SG.fake",
|
|
143
|
+
validBody: { scopes: ["mail.send", "mail.batch.read"] },
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
modulePath: "../providers/mailgun.ts",
|
|
147
|
+
providerName: "mailgun",
|
|
148
|
+
secretName: "MAILGUN_API_KEY",
|
|
149
|
+
validKey: "key-mailgun-fake",
|
|
150
|
+
validBody: { items: [{ name: "mg.example.com" }] },
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
modulePath: "../providers/postmark.ts",
|
|
154
|
+
providerName: "postmark",
|
|
155
|
+
secretName: "POSTMARK_ACCOUNT_TOKEN",
|
|
156
|
+
validKey: "postmark-account-fake",
|
|
157
|
+
validBody: { Servers: [{ ID: 1, Name: "default" }] },
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
describe("api-key providers — verify + materialize", () => {
|
|
162
|
+
let h: Harness;
|
|
163
|
+
let realFetch: typeof fetch;
|
|
164
|
+
|
|
165
|
+
beforeEach(() => {
|
|
166
|
+
h = setupFakePhantom();
|
|
167
|
+
realFetch = globalThis.fetch;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
afterEach(() => {
|
|
171
|
+
globalThis.fetch = realFetch;
|
|
172
|
+
h.cleanup();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
for (const spec of SPECS) {
|
|
176
|
+
test(`${spec.providerName}: verify accepts a valid key and stores it`, async () => {
|
|
177
|
+
globalThis.fetch = (async () =>
|
|
178
|
+
new Response(JSON.stringify(spec.validBody), { status: 200 })) as unknown as typeof fetch;
|
|
179
|
+
|
|
180
|
+
const provider = (await import(spec.modulePath)).default;
|
|
181
|
+
const ctx: ProviderContext = { cwd: process.cwd(), interactive: false, log: () => {} };
|
|
182
|
+
|
|
183
|
+
// provision is a synthetic step for api-key providers — call directly.
|
|
184
|
+
const auth = { token: spec.validKey, identity: {} as Record<string, string> };
|
|
185
|
+
const resource = await provider.provision(ctx, auth, {});
|
|
186
|
+
const materialized = await provider.materialize(ctx, resource, auth);
|
|
187
|
+
|
|
188
|
+
expect(materialized.secrets[spec.secretName]).toBe(spec.validKey);
|
|
189
|
+
const vault = await readVault(h.dir);
|
|
190
|
+
expect(vault[spec.secretName]).toBe(spec.validKey);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Spot-check one "firebase" provider — its verify is structural (no HTTP), so it can't use the fetch-mock path.
|
|
195
|
+
test("firebase: accepts a valid service-account JSON shape", async () => {
|
|
196
|
+
const firebase = (await import("../providers/firebase.ts")).default;
|
|
197
|
+
const validJson = JSON.stringify({
|
|
198
|
+
type: "service_account",
|
|
199
|
+
project_id: "my-proj",
|
|
200
|
+
client_email: "bot@my-proj.iam.gserviceaccount.com",
|
|
201
|
+
private_key: "-----BEGIN PRIVATE KEY-----...",
|
|
202
|
+
});
|
|
203
|
+
const ctx: ProviderContext = { cwd: process.cwd(), interactive: false, log: () => {} };
|
|
204
|
+
const auth = { token: validJson, identity: {} as Record<string, string> };
|
|
205
|
+
const resource = await firebase.provision(ctx, auth, {});
|
|
206
|
+
const materialized = await firebase.materialize(ctx, resource, auth);
|
|
207
|
+
expect(materialized.secrets.FIREBASE_SERVICE_ACCOUNT_JSON).toBe(validJson);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("firebase: login function exists (structural check)", async () => {
|
|
211
|
+
const firebase = (await import("../providers/firebase.ts")).default;
|
|
212
|
+
expect(typeof firebase.login).toBe("function");
|
|
213
|
+
// The structural verify is deep inside makeApiKeyProvider; we exercise it
|
|
214
|
+
// indirectly through the positive test above. An invalid JSON body would
|
|
215
|
+
// cause login() to throw SUPABASE_AUTH_INVALID-equivalent in non-TTY mode.
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("convex: structural key shape check accepts prod:team:project|token format", async () => {
|
|
219
|
+
const convex = (await import("../providers/convex.ts")).default;
|
|
220
|
+
const validKey = "prod:my-team:my-project|encoded-token-bytes";
|
|
221
|
+
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 resource = await convex.provision(ctx, auth, {});
|
|
224
|
+
const materialized = await convex.materialize(ctx, resource, auth);
|
|
225
|
+
expect(materialized.secrets.CONVEX_DEPLOY_KEY).toBe(validKey);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("modal: accepts <id>:<secret> token shape with ak- or as- prefix", async () => {
|
|
229
|
+
const modal = (await import("../providers/modal.ts")).default;
|
|
230
|
+
const validKey = "ak-1234567890abcdef:secret-token-bytes-here";
|
|
231
|
+
const ctx: ProviderContext = { cwd: process.cwd(), interactive: false, log: () => {} };
|
|
232
|
+
const auth = { token: validKey, identity: { token_id: "ak-1234567890abcdef" } };
|
|
233
|
+
const resource = await modal.provision(ctx, auth, {});
|
|
234
|
+
const materialized = await modal.materialize(ctx, resource, auth);
|
|
235
|
+
expect(materialized.secrets.MODAL_TOKEN).toBe(validKey);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
__resetIndex,
|
|
4
|
+
retrieve,
|
|
5
|
+
retrieveByCategory,
|
|
6
|
+
} from "../ai/catalog-index.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Golden retrieval tests. Each query must surface the right provider(s) in
|
|
10
|
+
* the top few hits. When adding a provider to `catalog.ts`, re-run these to
|
|
11
|
+
* confirm it hasn't poisoned existing rankings.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
afterEach(() => __resetIndex());
|
|
15
|
+
|
|
16
|
+
function names(hits: ReturnType<typeof retrieve>): string[] {
|
|
17
|
+
return hits.map((h) => h.provider.name);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("catalog-index.retrieve", () => {
|
|
21
|
+
it("returns empty array for empty / stopword-only query", () => {
|
|
22
|
+
expect(retrieve("")).toEqual([]);
|
|
23
|
+
expect(retrieve("the a to from")).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("ranks Postgres databases at the top for a database query", () => {
|
|
27
|
+
const hits = names(retrieve("I need a postgres database", { k: 4 }));
|
|
28
|
+
// Both Neon and Supabase are legitimate top answers for "postgres"; we
|
|
29
|
+
// just require that both appear in the top 2 in some order.
|
|
30
|
+
expect(hits.slice(0, 2).sort()).toEqual(["neon", "supabase"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("finds Clerk + Supabase for an auth query", () => {
|
|
34
|
+
const hits = names(retrieve("drop-in auth with users", { k: 4 }));
|
|
35
|
+
expect(hits).toContain("clerk");
|
|
36
|
+
// Supabase also mentions "Auth" in its blurb — it should rank too.
|
|
37
|
+
expect(hits.length).toBeGreaterThan(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("picks Stripe for payments", () => {
|
|
41
|
+
const hits = names(retrieve("accept payments from customers", { k: 3 }));
|
|
42
|
+
expect(hits[0]).toBe("stripe");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("picks Vercel for frontend hosting", () => {
|
|
46
|
+
const hits = names(retrieve("deploy a next.js frontend", { k: 3 }));
|
|
47
|
+
expect(hits[0]).toBe("vercel");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("picks Sentry for error tracking", () => {
|
|
51
|
+
const hits = names(retrieve("error tracking and monitoring", { k: 3 }));
|
|
52
|
+
expect(hits[0]).toBe("sentry");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("picks Anthropic when Claude is named", () => {
|
|
56
|
+
const hits = names(retrieve("LLM provider for Claude models", { k: 3 }));
|
|
57
|
+
expect(hits[0]).toBe("anthropic");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("picks OpenAI when GPT is named", () => {
|
|
61
|
+
const hits = names(retrieve("GPT-4 for my chatbot", { k: 3 }));
|
|
62
|
+
expect(hits[0]).toBe("openai");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("composes a B2B SaaS recipe across categories", () => {
|
|
66
|
+
const hits = names(retrieve(
|
|
67
|
+
"B2B SaaS with auth and payments and AI and error tracking",
|
|
68
|
+
{ k: 8 },
|
|
69
|
+
));
|
|
70
|
+
// Expect at least one provider from each intended category.
|
|
71
|
+
expect(hits).toContain("stripe"); // Payments
|
|
72
|
+
expect(hits).toContain("sentry"); // Errors
|
|
73
|
+
// One of the auth providers
|
|
74
|
+
expect(hits.some((n) => ["clerk", "supabase"].includes(n))).toBe(true);
|
|
75
|
+
// One of the AI providers
|
|
76
|
+
expect(hits.some((n) => ["anthropic", "openai", "xai", "deepseek"].includes(n))).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("respects category filter", () => {
|
|
80
|
+
const hits = names(retrieve("edge compute", {
|
|
81
|
+
k: 5,
|
|
82
|
+
categories: ["Deploy"],
|
|
83
|
+
}));
|
|
84
|
+
for (const n of hits) {
|
|
85
|
+
const cat = ["vercel", "railway", "fly", "cloudflare", "render"].includes(n);
|
|
86
|
+
expect(cat).toBe(true);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("returns empty for gibberish queries below threshold", () => {
|
|
91
|
+
const hits = retrieve("xyzabc qqq noop");
|
|
92
|
+
expect(hits).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("retrieveByCategory buckets results", () => {
|
|
96
|
+
const grouped = retrieveByCategory(
|
|
97
|
+
"B2B SaaS with auth and payments and AI",
|
|
98
|
+
{ k: 20 },
|
|
99
|
+
);
|
|
100
|
+
expect(Object.keys(grouped).length).toBeGreaterThan(1);
|
|
101
|
+
expect(grouped.Payments?.[0]?.provider.name).toBe("stripe");
|
|
102
|
+
});
|
|
103
|
+
});
|