@ashlr/stack-core 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +26 -15
  3. package/package.json +1 -1
  4. package/src/__tests__/_harness.ts +67 -7
  5. package/src/__tests__/agent-onboard.test.ts +279 -0
  6. package/src/__tests__/api-key-providers.test.ts +6 -2
  7. package/src/__tests__/base-urls.test.ts +208 -0
  8. package/src/__tests__/catalog-index.test.ts +14 -20
  9. package/src/__tests__/catalog.test.ts +31 -0
  10. package/src/__tests__/cloudflare-provider.test.ts +32 -10
  11. package/src/__tests__/completion-ci.test.ts +239 -23
  12. package/src/__tests__/config-own-services.test.ts +183 -0
  13. package/src/__tests__/config-split.test.ts +272 -2
  14. package/src/__tests__/config.test.ts +94 -4
  15. package/src/__tests__/credential-staging.test.ts +660 -0
  16. package/src/__tests__/deps.test.ts +2 -1
  17. package/src/__tests__/detect-source.test.ts +183 -4
  18. package/src/__tests__/fixtures/stack-manifest.v1.json +10 -0
  19. package/src/__tests__/github-provider.test.ts +3 -2
  20. package/src/__tests__/helpers.test.ts +24 -4
  21. package/src/__tests__/http.test.ts +2 -2
  22. package/src/__tests__/import-integration.test.ts +18 -4
  23. package/src/__tests__/inference.test.ts +57 -13
  24. package/src/__tests__/interactive-prompt.test.ts +106 -0
  25. package/src/__tests__/mcp-mirror.test.ts +116 -0
  26. package/src/__tests__/neon-provider.test.ts +136 -3
  27. package/src/__tests__/new-providers.test.ts +470 -0
  28. package/src/__tests__/no-orphan-modules.test.ts +114 -0
  29. package/src/__tests__/oauth-port.test.ts +84 -0
  30. package/src/__tests__/openai-provider.test.ts +1 -1
  31. package/src/__tests__/phantom-contract.test.ts +177 -0
  32. package/src/__tests__/phantom-harness.test.ts +226 -8
  33. package/src/__tests__/phantom-process.test.ts +135 -0
  34. package/src/__tests__/phantom-project-preflight.test.ts +118 -0
  35. package/src/__tests__/phantom-wire.test.ts +403 -22
  36. package/src/__tests__/pipeline-integration.test.ts +33 -1
  37. package/src/__tests__/pipeline-recovery.test.ts +29 -28
  38. package/src/__tests__/pipeline-rollback.test.ts +509 -0
  39. package/src/__tests__/pipeline.test.ts +3 -1
  40. package/src/__tests__/pm-detect.test.ts +113 -0
  41. package/src/__tests__/provider-deprovision.test.ts +269 -0
  42. package/src/__tests__/provider-parity.test.ts +46 -0
  43. package/src/__tests__/publish-script.test.ts +704 -0
  44. package/src/__tests__/recipe.test.ts +27 -2
  45. package/src/__tests__/recommend-apply-e2e.test.ts +29 -27
  46. package/src/__tests__/registry.test.ts +11 -2
  47. package/src/__tests__/release-artifacts.test.ts +55 -0
  48. package/src/__tests__/scan-missing-dir.test.ts +45 -0
  49. package/src/__tests__/secret-redaction.test.ts +10 -10
  50. package/src/__tests__/security.test.ts +9 -10
  51. package/src/__tests__/stack-manifest.test.ts +38 -0
  52. package/src/__tests__/supabase-provider.test.ts +6 -4
  53. package/src/__tests__/swap.test.ts +114 -0
  54. package/src/__tests__/telemetry.test.ts +354 -0
  55. package/src/__tests__/template-litellm-gateway.test.ts +70 -0
  56. package/src/__tests__/template-temporal-worker-phantom.test.ts +45 -0
  57. package/src/__tests__/templates.test.ts +1 -1
  58. package/src/__tests__/turso-provider.test.ts +2 -5
  59. package/src/agent-onboard.ts +346 -0
  60. package/src/ai/catalog-index.ts +68 -10
  61. package/src/ai/circuit-breaker.ts +3 -6
  62. package/src/ai/cost-tracker.ts +6 -15
  63. package/src/ai/inference.ts +19 -11
  64. package/src/ai/phantom-wire.ts +73 -58
  65. package/src/ai/recipe.ts +69 -0
  66. package/src/base-urls.ts +71 -0
  67. package/src/catalog.ts +281 -2
  68. package/src/config.ts +31 -18
  69. package/src/detect-source.ts +50 -9
  70. package/src/detect.ts +13 -5
  71. package/src/errors.ts +6 -0
  72. package/src/http.ts +6 -5
  73. package/src/index.ts +16 -0
  74. package/src/mcp-writer.ts +97 -2
  75. package/src/oauth.ts +85 -53
  76. package/src/phantom-process.ts +72 -0
  77. package/src/phantom.ts +319 -30
  78. package/src/pipeline.ts +177 -68
  79. package/src/pm-detect.ts +56 -0
  80. package/src/providers/_api-key.ts +18 -17
  81. package/src/providers/_base.ts +28 -1
  82. package/src/providers/_credential-stage.ts +237 -0
  83. package/src/providers/_helpers.ts +55 -9
  84. package/src/providers/anthropic.ts +2 -1
  85. package/src/providers/auth0.ts +27 -0
  86. package/src/providers/aws.ts +32 -33
  87. package/src/providers/cloudflare.ts +18 -37
  88. package/src/providers/convex.ts +1 -2
  89. package/src/providers/datadog.ts +29 -0
  90. package/src/providers/digitalocean.ts +30 -0
  91. package/src/providers/gcp.ts +36 -0
  92. package/src/providers/github.ts +37 -35
  93. package/src/providers/grafana.ts +29 -0
  94. package/src/providers/hetzner.ts +29 -0
  95. package/src/providers/index.ts +13 -1
  96. package/src/providers/launchdarkly.ts +33 -0
  97. package/src/providers/mixpanel.ts +37 -0
  98. package/src/providers/neon.ts +50 -26
  99. package/src/providers/openai.ts +2 -1
  100. package/src/providers/plausible.ts +26 -0
  101. package/src/providers/sentry.ts +17 -17
  102. package/src/providers/stripe.ts +1 -2
  103. package/src/providers/supabase.ts +66 -37
  104. package/src/providers/turso.ts +49 -38
  105. package/src/providers/vercel.ts +50 -20
  106. package/src/providers/workos.ts +27 -0
  107. package/src/registry.ts +7 -11
  108. package/src/stack-manifest.ts +39 -0
  109. package/src/swap.ts +108 -0
  110. package/src/telemetry.ts +267 -0
  111. package/src/templates.ts +6 -2
