@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.
Files changed (101) 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 +217 -0
  6. package/src/__tests__/api-key-providers.test.ts +6 -2
  7. package/src/__tests__/base-urls.test.ts +162 -0
  8. package/src/__tests__/catalog-index.test.ts +14 -20
  9. package/src/__tests__/cloudflare-provider.test.ts +9 -10
  10. package/src/__tests__/completion-ci.test.ts +239 -23
  11. package/src/__tests__/config-own-services.test.ts +183 -0
  12. package/src/__tests__/config-split.test.ts +206 -2
  13. package/src/__tests__/config.test.ts +2 -1
  14. package/src/__tests__/credential-staging.test.ts +659 -0
  15. package/src/__tests__/deps.test.ts +2 -1
  16. package/src/__tests__/detect-source.test.ts +1 -1
  17. package/src/__tests__/github-provider.test.ts +3 -2
  18. package/src/__tests__/helpers.test.ts +24 -4
  19. package/src/__tests__/http.test.ts +2 -2
  20. package/src/__tests__/inference.test.ts +19 -13
  21. package/src/__tests__/interactive-prompt.test.ts +106 -0
  22. package/src/__tests__/mcp-mirror.test.ts +116 -0
  23. package/src/__tests__/neon-provider.test.ts +136 -3
  24. package/src/__tests__/new-providers.test.ts +470 -0
  25. package/src/__tests__/oauth-port.test.ts +84 -0
  26. package/src/__tests__/openai-provider.test.ts +1 -1
  27. package/src/__tests__/phantom-contract.test.ts +177 -0
  28. package/src/__tests__/phantom-harness.test.ts +125 -8
  29. package/src/__tests__/phantom-project-preflight.test.ts +118 -0
  30. package/src/__tests__/phantom-wire.test.ts +31 -20
  31. package/src/__tests__/pipeline-integration.test.ts +1 -1
  32. package/src/__tests__/pipeline-recovery.test.ts +29 -28
  33. package/src/__tests__/pipeline-rollback.test.ts +509 -0
  34. package/src/__tests__/pipeline.test.ts +3 -1
  35. package/src/__tests__/pm-detect.test.ts +113 -0
  36. package/src/__tests__/provider-deprovision.test.ts +269 -0
  37. package/src/__tests__/provider-parity.test.ts +46 -0
  38. package/src/__tests__/publish-script.test.ts +659 -0
  39. package/src/__tests__/recipe.test.ts +4 -2
  40. package/src/__tests__/recommend-apply-e2e.test.ts +14 -27
  41. package/src/__tests__/registry.test.ts +11 -2
  42. package/src/__tests__/release-artifacts.test.ts +55 -0
  43. package/src/__tests__/secret-redaction.test.ts +10 -10
  44. package/src/__tests__/security.test.ts +9 -10
  45. package/src/__tests__/supabase-provider.test.ts +6 -4
  46. package/src/__tests__/swap.test.ts +114 -0
  47. package/src/__tests__/telemetry.test.ts +354 -0
  48. package/src/__tests__/template-litellm-gateway.test.ts +41 -0
  49. package/src/__tests__/template-temporal-worker-phantom.test.ts +31 -0
  50. package/src/__tests__/templates.test.ts +1 -1
  51. package/src/__tests__/turso-provider.test.ts +2 -5
  52. package/src/agent-onboard.ts +346 -0
  53. package/src/ai/catalog-index.ts +68 -10
  54. package/src/ai/circuit-breaker.ts +3 -6
  55. package/src/ai/cost-tracker.ts +6 -15
  56. package/src/ai/inference.ts +19 -11
  57. package/src/ai/phantom-wire.ts +29 -52
  58. package/src/ai/recipe.ts +69 -0
  59. package/src/base-urls.ts +71 -0
  60. package/src/catalog.ts +281 -2
  61. package/src/config.ts +31 -18
  62. package/src/detect-source.ts +36 -8
  63. package/src/detect.ts +13 -5
  64. package/src/http.ts +6 -5
  65. package/src/index.ts +15 -0
  66. package/src/mcp-writer.ts +97 -2
  67. package/src/oauth.ts +85 -53
  68. package/src/phantom.ts +193 -15
  69. package/src/pipeline.ts +177 -68
  70. package/src/pm-detect.ts +56 -0
  71. package/src/providers/_api-key.ts +18 -17
  72. package/src/providers/_base.ts +28 -1
  73. package/src/providers/_credential-stage.ts +237 -0
  74. package/src/providers/_helpers.ts +55 -9
  75. package/src/providers/anthropic.ts +2 -1
  76. package/src/providers/auth0.ts +27 -0
  77. package/src/providers/aws.ts +32 -33
  78. package/src/providers/cloudflare.ts +18 -37
  79. package/src/providers/convex.ts +1 -2
  80. package/src/providers/datadog.ts +29 -0
  81. package/src/providers/digitalocean.ts +30 -0
  82. package/src/providers/gcp.ts +36 -0
  83. package/src/providers/github.ts +37 -35
  84. package/src/providers/grafana.ts +29 -0
  85. package/src/providers/hetzner.ts +29 -0
  86. package/src/providers/index.ts +13 -1
  87. package/src/providers/launchdarkly.ts +33 -0
  88. package/src/providers/mixpanel.ts +37 -0
  89. package/src/providers/neon.ts +50 -26
  90. package/src/providers/openai.ts +2 -1
  91. package/src/providers/plausible.ts +26 -0
  92. package/src/providers/sentry.ts +17 -17
  93. package/src/providers/stripe.ts +1 -2
  94. package/src/providers/supabase.ts +66 -37
  95. package/src/providers/turso.ts +49 -38
  96. package/src/providers/vercel.ts +50 -20
  97. package/src/providers/workos.ts +27 -0
  98. package/src/registry.ts +7 -11
  99. package/src/swap.ts +108 -0
  100. package/src/telemetry.ts +267 -0
  101. package/src/templates.ts +6 -2
@@ -1,41 +1,37 @@
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
12
  * so the user has somewhere concrete to land the signature verification.
10
13
  *
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.
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 successfully added to Phantom. */
30
- envelopes: string[];
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, etc). */
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 = { envelopes: [], webhooks: [], skipped: [] };
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 envelope creation but still drop webhook stubs
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
- 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}`);
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
- * 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.
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 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> {
97
+ async function listVaultNames(cwd: string): Promise<Set<string> | undefined> {
121
98
  try {
122
- return await phantomExec(args, { cwd, allowFailure: true });
99
+ return new Set(await listSecrets(cwd));
123
100
  } catch {
124
- return { stdout: "", stderr: "", code: 1 };
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
- // Secret envelope lives in Phantom; read it at call time with:
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
+ }
@@ -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
+ }