@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/src/ai/phantom-wire.ts
CHANGED
|
@@ -1,41 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Recipe → Phantom
|
|
2
|
+
* Recipe → Phantom vault check + webhook stub materializer.
|
|
3
3
|
*
|
|
4
4
|
* For each provider in a Recipe:
|
|
5
|
-
* 1.
|
|
6
|
-
*
|
|
5
|
+
* 1. Report which declared secrets are already in the Phantom vault and
|
|
6
|
+
* which are missing. `stack add` stores the real values; this step never
|
|
7
|
+
* writes to the vault. Phantom Secrets refuses secret values in argv and
|
|
8
|
+
* denies overwriting existing names, so placeholder entries would either
|
|
9
|
+
* fail or block the real value from landing later.
|
|
7
10
|
* 2. For webhook-capable providers (Stripe, Clerk, Supabase, GitHub),
|
|
8
11
|
* scaffold a commented-out handler stub in `.stack/webhooks/<provider>.ts`
|
|
9
12
|
* so the user has somewhere concrete to land the signature verification.
|
|
10
13
|
*
|
|
11
|
-
*
|
|
12
|
-
* for the real credentials, which the provider-specific `addCommand` flow
|
|
13
|
-
* then fills in. An opt-out (`--no-wire`) exists for users who want pure
|
|
14
|
-
* provisioning without Phantom-assisted rotation.
|
|
14
|
+
* An opt-out (`--no-wire`) skips both steps.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
18
18
|
import { dirname, resolve } from "node:path";
|
|
19
19
|
import { findProviderRef } from "../catalog.ts";
|
|
20
|
-
import {
|
|
21
|
-
assertPhantomInstalled,
|
|
22
|
-
exec as phantomExec,
|
|
23
|
-
PhantomExecResult,
|
|
24
|
-
} from "../phantom.ts";
|
|
25
20
|
import { PhantomNotInstalledError } from "../errors.ts";
|
|
21
|
+
import { assertPhantomInstalled, listSecrets } from "../phantom.ts";
|
|
26
22
|
import type { Recipe } from "./recipe.ts";
|
|
27
23
|
|
|
28
24
|
export interface WireResult {
|
|
29
|
-
/** `PROVIDER:SECRET_NAME` entries
|
|
30
|
-
|
|
25
|
+
/** `PROVIDER:SECRET_NAME` entries already stored in the Phantom vault. */
|
|
26
|
+
present: string[];
|
|
27
|
+
/** `PROVIDER:SECRET_NAME` entries the recipe declares but the vault lacks. */
|
|
28
|
+
missing: string[];
|
|
31
29
|
/** Absolute paths of webhook stub files created. */
|
|
32
30
|
webhooks: string[];
|
|
33
|
-
/** Names of providers skipped (phantom missing, unknown provider
|
|
31
|
+
/** Names of providers skipped (phantom missing, vault unreadable, unknown provider). */
|
|
34
32
|
skipped: string[];
|
|
35
33
|
}
|
|
36
34
|
|
|
37
|
-
const PLACEHOLDER = "TODO_STACK_APPLY";
|
|
38
|
-
|
|
39
35
|
/**
|
|
40
36
|
* Providers with webhooks worth pre-wiring. Keep this list tight — each entry
|
|
41
37
|
* gets a hand-maintained stub in `webhookStubFor`. Adding a new provider here
|
|
@@ -48,7 +44,7 @@ export async function wirePhantomForRecipe(
|
|
|
48
44
|
recipe: Recipe,
|
|
49
45
|
opts: { cwd?: string; noWire?: boolean } = {},
|
|
50
46
|
): Promise<WireResult> {
|
|
51
|
-
const result: WireResult = {
|
|
47
|
+
const result: WireResult = { present: [], missing: [], webhooks: [], skipped: [] };
|
|
52
48
|
if (opts.noWire) return result;
|
|
53
49
|
|
|
54
50
|
const cwd = opts.cwd ?? process.cwd();
|
|
@@ -57,7 +53,7 @@ export async function wirePhantomForRecipe(
|
|
|
57
53
|
await assertPhantomInstalled();
|
|
58
54
|
} catch (err) {
|
|
59
55
|
if (err instanceof PhantomNotInstalledError) {
|
|
60
|
-
// Phantom missing → skip
|
|
56
|
+
// Phantom missing → skip the vault check but still drop webhook stubs
|
|
61
57
|
// (they're just files). Surface the provider names so the CLI can tell
|
|
62
58
|
// the user why nothing landed in the vault.
|
|
63
59
|
for (const p of recipe.providers) result.skipped.push(p.name);
|
|
@@ -72,16 +68,16 @@ export async function wirePhantomForRecipe(
|
|
|
72
68
|
throw err;
|
|
73
69
|
}
|
|
74
70
|
|
|
71
|
+
const stored = await listVaultNames(cwd);
|
|
72
|
+
|
|
75
73
|
for (const { name } of recipe.providers) {
|
|
76
74
|
const ref = findProviderRef(name);
|
|
77
|
-
if (!ref) {
|
|
75
|
+
if (!ref || !stored) {
|
|
78
76
|
result.skipped.push(name);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const added = await addEnvelope(secret, cwd);
|
|
84
|
-
if (added) result.envelopes.push(`${name}:${secret}`);
|
|
77
|
+
} else {
|
|
78
|
+
for (const secret of ref.secrets) {
|
|
79
|
+
(stored.has(secret) ? result.present : result.missing).push(`${name}:${secret}`);
|
|
80
|
+
}
|
|
85
81
|
}
|
|
86
82
|
|
|
87
83
|
if (WEBHOOK_PROVIDERS.has(name)) {
|
|
@@ -94,34 +90,15 @@ export async function wirePhantomForRecipe(
|
|
|
94
90
|
}
|
|
95
91
|
|
|
96
92
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
93
|
+
* Secret names in the project's Phantom vault, via `phantom list --json`
|
|
94
|
+
* (names and metadata only, never values). Returns undefined when the vault
|
|
95
|
+
* can't be read (not initialized, older Phantom without `--json`, etc).
|
|
100
96
|
*/
|
|
101
|
-
async function
|
|
102
|
-
const rotateAttempt = await tryExec(["add", key, PLACEHOLDER, "--rotate"], cwd);
|
|
103
|
-
if (rotateAttempt.code === 0) return true;
|
|
104
|
-
|
|
105
|
-
// Heuristic for "Phantom rejected --rotate because it's an older build":
|
|
106
|
-
// stderr mentions the flag name or "unknown option". On any other failure,
|
|
107
|
-
// we already logged it via stderr — the caller's noWire path can skip.
|
|
108
|
-
const stderrLower = rotateAttempt.stderr.toLowerCase();
|
|
109
|
-
const looksLikeUnknownFlag =
|
|
110
|
-
stderrLower.includes("--rotate") ||
|
|
111
|
-
stderrLower.includes("unknown option") ||
|
|
112
|
-
stderrLower.includes("unrecognized");
|
|
113
|
-
|
|
114
|
-
if (!looksLikeUnknownFlag) return false;
|
|
115
|
-
|
|
116
|
-
const fallback = await tryExec(["add", key, PLACEHOLDER], cwd);
|
|
117
|
-
return fallback.code === 0;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async function tryExec(args: string[], cwd: string): Promise<PhantomExecResult> {
|
|
97
|
+
async function listVaultNames(cwd: string): Promise<Set<string> | undefined> {
|
|
121
98
|
try {
|
|
122
|
-
return await
|
|
99
|
+
return new Set(await listSecrets(cwd));
|
|
123
100
|
} catch {
|
|
124
|
-
return
|
|
101
|
+
return undefined;
|
|
125
102
|
}
|
|
126
103
|
}
|
|
127
104
|
|
|
@@ -136,7 +113,7 @@ function webhookStubFor(provider: string): string {
|
|
|
136
113
|
const base = `// Auto-generated by \`stack apply\` — webhook handler stub for ${provider}.
|
|
137
114
|
// Fill in the secret retrieval + signature verification before deploying.
|
|
138
115
|
//
|
|
139
|
-
//
|
|
116
|
+
// The secret lives in Phantom; read it at call time with:
|
|
140
117
|
// phantom reveal ${webhookSecretKeyFor(provider)}
|
|
141
118
|
//
|
|
142
119
|
// export async function handle${capitalize(provider)}Webhook(req: Request): Promise<Response> {
|
package/src/ai/recipe.ts
CHANGED
|
@@ -176,3 +176,72 @@ function coerceRecipe(raw: Record<string, unknown>, fallbackId: string): Recipe
|
|
|
176
176
|
}
|
|
177
177
|
return recipe;
|
|
178
178
|
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Remote recipe marketplace.
|
|
182
|
+
*
|
|
183
|
+
* Opt-in via env var — local recipes always take precedence.
|
|
184
|
+
* Set STACK_RECIPE_BASE_URL to enable remote fallback, e.g.:
|
|
185
|
+
* STACK_RECIPE_BASE_URL=https://rjowfztlxxstcrwjdaet.supabase.co/functions/v1/list-recipes
|
|
186
|
+
*/
|
|
187
|
+
const REMOTE_BASE_URL = process.env.STACK_RECIPE_BASE_URL;
|
|
188
|
+
|
|
189
|
+
/** Fetch all recipes from the remote marketplace (opt-in). Returns [] if disabled or unreachable. */
|
|
190
|
+
export async function listRemoteRecipes(): Promise<Recipe[]> {
|
|
191
|
+
if (!REMOTE_BASE_URL) return [];
|
|
192
|
+
try {
|
|
193
|
+
const res = await fetch(REMOTE_BASE_URL, {
|
|
194
|
+
headers: { Accept: "application/json" },
|
|
195
|
+
signal: AbortSignal.timeout(5000),
|
|
196
|
+
});
|
|
197
|
+
if (!res.ok) return [];
|
|
198
|
+
const json = (await res.json()) as { recipes?: unknown[] };
|
|
199
|
+
const rows = Array.isArray(json.recipes) ? json.recipes : [];
|
|
200
|
+
return rows.map((row) => {
|
|
201
|
+
const r = row as Record<string, unknown>;
|
|
202
|
+
return coerceRecipe(
|
|
203
|
+
{
|
|
204
|
+
id: r.slug,
|
|
205
|
+
query: r.query,
|
|
206
|
+
createdAt: r.created_at,
|
|
207
|
+
providers: r.providers,
|
|
208
|
+
guidance: undefined,
|
|
209
|
+
},
|
|
210
|
+
String(r.slug ?? ""),
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
} catch {
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Fetch a single recipe by slug from the remote marketplace (opt-in).
|
|
220
|
+
* Returns undefined if disabled, not found, or unreachable.
|
|
221
|
+
*/
|
|
222
|
+
export async function getRemoteRecipe(slug: string): Promise<Recipe | undefined> {
|
|
223
|
+
if (!REMOTE_BASE_URL) return undefined;
|
|
224
|
+
try {
|
|
225
|
+
const url = `${REMOTE_BASE_URL}?slug=${encodeURIComponent(slug)}`;
|
|
226
|
+
const res = await fetch(url, {
|
|
227
|
+
headers: { Accept: "application/json" },
|
|
228
|
+
signal: AbortSignal.timeout(5000),
|
|
229
|
+
});
|
|
230
|
+
if (!res.ok) return undefined;
|
|
231
|
+
const json = (await res.json()) as { recipe?: unknown };
|
|
232
|
+
if (!json.recipe || typeof json.recipe !== "object") return undefined;
|
|
233
|
+
const r = json.recipe as Record<string, unknown>;
|
|
234
|
+
return coerceRecipe(
|
|
235
|
+
{
|
|
236
|
+
id: r.slug,
|
|
237
|
+
query: r.query,
|
|
238
|
+
createdAt: r.created_at,
|
|
239
|
+
providers: r.providers,
|
|
240
|
+
guidance: undefined,
|
|
241
|
+
},
|
|
242
|
+
String(r.slug ?? ""),
|
|
243
|
+
);
|
|
244
|
+
} catch {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
}
|
package/src/base-urls.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model provider base URLs, overridable from the environment.
|
|
3
|
+
*
|
|
4
|
+
* `ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL` and `OLLAMA_HOST` let a gateway
|
|
5
|
+
* (a LiteLLM proxy, a Phantom proxy, or Ollama on another machine) sit in
|
|
6
|
+
* front of the providers. Unset or blank values give exactly the URLs Stack
|
|
7
|
+
* always used. Values are read when called, not at import.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
type Env = Record<string, string | undefined>;
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
|
13
|
+
export const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
|
|
14
|
+
export const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434";
|
|
15
|
+
|
|
16
|
+
/** Trim whitespace and trailing slashes; `undefined` when nothing is left. */
|
|
17
|
+
function clean(raw: string | undefined): string | undefined {
|
|
18
|
+
const v = raw?.trim().replace(/\/+$/, "").trim();
|
|
19
|
+
return v ? v : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A keyed gateway URL must be https, or plain http only to the loopback
|
|
24
|
+
* literals 127.0.0.1 and [::1], so an API key is never sent in clear text
|
|
25
|
+
* across a network. Throws with the variable name only, never the URL (it
|
|
26
|
+
* can carry credentials).
|
|
27
|
+
*/
|
|
28
|
+
export function assertGatewayUrl(name: string, value: string): string {
|
|
29
|
+
let url: URL;
|
|
30
|
+
try {
|
|
31
|
+
url = new URL(value);
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error(`${name} is not a valid URL`);
|
|
34
|
+
}
|
|
35
|
+
if (url.protocol === "https:") return value;
|
|
36
|
+
if (url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "[::1]")) {
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
throw new Error(`${name} must use https, or http only to 127.0.0.1 or [::1]`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Anthropic API root; Stack appends `/v1/...`. */
|
|
43
|
+
export function anthropicBaseUrl(env: Env = process.env): string {
|
|
44
|
+
const v = clean(env.ANTHROPIC_BASE_URL);
|
|
45
|
+
return v ? assertGatewayUrl("ANTHROPIC_BASE_URL", v) : DEFAULT_ANTHROPIC_BASE_URL;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** OpenAI API root including `/v1`, the same convention as the OpenAI SDKs. */
|
|
49
|
+
export function openaiBaseUrl(env: Env = process.env): string {
|
|
50
|
+
const v = clean(env.OPENAI_BASE_URL);
|
|
51
|
+
return v ? assertGatewayUrl("OPENAI_BASE_URL", v) : DEFAULT_OPENAI_BASE_URL;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Ollama server root. `OLLAMA_HOST` may be `host`, `host:port`, `:port`
|
|
56
|
+
* (localhost) or a full `http://` / `https://` URL, as Ollama itself
|
|
57
|
+
* accepts. A missing scheme becomes `http://`, a missing port 11434.
|
|
58
|
+
*/
|
|
59
|
+
export function ollamaBaseUrl(env: Env = process.env): string {
|
|
60
|
+
let v = clean(env.OLLAMA_HOST);
|
|
61
|
+
if (!v) return DEFAULT_OLLAMA_BASE_URL;
|
|
62
|
+
if (/^https?:\/\//i.test(v)) return v;
|
|
63
|
+
if (v.startsWith(":")) v = `localhost${v}`;
|
|
64
|
+
const slash = v.indexOf("/");
|
|
65
|
+
const authority = slash === -1 ? v : v.slice(0, slash);
|
|
66
|
+
const path = slash === -1 ? "" : v.slice(slash);
|
|
67
|
+
const close = authority.lastIndexOf("]");
|
|
68
|
+
const hasPort =
|
|
69
|
+
close === -1 ? authority.includes(":") : authority.slice(close + 1).startsWith(":");
|
|
70
|
+
return `http://${authority}${hasPort ? "" : ":11434"}${path}`;
|
|
71
|
+
}
|