@@ -1,46 +1,47 @@
1
1
  /**
2
- * Recipe → Phantom envelope + webhook stub materializer.
2
+ * Recipe → Phantom vault check + webhook stub materializer.
3
3
  *
4
4
  * For each provider in a Recipe:
5
- * 1. Pre-create Phantom envelopes (one per declared secret) so `stack add`
6
- * slots its real values into an already-rotating slot.
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
- * so the user has somewhere concrete to land the signature verification.
12
+ * as an example only. Signature verification is not implemented: the
13
+ * stub's TODO states the trust limitation — a present signature header
14
+ * authenticates nothing and the body is untrusted input — and points at
15
+ * the provider-specific verification to add before deploying a handler.
10
16
  *
11
- * This is the Stack moat: `stack apply` ends with envelopes + stubs waiting
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.
17
+ * An opt-out (`--no-wire`) skips both steps.
18
+ *
19
+ * Recipe manifests are unsigned: signature verification is not implemented.
20
+ * Checking provider names against the catalog does not authenticate a recipe's
21
+ * origin or integrity; callers must use recipes from a trusted source.
15
22
  */
16
23
 
17
24
  import { mkdir, writeFile } from "node:fs/promises";
18
25
  import { dirname, resolve } from "node:path";
19
26
  import { findProviderRef } from "../catalog.ts";
