@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/src/oauth.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { platform } from "node:os";
|
|
5
|
+
import type { AddressInfo } from "node:net";
|
|
6
|
+
import { StackError } from "./errors.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Minimal OAuth PKCE helper suitable for CLI/desktop OAuth flows. Spins up a
|
|
10
|
+
* localhost loopback server, opens the browser to the provider auth URL,
|
|
11
|
+
* catches the callback, then exchanges the code for an access token.
|
|
12
|
+
*
|
|
13
|
+
* This helper is transport-only — each provider composes it with its own URLs
|
|
14
|
+
* and scopes.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface PkceFlowOptions {
|
|
18
|
+
clientId: string;
|
|
19
|
+
authUrl: string;
|
|
20
|
+
tokenUrl: string;
|
|
21
|
+
scope?: string;
|
|
22
|
+
/** Additional query params for the authorize request. */
|
|
23
|
+
extraAuthParams?: Record<string, string>;
|
|
24
|
+
/** Human-readable label used in console prompts. */
|
|
25
|
+
providerName: string;
|
|
26
|
+
/** Timeout in ms. Defaults to 5 minutes. */
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TokenResponse {
|
|
31
|
+
access_token: string;
|
|
32
|
+
token_type?: string;
|
|
33
|
+
expires_in?: number;
|
|
34
|
+
refresh_token?: string;
|
|
35
|
+
scope?: string;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function runPkceFlow(opts: PkceFlowOptions): Promise<TokenResponse> {
|
|
40
|
+
const verifier = base64url(randomBytes(64));
|
|
41
|
+
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
|
42
|
+
const state = base64url(randomBytes(16));
|
|
43
|
+
|
|
44
|
+
const { code, redirectUri } = await captureCallback({
|
|
45
|
+
state,
|
|
46
|
+
authUrl: opts.authUrl,
|
|
47
|
+
clientId: opts.clientId,
|
|
48
|
+
challenge,
|
|
49
|
+
scope: opts.scope,
|
|
50
|
+
extraAuthParams: opts.extraAuthParams,
|
|
51
|
+
providerName: opts.providerName,
|
|
52
|
+
timeoutMs: opts.timeoutMs ?? 5 * 60 * 1000,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const body = new URLSearchParams({
|
|
56
|
+
grant_type: "authorization_code",
|
|
57
|
+
code,
|
|
58
|
+
redirect_uri: redirectUri,
|
|
59
|
+
client_id: opts.clientId,
|
|
60
|
+
code_verifier: verifier,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const response = await fetch(opts.tokenUrl, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
66
|
+
body,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
const text = await response.text();
|
|
71
|
+
throw new StackError(
|
|
72
|
+
"OAUTH_TOKEN_EXCHANGE_FAILED",
|
|
73
|
+
`${opts.providerName} rejected the token exchange (${response.status}): ${text}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (await response.json()) as TokenResponse;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface CaptureArgs {
|
|
81
|
+
state: string;
|
|
82
|
+
authUrl: string;
|
|
83
|
+
clientId: string;
|
|
84
|
+
challenge: string;
|
|
85
|
+
scope?: string;
|
|
86
|
+
extraAuthParams?: Record<string, string>;
|
|
87
|
+
providerName: string;
|
|
88
|
+
timeoutMs: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function captureCallback(args: CaptureArgs): Promise<{ code: string; redirectUri: string }> {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
// Bind the port BEFORE registering the request handler. This guarantees
|
|
94
|
+
// `redirectUri` is a completed string by the time any callback request
|
|
95
|
+
// can possibly arrive, eliminating the race where an adversary-replayed
|
|
96
|
+
// callback could land with `redirectUri === ""`.
|
|
97
|
+
const server = createServer();
|
|
98
|
+
|
|
99
|
+
const timer = setTimeout(() => {
|
|
100
|
+
server.close();
|
|
101
|
+
reject(new StackError("OAUTH_TIMEOUT", `${args.providerName} auth timed out.`));
|
|
102
|
+
}, args.timeoutMs);
|
|
103
|
+
server.on("close", () => clearTimeout(timer));
|
|
104
|
+
|
|
105
|
+
server.listen(0, "127.0.0.1", () => {
|
|
106
|
+
const { port } = server.address() as AddressInfo;
|
|
107
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
108
|
+
|
|
109
|
+
server.on("request", (req, res) => {
|
|
110
|
+
if (!req.url) {
|
|
111
|
+
res.writeHead(400).end();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
115
|
+
if (url.pathname !== "/callback") {
|
|
116
|
+
res.writeHead(404).end();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const receivedState = url.searchParams.get("state");
|
|
120
|
+
const code = url.searchParams.get("code");
|
|
121
|
+
const error = url.searchParams.get("error");
|
|
122
|
+
if (error) {
|
|
123
|
+
res
|
|
124
|
+
.writeHead(400, { "content-type": "text/html" })
|
|
125
|
+
.end(htmlPage("error", args.providerName, error));
|
|
126
|
+
server.close();
|
|
127
|
+
reject(new StackError("OAUTH_DENIED", `${args.providerName} auth denied: ${error}`));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!code || receivedState !== args.state) {
|
|
131
|
+
res
|
|
132
|
+
.writeHead(400, { "content-type": "text/html" })
|
|
133
|
+
.end(htmlPage("error", args.providerName, "state mismatch"));
|
|
134
|
+
server.close();
|
|
135
|
+
reject(new StackError("OAUTH_STATE_MISMATCH", "Auth callback state did not match request."));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
res.writeHead(200, { "content-type": "text/html" }).end(htmlPage("ok", args.providerName));
|
|
139
|
+
server.close();
|
|
140
|
+
resolve({ code, redirectUri });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const params = new URLSearchParams({
|
|
144
|
+
response_type: "code",
|
|
145
|
+
client_id: args.clientId,
|
|
146
|
+
redirect_uri: redirectUri,
|
|
147
|
+
state: args.state,
|
|
148
|
+
code_challenge: args.challenge,
|
|
149
|
+
code_challenge_method: "S256",
|
|
150
|
+
...(args.scope ? { scope: args.scope } : {}),
|
|
151
|
+
...(args.extraAuthParams ?? {}),
|
|
152
|
+
});
|
|
153
|
+
const authorizeUrl = `${args.authUrl}?${params.toString()}`;
|
|
154
|
+
|
|
155
|
+
console.log(`\n Opening ${args.providerName} in your browser…`);
|
|
156
|
+
console.log(` If nothing happens, visit:\n ${authorizeUrl}\n`);
|
|
157
|
+
openBrowser(authorizeUrl);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function openBrowser(url: string): void {
|
|
163
|
+
const cmd = platform() === "darwin" ? "open" : platform() === "win32" ? "start" : "xdg-open";
|
|
164
|
+
try {
|
|
165
|
+
spawn(cmd, [url], { stdio: "ignore", detached: true }).unref();
|
|
166
|
+
} catch {
|
|
167
|
+
/* user can still copy the URL manually */
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function base64url(buf: Buffer | Uint8Array): string {
|
|
172
|
+
return Buffer.from(buf).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function htmlPage(kind: "ok" | "error", provider: string, detail?: string): string {
|
|
176
|
+
const title = kind === "ok" ? "Sign-in complete" : "Sign-in failed";
|
|
177
|
+
const body =
|
|
178
|
+
kind === "ok"
|
|
179
|
+
? `You can close this tab and return to your terminal. Stack has finished wiring up ${provider}.`
|
|
180
|
+
: `Something went wrong${detail ? `: ${detail}` : ""}. Return to your terminal for next steps.`;
|
|
181
|
+
return `<!doctype html><meta charset="utf-8"><title>${title}</title>
|
|
182
|
+
<style>html,body{height:100%;margin:0;font-family:ui-sans-serif,system-ui,-apple-system}main{display:grid;place-items:center;height:100%}section{max-width:420px;padding:32px;border-radius:12px;background:#fafafa;border:1px solid #eee}h1{font-size:18px;margin:0 0 8px}p{margin:0;color:#555;line-height:1.5}</style>
|
|
183
|
+
<main><section><h1>${title}</h1><p>${body}</p></section></main>`;
|
|
184
|
+
}
|
package/src/phantom.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { PhantomNotInstalledError, StackError } from "./errors.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Thin wrapper around the `phantom` CLI. Stack never stores secrets locally —
|
|
6
|
+
* every read/write goes through Phantom. If Phantom is missing, we surface a
|
|
7
|
+
* clear install hint instead of silently falling back to a weaker store.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface PhantomExecResult {
|
|
11
|
+
stdout: string;
|
|
12
|
+
stderr: string;
|
|
13
|
+
code: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let cachedInstalled: boolean | undefined;
|
|
17
|
+
|
|
18
|
+
export async function isPhantomInstalled(): Promise<boolean> {
|
|
19
|
+
if (cachedInstalled !== undefined) return cachedInstalled;
|
|
20
|
+
try {
|
|
21
|
+
const result = await exec(["--version"], { allowFailure: true });
|
|
22
|
+
cachedInstalled = result.code === 0;
|
|
23
|
+
} catch {
|
|
24
|
+
cachedInstalled = false;
|
|
25
|
+
}
|
|
26
|
+
return cachedInstalled;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function assertPhantomInstalled(): Promise<void> {
|
|
30
|
+
if (!(await isPhantomInstalled())) throw new PhantomNotInstalledError();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ExecOptions {
|
|
34
|
+
cwd?: string;
|
|
35
|
+
allowFailure?: boolean;
|
|
36
|
+
stdin?: string;
|
|
37
|
+
/** If true, inherit stdio so interactive flows (OAuth browser) work. */
|
|
38
|
+
interactive?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function exec(args: string[], opts: ExecOptions = {}): Promise<PhantomExecResult> {
|
|
42
|
+
return new Promise((resolvePromise, reject) => {
|
|
43
|
+
const child = spawn("phantom", args, {
|
|
44
|
+
cwd: opts.cwd,
|
|
45
|
+
stdio: opts.interactive ? "inherit" : ["pipe", "pipe", "pipe"],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
let stdout = "";
|
|
49
|
+
let stderr = "";
|
|
50
|
+
|
|
51
|
+
if (!opts.interactive) {
|
|
52
|
+
child.stdout?.on("data", (chunk) => {
|
|
53
|
+
stdout += chunk.toString();
|
|
54
|
+
});
|
|
55
|
+
child.stderr?.on("data", (chunk) => {
|
|
56
|
+
stderr += chunk.toString();
|
|
57
|
+
});
|
|
58
|
+
if (opts.stdin !== undefined) {
|
|
59
|
+
child.stdin?.write(opts.stdin);
|
|
60
|
+
child.stdin?.end();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
child.on("error", (err) => {
|
|
65
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
66
|
+
reject(new PhantomNotInstalledError());
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
reject(err);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
child.on("close", (code) => {
|
|
73
|
+
const result: PhantomExecResult = { stdout, stderr, code: code ?? 0 };
|
|
74
|
+
if (code !== 0 && !opts.allowFailure) {
|
|
75
|
+
reject(
|
|
76
|
+
new StackError(
|
|
77
|
+
"PHANTOM_EXEC_FAILED",
|
|
78
|
+
`phantom ${formatArgsForError(args)} failed (exit ${code}): ${stderr.trim() || stdout.trim()}`,
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
resolvePromise(result);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function addSecret(key: string, value: string, cwd?: string): Promise<void> {
|
|
89
|
+
await assertPhantomInstalled();
|
|
90
|
+
await exec(["add", key, value], { cwd });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function removeSecret(key: string, cwd?: string): Promise<void> {
|
|
94
|
+
await assertPhantomInstalled();
|
|
95
|
+
await exec(["remove", key], { cwd, allowFailure: true });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function listSecrets(cwd?: string): Promise<string[]> {
|
|
99
|
+
await assertPhantomInstalled();
|
|
100
|
+
const { stdout } = await exec(["list"], { cwd, allowFailure: true });
|
|
101
|
+
return stdout
|
|
102
|
+
.split("\n")
|
|
103
|
+
.map((line) => line.trim())
|
|
104
|
+
.filter((line) => line.length > 0 && !line.startsWith("#"));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function revealSecret(key: string, cwd?: string): Promise<string> {
|
|
108
|
+
await assertPhantomInstalled();
|
|
109
|
+
const { stdout } = await exec(["reveal", key], { cwd });
|
|
110
|
+
return stdout.trim();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function ensureInitialized(cwd?: string): Promise<void> {
|
|
114
|
+
await assertPhantomInstalled();
|
|
115
|
+
const result = await exec(["status"], { cwd, allowFailure: true });
|
|
116
|
+
if (result.code !== 0 || /not initialized/i.test(result.stdout + result.stderr)) {
|
|
117
|
+
await exec(["init"], { cwd, interactive: true });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function syncToPlatform(
|
|
122
|
+
platform: "vercel" | "railway" | "fly",
|
|
123
|
+
projectId: string,
|
|
124
|
+
cwd?: string,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
await assertPhantomInstalled();
|
|
127
|
+
await exec(["sync", "--platform", platform, "--project", projectId], { cwd, interactive: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* For tests only. Resets the installation-detection cache so a fake `phantom`
|
|
132
|
+
* binary placed on PATH by a test harness can be picked up.
|
|
133
|
+
*/
|
|
134
|
+
export function __resetPhantomCache(): void {
|
|
135
|
+
cachedInstalled = undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Redact the secret value in phantom CLI arg lists before they're interpolated
|
|
140
|
+
* into any error message or log line. `phantom add <KEY> <VALUE>` is the main
|
|
141
|
+
* risk surface: without this, a non-zero exit would leak the raw secret in the
|
|
142
|
+
* thrown error. Also redacts `add-secret` just in case a future MCP sub-tool
|
|
143
|
+
* shells out with a different verb.
|
|
144
|
+
*/
|
|
145
|
+
function formatArgsForError(args: string[]): string {
|
|
146
|
+
const SENSITIVE_FOLLOWERS = new Set(["add", "add-secret"]);
|
|
147
|
+
const redacted = args.map((arg, i) => {
|
|
148
|
+
const prev = args[i - 1];
|
|
149
|
+
// For `add KEY VALUE`, the third arg (i=2 if args[0] is the verb) is the value.
|
|
150
|
+
if (SENSITIVE_FOLLOWERS.has(args[0]) && i === 2) return "<redacted>";
|
|
151
|
+
// `reveal KEY` has no secret value in args, but belt-and-suspenders for future verbs.
|
|
152
|
+
if (prev && SENSITIVE_FOLLOWERS.has(prev) && i === 2) return "<redacted>";
|
|
153
|
+
return arg;
|
|
154
|
+
});
|
|
155
|
+
return redacted.join(" ");
|
|
156
|
+
}
|
package/src/pipeline.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ServiceEntry,
|
|
3
|
+
type StackConfig,
|
|
4
|
+
readConfig,
|
|
5
|
+
writeConfig,
|
|
6
|
+
} from "./config.ts";
|
|
7
|
+
import { StackError } from "./errors.ts";
|
|
8
|
+
import { addSecret, assertPhantomInstalled } from "./phantom.ts";
|
|
9
|
+
import { mergeMcpEntry } from "./mcp-writer.ts";
|
|
10
|
+
import { getProvider } from "./providers/index.ts";
|
|
11
|
+
import type { LogEvent, ProviderContext } from "./providers/_base.ts";
|
|
12
|
+
|
|
13
|
+
export interface AddServiceOpts {
|
|
14
|
+
providerName: string;
|
|
15
|
+
cwd?: string;
|
|
16
|
+
interactive?: boolean;
|
|
17
|
+
existingResourceId?: string;
|
|
18
|
+
hints?: Record<string, unknown>;
|
|
19
|
+
log?: (event: LogEvent) => void;
|
|
20
|
+
/** If false, skip persisting to .stack.toml (used by dry-run / preview). */
|
|
21
|
+
persist?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AddServiceResult {
|
|
25
|
+
providerName: string;
|
|
26
|
+
resourceId: string;
|
|
27
|
+
displayName: string;
|
|
28
|
+
secretCount: number;
|
|
29
|
+
mcpWired: boolean;
|
|
30
|
+
entry: ServiceEntry;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Full add-service pipeline: login → provision → materialize → write secrets
|
|
35
|
+
* → merge .mcp.json → update .stack.toml. Used by both `stack add` and
|
|
36
|
+
* `stack templates apply` so behaviour stays in lockstep.
|
|
37
|
+
*/
|
|
38
|
+
export async function addService(opts: AddServiceOpts): Promise<AddServiceResult> {
|
|
39
|
+
await assertPhantomInstalled();
|
|
40
|
+
const provider = await getProvider(opts.providerName);
|
|
41
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
42
|
+
const config: StackConfig = await readConfig(cwd);
|
|
43
|
+
|
|
44
|
+
// Attaching to an existing resource id is always allowed — that's the path
|
|
45
|
+
// `stack doctor --fix` uses to re-run the pipeline for a degraded service.
|
|
46
|
+
if (config.services[provider.name] && !opts.existingResourceId) {
|
|
47
|
+
throw new StackError(
|
|
48
|
+
"SERVICE_ALREADY_ADDED",
|
|
49
|
+
`${provider.displayName} is already in this stack. Pass existingResourceId to re-attach, or remove it first.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ctx: ProviderContext = {
|
|
54
|
+
cwd,
|
|
55
|
+
interactive: opts.interactive ?? process.stdout.isTTY === true,
|
|
56
|
+
log: opts.log ?? (() => {}),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const auth = await provider.login(ctx);
|
|
60
|
+
const resource = await provider.provision(ctx, auth, {
|
|
61
|
+
existingResourceId: opts.existingResourceId,
|
|
62
|
+
hints: opts.hints,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Provision succeeded: from here on, any failure leaves a dangling upstream
|
|
66
|
+
// resource. Wrap the rest of the pipeline so we can persist a minimal
|
|
67
|
+
// breadcrumb entry to .stack.toml before re-throwing. This is what lets
|
|
68
|
+
// `stack doctor --fix` (or `stack remove`) clean up later — without the
|
|
69
|
+
// breadcrumb, the user has a live resource on the provider side with no
|
|
70
|
+
// local record at all.
|
|
71
|
+
try {
|
|
72
|
+
const materialized = await provider.materialize(ctx, resource, auth);
|
|
73
|
+
|
|
74
|
+
for (const [key, value] of Object.entries(materialized.secrets)) {
|
|
75
|
+
await addSecret(key, value, cwd);
|
|
76
|
+
}
|
|
77
|
+
if (materialized.mcp) await mergeMcpEntry(materialized.mcp, cwd);
|
|
78
|
+
|
|
79
|
+
const entry: ServiceEntry = {
|
|
80
|
+
provider: provider.name,
|
|
81
|
+
resource_id: resource.id,
|
|
82
|
+
region: resource.region,
|
|
83
|
+
secrets: Object.keys(materialized.secrets),
|
|
84
|
+
mcp: materialized.mcp?.name,
|
|
85
|
+
meta: resource.meta,
|
|
86
|
+
created_at: new Date().toISOString(),
|
|
87
|
+
created_by: "stack add",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
if (opts.persist !== false) {
|
|
91
|
+
config.services[provider.name] = entry;
|
|
92
|
+
await writeConfig(config, cwd);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
providerName: provider.name,
|
|
97
|
+
resourceId: resource.id,
|
|
98
|
+
displayName: resource.displayName,
|
|
99
|
+
secretCount: Object.keys(materialized.secrets).length,
|
|
100
|
+
mcpWired: Boolean(materialized.mcp),
|
|
101
|
+
entry,
|
|
102
|
+
};
|
|
103
|
+
} catch (err) {
|
|
104
|
+
// Partial-failure breadcrumb: write just enough to .stack.toml that a
|
|
105
|
+
// later `stack doctor --fix` or `stack remove` can find the dangling
|
|
106
|
+
// upstream resource. We intentionally leave `secrets: []` — materialize
|
|
107
|
+
// didn't run to completion, so we don't know which vault keys (if any)
|
|
108
|
+
// were populated. Same goes for `mcp`: we don't know what the MCP entry
|
|
109
|
+
// would have been, so we don't claim to have wired one.
|
|
110
|
+
if (opts.persist !== false) {
|
|
111
|
+
const partial: ServiceEntry = {
|
|
112
|
+
provider: provider.name,
|
|
113
|
+
resource_id: resource.id,
|
|
114
|
+
region: resource.region,
|
|
115
|
+
secrets: [],
|
|
116
|
+
meta: resource.meta,
|
|
117
|
+
created_at: new Date().toISOString(),
|
|
118
|
+
created_by: "stack add (partial)",
|
|
119
|
+
};
|
|
120
|
+
try {
|
|
121
|
+
config.services[provider.name] = partial;
|
|
122
|
+
await writeConfig(config, cwd);
|
|
123
|
+
} catch {
|
|
124
|
+
// If even writing the breadcrumb fails, swallow — the original
|
|
125
|
+
// error below is the user-visible signal; we don't want to mask it
|
|
126
|
+
// with a disk-write failure.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { ServiceEntry } from "../config.ts";
|
|
2
|
+
import { StackError } from "../errors.ts";
|
|
3
|
+
import { addSecret } from "../phantom.ts";
|
|
4
|
+
import { readLine, tryRevealSecret } from "./_helpers.ts";
|
|
5
|
+
import type {
|
|
6
|
+
AuthHandle,
|
|
7
|
+
HealthStatus,
|
|
8
|
+
Materialized,
|
|
9
|
+
McpServerEntry,
|
|
10
|
+
Provider,
|
|
11
|
+
ProviderCategory,
|
|
12
|
+
ProviderContext,
|
|
13
|
+
Resource,
|
|
14
|
+
} from "./_base.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Factory for API-key-paste providers (OpenAI, Anthropic, xAI, DeepSeek, Resend,
|
|
18
|
+
* PostHog-PAT, Upstash, Clerk, Railway, Fly). These providers don't provision
|
|
19
|
+
* upstream resources — they just validate a pasted key and route it into the
|
|
20
|
+
* Phantom vault under a canonical secret name.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface ApiKeyProviderSpec {
|
|
24
|
+
name: string;
|
|
25
|
+
displayName: string;
|
|
26
|
+
category: ProviderCategory;
|
|
27
|
+
docs: string;
|
|
28
|
+
/** .env name for the key. Also the Phantom vault key. */
|
|
29
|
+
secretName: string;
|
|
30
|
+
/** Optional friendly instruction shown when prompting for the key. */
|
|
31
|
+
howTo: string;
|
|
32
|
+
/**
|
|
33
|
+
* Verify the pasted key against the provider's API. Return an identity object
|
|
34
|
+
* (e.g. org id, account email) on success, undefined on invalid.
|
|
35
|
+
*/
|
|
36
|
+
verify(key: string): Promise<Record<string, string> | undefined>;
|
|
37
|
+
/** Optional dashboard URL (used by `stack open`). */
|
|
38
|
+
dashboard?: string;
|
|
39
|
+
/** Optional MCP server entry to wire into .mcp.json. */
|
|
40
|
+
mcp?: McpServerEntry;
|
|
41
|
+
/**
|
|
42
|
+
* Optional health check. Defaults to re-running `verify` against the stored
|
|
43
|
+
* secret, which is the right call for stateless API keys.
|
|
44
|
+
*/
|
|
45
|
+
healthcheck?: (ctx: ProviderContext, entry: ServiceEntry) => Promise<HealthStatus>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function makeApiKeyProvider(spec: ApiKeyProviderSpec): Provider {
|
|
49
|
+
return {
|
|
50
|
+
name: spec.name,
|
|
51
|
+
displayName: spec.displayName,
|
|
52
|
+
category: spec.category,
|
|
53
|
+
authKind: "api_key",
|
|
54
|
+
docs: spec.docs,
|
|
55
|
+
|
|
56
|
+
async login(ctx: ProviderContext): Promise<AuthHandle> {
|
|
57
|
+
const cached = await tryRevealSecret(spec.secretName);
|
|
58
|
+
if (cached) {
|
|
59
|
+
const identity = await spec.verify(cached);
|
|
60
|
+
if (identity) return { token: cached, identity };
|
|
61
|
+
ctx.log({ level: "warn", msg: `Cached ${spec.displayName} key invalid; re-entering.` });
|
|
62
|
+
}
|
|
63
|
+
if (!ctx.interactive) {
|
|
64
|
+
throw new StackError(
|
|
65
|
+
`${spec.name.toUpperCase()}_AUTH_REQUIRED`,
|
|
66
|
+
`${spec.displayName}: no valid key in vault and session is non-interactive.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
process.stderr.write(`\n ${spec.howTo}\n Paste your ${spec.displayName} API key: `);
|
|
70
|
+
const key = (await readLine()).trim();
|
|
71
|
+
if (!key) throw new StackError(`${spec.name.toUpperCase()}_AUTH_REQUIRED`, "No key provided.");
|
|
72
|
+
const identity = await spec.verify(key);
|
|
73
|
+
if (!identity)
|
|
74
|
+
throw new StackError(
|
|
75
|
+
`${spec.name.toUpperCase()}_AUTH_INVALID`,
|
|
76
|
+
`${spec.displayName} rejected that key.`,
|
|
77
|
+
);
|
|
78
|
+
// Persist the verified key immediately. Previously we deferred to
|
|
79
|
+
// `materialize()`, so if any step after `login()` threw (e.g. a
|
|
80
|
+
// provision-side API error) the user would have to re-paste their key.
|
|
81
|
+
// Matches the hand-written providers (supabase, neon, vercel, github, sentry).
|
|
82
|
+
await addSecret(spec.secretName, key);
|
|
83
|
+
return { token: key, identity };
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
async provision(_ctx, auth, opts): Promise<Resource> {
|
|
87
|
+
const id = opts.existingResourceId ?? auth.identity?.id ?? auth.identity?.org_id ?? "default";
|
|
88
|
+
return {
|
|
89
|
+
id,
|
|
90
|
+
displayName:
|
|
91
|
+
(auth.identity?.name as string | undefined) ??
|
|
92
|
+
(auth.identity?.email as string | undefined) ??
|
|
93
|
+
spec.displayName,
|
|
94
|
+
meta: auth.identity,
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
async materialize(_ctx, _resource, auth): Promise<Materialized> {
|
|
99
|
+
await addSecret(spec.secretName, auth.token);
|
|
100
|
+
return {
|
|
101
|
+
secrets: { [spec.secretName]: auth.token },
|
|
102
|
+
...(spec.mcp ? { mcp: spec.mcp } : {}),
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
async healthcheck(ctx, entry) {
|
|
107
|
+
if (spec.healthcheck) return spec.healthcheck(ctx, entry);
|
|
108
|
+
const key = await tryRevealSecret(spec.secretName);
|
|
109
|
+
if (!key) return { kind: "error", detail: `${spec.secretName} missing from vault` };
|
|
110
|
+
const start = Date.now();
|
|
111
|
+
const identity = await spec.verify(key);
|
|
112
|
+
const latencyMs = Date.now() - start;
|
|
113
|
+
return identity ? { kind: "ok", latencyMs } : { kind: "error", detail: "key invalid" };
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
dashboardUrl() {
|
|
117
|
+
return spec.dashboard ?? "";
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ServiceEntry } from "../config.ts";
|
|
2
|
+
|
|
3
|
+
export type ProviderCategory =
|
|
4
|
+
| "database"
|
|
5
|
+
| "deploy"
|
|
6
|
+
| "cloud"
|
|
7
|
+
| "analytics"
|
|
8
|
+
| "errors"
|
|
9
|
+
| "ai"
|
|
10
|
+
| "payments"
|
|
11
|
+
| "code"
|
|
12
|
+
| "tickets"
|
|
13
|
+
| "email"
|
|
14
|
+
| "auth"
|
|
15
|
+
| "comms";
|
|
16
|
+
|
|
17
|
+
export type AuthKind = "oauth_pkce" | "oauth_device" | "pat" | "api_key" | "cli_shell";
|
|
18
|
+
|
|
19
|
+
export interface ProviderContext {
|
|
20
|
+
cwd: string;
|
|
21
|
+
/** If non-interactive (CI, agent), providers should fail fast instead of prompting. */
|
|
22
|
+
interactive: boolean;
|
|
23
|
+
/** Structured logger — callers wire this to the CLI's @clack spinner. */
|
|
24
|
+
log: (event: LogEvent) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LogEvent {
|
|
28
|
+
level: "info" | "warn" | "error";
|
|
29
|
+
msg: string;
|
|
30
|
+
data?: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AuthHandle {
|
|
34
|
+
/** Opaque, provider-specific. Usually an access token or PAT. */
|
|
35
|
+
token: string;
|
|
36
|
+
/** Optional provider-side identity (user id, email, org id). */
|
|
37
|
+
identity?: Record<string, string>;
|
|
38
|
+
/** Epoch ms when token expires. Omit if non-expiring. */
|
|
39
|
+
expiresAt?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ProvisionOpts {
|
|
43
|
+
/** If the user already has a resource on the provider side, use this instead of creating. */
|
|
44
|
+
existingResourceId?: string;
|
|
45
|
+
/** Provider-specific provisioning hints (region, tier, org). */
|
|
46
|
+
hints?: Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Resource {
|
|
50
|
+
id: string;
|
|
51
|
+
displayName: string;
|
|
52
|
+
region?: string;
|
|
53
|
+
/** Provider-specific fields persisted into .stack.toml meta. */
|
|
54
|
+
meta?: Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface Materialized {
|
|
58
|
+
/** Secrets to store in Phantom vault. Key is the .env name, value is the real secret. */
|
|
59
|
+
secrets: Record<string, string>;
|
|
60
|
+
/** Optional MCP server entry to merge into .mcp.json. */
|
|
61
|
+
mcp?: McpServerEntry;
|
|
62
|
+
/** Environment URLs or hints to expose in `stack status`. */
|
|
63
|
+
urls?: Record<string, string>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface McpServerEntry {
|
|
67
|
+
name: string;
|
|
68
|
+
type?: "stdio" | "sse" | "http";
|
|
69
|
+
command?: string;
|
|
70
|
+
args?: string[];
|
|
71
|
+
url?: string;
|
|
72
|
+
env?: Record<string, string>;
|
|
73
|
+
headers?: Record<string, string>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type HealthStatus =
|
|
77
|
+
| { kind: "ok"; latencyMs?: number; detail?: string }
|
|
78
|
+
| { kind: "warn"; detail: string }
|
|
79
|
+
| { kind: "error"; detail: string };
|
|
80
|
+
|
|
81
|
+
export interface Provider {
|
|
82
|
+
name: string;
|
|
83
|
+
displayName: string;
|
|
84
|
+
category: ProviderCategory;
|
|
85
|
+
authKind: AuthKind;
|
|
86
|
+
/** Human-readable docs URL for when something goes wrong. */
|
|
87
|
+
docs?: string;
|
|
88
|
+
|
|
89
|
+
login(ctx: ProviderContext): Promise<AuthHandle>;
|
|
90
|
+
provision(ctx: ProviderContext, auth: AuthHandle, opts: ProvisionOpts): Promise<Resource>;
|
|
91
|
+
materialize(ctx: ProviderContext, resource: Resource, auth: AuthHandle): Promise<Materialized>;
|
|
92
|
+
|
|
93
|
+
healthcheck?(ctx: ProviderContext, entry: ServiceEntry): Promise<HealthStatus>;
|
|
94
|
+
dashboardUrl?(entry: ServiceEntry): string;
|
|
95
|
+
deprovision?(ctx: ProviderContext, entry: ServiceEntry, auth: AuthHandle): Promise<void>;
|
|
96
|
+
}
|