20
- import {
21
- assertPhantomInstalled,
22
- exec as phantomExec,
23
- PhantomExecResult,
24
- } from "../phantom.ts";
25
27
  import { PhantomNotInstalledError } from "../errors.ts";
28
+ import { assertPhantomInstalled, listSecrets } from "../phantom.ts";
26
29
  import type { Recipe } from "./recipe.ts";
27
30
 
28
31
  export interface WireResult {
29
- /** `PROVIDER:SECRET_NAME` entries successfully added to Phantom. */
30
- envelopes: string[];
32
+ /** `PROVIDER:SECRET_NAME` entries already stored in the Phantom vault. */
33
+ present: string[];
34
+ /** `PROVIDER:SECRET_NAME` entries the recipe declares but the vault lacks. */
35
+ missing: string[];
31
36
  /** Absolute paths of webhook stub files created. */
32
37
  webhooks: string[];
33
- /** Names of providers skipped (phantom missing, unknown provider, etc). */
38
+ /** Names of providers skipped (phantom missing, vault unreadable, unknown provider). */
34
39
  skipped: string[];
35
40
  }
36
41
 
37
- const PLACEHOLDER = "TODO_STACK_APPLY";
38
-
39
42
  /**
40
- * Providers with webhooks worth pre-wiring. Keep this list tight — each entry
41
- * gets a hand-maintained stub in `webhookStubFor`. Adding a new provider here
42
- * without also adding its stub produces an empty file, which is worse than
43
- * no file.
43
+ * Providers with webhooks worth pre-wiring. Keep this list tight and update
44
+ * the provider-specific secret and signature header mappings for each entry.
44
45
  */
45
46
  const WEBHOOK_PROVIDERS = new Set(["stripe", "clerk", "supabase", "github"]);
46
47
 
@@ -48,7 +49,7 @@ export async function wirePhantomForRecipe(
48
49
  recipe: Recipe,
49
50
  opts: { cwd?: string; noWire?: boolean } = {},
50
51
  ): Promise<WireResult> {
51
- const result: WireResult = { envelopes: [], webhooks: [], skipped: [] };
52
+ const result: WireResult = { present: [], missing: [], webhooks: [], skipped: [] };
52
53
  if (opts.noWire) return result;
53
54
 
54
55
  const cwd = opts.cwd ?? process.cwd();
@@ -57,7 +58,7 @@ export async function wirePhantomForRecipe(
57
58
  await assertPhantomInstalled();
58
59
  } catch (err) {
59
60
  if (err instanceof PhantomNotInstalledError) {
60
- // Phantom missing → skip envelope creation but still drop webhook stubs
61
+ // Phantom missing → skip the vault check but still drop webhook stubs
61
62
  // (they're just files). Surface the provider names so the CLI can tell
62
63
  // the user why nothing landed in the vault.
63
64
  for (const p of recipe.providers) result.skipped.push(p.name);
@@ -72,16 +73,16 @@ export async function wirePhantomForRecipe(
72
73
  throw err;
73
74
  }
74
75
 
76
+ const stored = await listVaultNames(cwd);
77
+
75
78
  for (const { name } of recipe.providers) {
76
79
  const ref = findProviderRef(name);
77
- if (!ref) {
80
+ if (!ref || !stored) {
78
81
  result.skipped.push(name);
79
- continue;
80
- }
81
-
82
- for (const secret of ref.secrets) {
83
- const added = await addEnvelope(secret, cwd);
84
- if (added) result.envelopes.push(`${name}:${secret}`);
82
+ } else {
83
+ for (const secret of ref.secrets) {
84
+ (stored.has(secret) ? result.present : result.missing).push(`${name}:${secret}`);
85
+ }
85
86
  }
86
87
 
87
88
  if (WEBHOOK_PROVIDERS.has(name)) {
@@ -94,34 +95,15 @@ export async function wirePhantomForRecipe(
94
95
  }
95
96
 
96
97
  /**
97
- * Shell `phantom add <KEY> <placeholder>` with opportunistic `--rotate`.
98
- * Try with `--rotate` first; if the running Phantom doesn't support the flag,
99
- * fall back to a flagless add. Returns true when the envelope was written.
98
+ * Secret names in the project's Phantom vault, via `phantom list --json`
99
+ * (names and metadata only, never values). Returns undefined when the vault
100
+ * can't be read (not initialized, older Phantom without `--json`, etc).
100
101
  */
101
- async function addEnvelope(key: string, cwd: string): Promise<boolean> {
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> {
102
+ async function listVaultNames(cwd: string): Promise<Set<string> | undefined> {
121
103
  try {
122
- return await phantomExec(args, { cwd, allowFailure: true });
104
+ return new Set(await listSecrets(cwd));
123
105
  } catch {
124
- return { stdout: "", stderr: "", code: 1 };
106
+ return undefined;
125
107
  }
126
108
  }
127
109
 
@@ -135,20 +117,53 @@ async function writeWebhookStub(cwd: string, provider: string): Promise<string>
135
117
  function webhookStubFor(provider: string): string {
136
118
  const base = `// Auto-generated by \`stack apply\` — webhook handler stub for ${provider}.
137
119
  // Fill in the secret retrieval + signature verification before deploying.
120
+ // This entire example is commented out and does not verify signatures.
121
+ // If uncommented as-is, it accepts any non-empty signature header and returns
122
+ // 200 without authenticating the request. Implement verification first.
138
123
  //
139
- // Secret envelope lives in Phantom; read it at call time with:
124
+ // The secret lives in Phantom; read it at call time with:
140
125
  // phantom reveal ${webhookSecretKeyFor(provider)}
141
126
  //
142
127
  // export async function handle${capitalize(provider)}Webhook(req: Request): Promise<Response> {
143
128
  // const signature = req.headers.get("${signatureHeaderFor(provider)}");
129
+ // if (!signature) return new Response("Missing signature", { status: 400 });
144
130
  // const body = await req.text();
145
- // // TODO: verify(signature, body) before trusting.
131
+ // // TODO(security): signature verification is NOT implemented here. The
132
+ // // header check above proves only that a signature header arrived; its
133
+ // // value is never compared to anything. Until this TODO is done, \`body\`
134
+ // // is unauthenticated, attacker-controlled input — anyone who can POST
135
+ // // to this endpoint can forge a ${provider} webhook.
136
+ // // ${verificationHintFor(provider)}
137
+ // // Verify over the exact raw body bytes with the webhook secret
138
+ // // (\`phantom reveal ${webhookSecretKeyFor(provider)}\`) and compare in
139
+ // // constant time; reject missing or invalid signatures, and only then
140
+ // // parse, trust, or act on the body.
146
141
  // return new Response("ok", { status: 200 });
147
142
  // }
148
143
  `;
149
144
  return base;
150
145
  }
151
146
 
147
+ /**
148
+ * One-line pointer to the provider's documented signature scheme, inlined into
149
+ * the stub's TODO so the implementer starts at the right verification API.
150
+ * Falls back to a generic instruction for providers added later.
151
+ */
152
+ function verificationHintFor(provider: string): string {
153
+ switch (provider) {
154
+ case "stripe":
155
+ return "Stripe: `stripe.webhooks.constructEvent` — HMAC-SHA256 with timestamp tolerance.";
156
+ case "clerk":
157
+ return "Clerk (svix): `new Webhook(secret).verify(body, headers)`.";
158
+ case "supabase":
159
+ return "Supabase: `x-supabase-signature` per Supabase's documented webhook scheme.";
160
+ case "github":
161
+ return "GitHub: `x-hub-signature-256` is `sha256=` + HMAC-SHA256 of the raw body.";
162
+ default:
163
+ return "Verify per the provider's documented signature scheme.";
164
+ }
165
+ }
166
+
152
167
  function webhookSecretKeyFor(provider: string): string {
153
168
  switch (provider) {
154
169
  case "stripe":
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
+ }
@@ -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
+ }