@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
@@ -0,0 +1,26 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+ import { verifyFetch } from "./_helpers.ts";
3
+
4
+ export default makeApiKeyProvider({
5
+ name: "plausible",
6
+ displayName: "Plausible",
7
+ category: "analytics",
8
+ docs: "https://plausible.io/docs/stats-api",
9
+ secretName: "PLAUSIBLE_API_KEY",
10
+ howTo:
11
+ "Generate an API key at https://plausible.io/settings and note your site ID (the domain you track).",
12
+ dashboard: "https://plausible.io",
13
+ async verify(key) {
14
+ try {
15
+ // /api/v1/sites lists all sites — read-only, confirms key validity.
16
+ const res = await verifyFetch("https://plausible.io/api/v1/sites", {
17
+ headers: { Authorization: `Bearer ${key}` },
18
+ });
19
+ if (!res.ok) return undefined;
20
+ const body = (await res.json()) as { sites?: Array<{ domain: string }> };
21
+ return { sites: String(body.sites?.length ?? 0) };
22
+ } catch {
23
+ return undefined;
24
+ }
25
+ },
26
+ });
@@ -1,8 +1,6 @@
1
1
  import type { ServiceEntry } from "../config.ts";
2
2
  import { StackError } from "../errors.ts";
3
- import { addSecret } from "../phantom.ts";
4
3
  import { fetchWithRetry } from "../http.ts";
5
- import { readLine, tryRevealSecret } from "./_helpers.ts";
6
4
  import type {
7
5
  AuthHandle,
8
6
  HealthStatus,
@@ -12,6 +10,8 @@ import type {
12
10
  ProvisionOpts,
13
11
  Resource,
14
12
  } from "./_base.ts";
13
+ import { acceptVerifiedExisting, storeProviderSecret } from "./_credential-stage.ts";
14
+ import { promptSecret, tryRevealSecret } from "./_helpers.ts";
15
15
 
16
16
  /**
17
17
  * Sentry — error tracking. v1 uses an Auth Token (users create one at
@@ -33,20 +33,24 @@ const sentry: Provider = {
33
33
  docs: "https://docs.sentry.io/api/",
34
34
 
35
35
  async login(ctx: ProviderContext): Promise<AuthHandle> {
36
- const cached = await tryRevealSecret(TOKEN_SECRET);
36
+ const cached = await tryRevealSecret(TOKEN_SECRET, ctx);
37
37
  if (cached) {
38
38
  const identity = await fetchIdentity(cached);
39
- if (identity) return { token: cached, identity };
39
+ if (identity) {
40
+ acceptVerifiedExisting(ctx, TOKEN_SECRET, cached);
41
+ return { token: cached, identity };
42
+ }
40
43
  ctx.log({ level: "warn", msg: "Cached Sentry token invalid." });
41
44
  }
42
45
  if (!ctx.interactive) throw new StackError("SENTRY_AUTH_REQUIRED", "No valid Sentry token.");
43
- process.stderr.write(
44
- "\n Create a Sentry Auth Token at https://sentry.io/settings/account/api/auth-tokens/\n Required scopes: project:write, org:read\n Paste it here: ",
45
- );
46
- const token = (await readLine()).trim();
46
+ const token = await promptSecret(ctx, {
47
+ message: "Paste your Sentry Auth Token",
48
+ howTo:
49
+ "Create a Sentry Auth Token at https://sentry.io/settings/account/api/auth-tokens/ (scopes: project:write, org:read)",
50
+ });
47
51
  const identity = await fetchIdentity(token);
48
52
  if (!identity) throw new StackError("SENTRY_AUTH_INVALID", "Sentry rejected that token.");
49
- await addSecret(TOKEN_SECRET, token);
53
+ await storeProviderSecret(ctx, TOKEN_SECRET, token);
50
54
  return { token, identity };
51
55
  },
52
56
 
@@ -79,7 +83,7 @@ const sentry: Provider = {
79
83
  return { id: `${orgSlug}/${project.slug}`, displayName: project.name };
80
84
  },
81
85
 
82
- async materialize(_ctx, resource, auth): Promise<Materialized> {
86
+ async materialize(ctx, resource, auth): Promise<Materialized> {
83
87
  const [orgSlug, projectSlug] = resource.id.split("/");
84
88
  const dsn = await fetchDsn(auth.token, orgSlug, projectSlug);
85
89
  const secrets: Record<string, string> = { SENTRY_AUTH_TOKEN: auth.token };
@@ -102,8 +106,8 @@ const sentry: Provider = {
102
106
  };
103
107
  },
104
108
 
105
- async healthcheck(_ctx, entry: ServiceEntry): Promise<HealthStatus> {
106
- const token = await tryRevealSecret(TOKEN_SECRET);
109
+ async healthcheck(ctx, entry: ServiceEntry): Promise<HealthStatus> {
110
+ const token = await tryRevealSecret(TOKEN_SECRET, ctx);
107
111
  if (!token) return { kind: "error", detail: `${TOKEN_SECRET} missing from vault` };
108
112
  if (!entry.resource_id) return { kind: "warn", detail: "no resource_id" };
109
113
  const [orgSlug, projectSlug] = entry.resource_id.split("/");
@@ -114,9 +118,7 @@ const sentry: Provider = {
114
118
  headers: { Authorization: `Bearer ${token}` },
115
119
  });
116
120
  const latencyMs = Date.now() - start;
117
- return res.ok
118
- ? { kind: "ok", latencyMs }
119
- : { kind: "error", detail: `HTTP ${res.status}` };
121
+ return res.ok ? { kind: "ok", latencyMs } : { kind: "error", detail: `HTTP ${res.status}` };
120
122
  },
121
123
 
122
124
  dashboardUrl(entry: ServiceEntry): string {
@@ -179,5 +181,3 @@ async function fetchDsn(
179
181
  const keys = (await res.json()) as Array<{ dsn?: { public?: string } }>;
180
182
  return keys[0]?.dsn?.public;
181
183
  }
182
-
183
-
@@ -23,8 +23,7 @@ export default makeApiKeyProvider({
23
23
  if (!res.ok) return undefined;
24
24
  const body = (await res.json()) as Record<string, unknown>;
25
25
  const out: Record<string, string> = {};
26
- for (const [k, v] of Object.entries(body))
27
- if (typeof v === "string") out[k] = v;
26
+ for (const [k, v] of Object.entries(body)) if (typeof v === "string") out[k] = v;
28
27
  return out;
29
28
  } catch {
30
29
  return undefined;
@@ -1,19 +1,24 @@
1
- import {
2
- type AuthHandle,
3
- type HealthStatus,
4
- type Materialized,
5
- type Provider,
6
- type ProviderContext,
7
- type ProvisionOpts,
8
- type Resource,
9
- } from "./_base.ts";
10
1
  import type { ServiceEntry } from "../config.ts";
2
+ import { resolveOAuthClientId } from "../env.ts";
11
3
  import { StackError } from "../errors.ts";
12
- import { addSecret, exec as phantomExec } from "../phantom.ts";
13
- import { readLine, tryRevealSecret } from "./_helpers.ts";
14
4
  import { fetchWithRetry } from "../http.ts";
15
5
  import { runPkceFlow } from "../oauth.ts";
16
- import { resolveOAuthClientId } from "../env.ts";
6
+ import { exec as phantomExec } from "../phantom.ts";
7
+ import type {
8
+ AuthHandle,
9
+ HealthStatus,
10
+ Materialized,
11
+ Provider,
12
+ ProviderContext,
13
+ ProvisionOpts,
14
+ Resource,
15
+ } from "./_base.ts";
16
+ import {
17
+ acceptVerifiedExisting,
18
+ carryResourceCredential,
19
+ storeProviderSecret,
20
+ } from "./_credential-stage.ts";
21
+ import { promptSecret, tryRevealSecret } from "./_helpers.ts";
17
22
 
18
23
  /**
19
24
  * Supabase — the Wave 2 pilot provider. Proves the full loop:
@@ -37,11 +42,15 @@ const supabase: Provider = {
37
42
  const clientId = resolveOAuthClientId("supabase", process.env.SUPABASE_STACK_CLIENT_ID);
38
43
 
39
44
  // Path A — cached PAT from a previous login
40
- const cached = await tryRevealSecret(STACK_PAT_SECRET);
45
+ const cached = await tryRevealSecret(STACK_PAT_SECRET, ctx);
41
46
  if (cached) {
42
47
  const identity = await fetchIdentity(cached);
43
48
  if (identity) {
44
- ctx.log({ level: "info", msg: `Reusing cached Supabase token for ${identity.email ?? identity.id}` });
49
+ acceptVerifiedExisting(ctx, STACK_PAT_SECRET, cached);
50
+ ctx.log({
51
+ level: "info",
52
+ msg: `Reusing cached Supabase token for ${identity.email ?? identity.id}`,
53
+ });
45
54
  return { token: cached, identity };
46
55
  }
47
56
  ctx.log({ level: "warn", msg: "Cached Supabase token invalid; re-authenticating." });
@@ -56,8 +65,10 @@ const supabase: Provider = {
56
65
  tokenUrl: `${API}/v1/oauth/token`,
57
66
  });
58
67
  const token = tokenResponse.access_token;
59
- await addSecret(STACK_PAT_SECRET, token);
60
68
  const identity = await fetchIdentity(token);
69
+ if (!identity)
70
+ throw new StackError("SUPABASE_AUTH_INVALID", "Supabase rejected that access token.");
71
+ await storeProviderSecret(ctx, STACK_PAT_SECRET, token);
61
72
  return {
62
73
  token,
63
74
  identity,
@@ -75,24 +86,21 @@ const supabase: Provider = {
75
86
  );
76
87
  }
77
88
 
78
- // Avoid a hard dependency on @clack/prompts in the core package — read from stdin.
79
- process.stderr.write(
80
- "\n Create a Personal Access Token at https://supabase.com/dashboard/account/tokens\n Paste it here: ",
81
- );
82
- const token = (await readLine()).trim();
89
+ // The host (CLI) owns the prompt UI so it can pause its spinner; core stays
90
+ // free of a hard @clack dependency.
91
+ const token = await promptSecret(ctx, {
92
+ message: "Paste your Supabase access token",
93
+ howTo: "Create a Personal Access Token at https://supabase.com/dashboard/account/tokens",
94
+ });
83
95
  if (!token) throw new StackError("SUPABASE_AUTH_REQUIRED", "No token provided.");
84
96
  const identity = await fetchIdentity(token);
85
97
  if (!identity)
86
98
  throw new StackError("SUPABASE_AUTH_INVALID", "Supabase rejected that access token.");
87
- await addSecret(STACK_PAT_SECRET, token);
99
+ await storeProviderSecret(ctx, STACK_PAT_SECRET, token);
88
100
  return { token, identity };
89
101
  },
90
102
 
91
- async provision(
92
- ctx: ProviderContext,
93
- auth: AuthHandle,
94
- opts: ProvisionOpts,
95
- ): Promise<Resource> {
103
+ async provision(ctx: ProviderContext, auth: AuthHandle, opts: ProvisionOpts): Promise<Resource> {
96
104
  if (opts.existingResourceId) {
97
105
  const project = await fetchProject(auth.token, opts.existingResourceId);
98
106
  if (!project)
@@ -139,13 +147,13 @@ const supabase: Provider = {
139
147
  );
140
148
  }
141
149
  const project = (await res.json()) as SupabaseProject;
142
- // Stash the DB password as a secret too — it's otherwise unrecoverable.
143
- await addSecret(`SUPABASE_DB_PASSWORD_${project.id}`, dbPass);
144
- return toResource(project);
150
+ const resource = toResource(project);
151
+ await carryResourceCredential(ctx, resource, `SUPABASE_DB_PASSWORD_${project.id}`, dbPass);
152
+ return resource;
145
153
  },
146
154
 
147
155
  async materialize(
148
- _ctx: ProviderContext,
156
+ ctx: ProviderContext,
149
157
  resource: Resource,
150
158
  auth: AuthHandle,
151
159
  ): Promise<Materialized> {
@@ -178,17 +186,16 @@ const supabase: Provider = {
178
186
  };
179
187
  },
180
188
 
181
- async healthcheck(_ctx: ProviderContext, entry: ServiceEntry): Promise<HealthStatus> {
189
+ async healthcheck(ctx: ProviderContext, entry: ServiceEntry): Promise<HealthStatus> {
182
190
  if (!entry.resource_id) return { kind: "error", detail: "missing resource_id" };
183
- const anon = await tryRevealSecret("SUPABASE_ANON_KEY");
191
+ const anon = await tryRevealSecret("SUPABASE_ANON_KEY", ctx);
184
192
  if (!anon) return { kind: "error", detail: "SUPABASE_ANON_KEY missing from vault" };
185
193
  const start = Date.now();
186
194
  try {
187
195
  // GET against the REST root. Safe to retry on transient 5xx / 429.
188
- const res = await fetchWithRetry(
189
- `https://${entry.resource_id}.supabase.co/rest/v1/`,
190
- { headers: { apikey: anon, Authorization: `Bearer ${anon}` } },
191
- );
196
+ const res = await fetchWithRetry(`https://${entry.resource_id}.supabase.co/rest/v1/`, {
197
+ headers: { apikey: anon, Authorization: `Bearer ${anon}` },
198
+ });
192
199
  const latencyMs = Date.now() - start;
193
200
  if (res.ok) return { kind: "ok", latencyMs };
194
201
  return { kind: "warn", detail: `HTTP ${res.status}` };
@@ -200,6 +207,28 @@ const supabase: Provider = {
200
207
  dashboardUrl(entry: ServiceEntry): string {
201
208
  return entry.resource_id ? dashboardUrl(entry.resource_id) : "https://supabase.com/dashboard";
202
209
  },
210
+
211
+ async deprovision(_ctx: ProviderContext, auth: AuthHandle, resourceId: string): Promise<void> {
212
+ let res: Response;
213
+ try {
214
+ res = await fetch(`${API}/v1/projects/${resourceId}`, {
215
+ method: "DELETE",
216
+ headers: authHeaders(auth.token),
217
+ });
218
+ } catch {
219
+ throw new StackError(
220
+ "SUPABASE_DELETE_UNCONFIRMED",
221
+ "Supabase resource deletion could not be confirmed.",
222
+ );
223
+ }
224
+ // Admit only documented completion statuses; other 2xx/404 are unconfirmed.
225
+ // https://supabase.com/docs/reference/api/v1-delete-a-project
226
+ if (res.status === 200) return;
227
+ throw new StackError(
228
+ "SUPABASE_DELETE_FAILED",
229
+ `Supabase resource deletion failed (HTTP ${res.status}).`,
230
+ );
231
+ },
203
232
  };
204
233
 
205
234
  export default supabase;
@@ -227,7 +256,7 @@ interface SupabaseApiKey {
227
256
 
228
257
  type SupabaseIdentity = Record<string, string>;
229
258
 
230
- function authHeaders(token: string): HeadersInit {
259
+ function authHeaders(token: string): NonNullable<RequestInit["headers"]> {
231
260
  return {
232
261
  Authorization: `Bearer ${token}`,
233
262
  "content-type": "application/json",
@@ -1,6 +1,5 @@
1
1
  import type { ServiceEntry } from "../config.ts";
2
2
  import { StackError } from "../errors.ts";
3
- import { addSecret, revealSecret } from "../phantom.ts";
4
3
  import { fetchWithRetry } from "../http.ts";
5
4
  import type {
6
5
  AuthHandle,
@@ -11,6 +10,8 @@ import type {
11
10
  ProvisionOpts,
12
11
  Resource,
13
12
  } from "./_base.ts";
13
+ import { acceptVerifiedExisting, storeProviderSecret } from "./_credential-stage.ts";
14
+ import { promptSecret, tryRevealSecret } from "./_helpers.ts";
14
15
 
15
16
  /**
16
17
  * Turso — LibSQL-backed edge database. v1 uses a user-created Platform API
@@ -31,26 +32,31 @@ const turso: Provider = {
31
32
  docs: "https://docs.turso.tech",
32
33
 
33
34
  async login(ctx: ProviderContext): Promise<AuthHandle> {
34
- const cached = await tryRevealSecret(PLATFORM_TOKEN_SECRET);
35
+ const cached = await tryRevealSecret(PLATFORM_TOKEN_SECRET, ctx);
35
36
  if (cached) {
36
37
  const identity = await fetchIdentity(cached);
37
- if (identity) return { token: cached, identity };
38
+ if (identity) {
39
+ acceptVerifiedExisting(ctx, PLATFORM_TOKEN_SECRET, cached);
40
+ return { token: cached, identity };
41
+ }
38
42
  ctx.log({ level: "warn", msg: "Cached Turso token invalid." });
39
43
  }
40
- if (!ctx.interactive) throw new StackError("TURSO_AUTH_REQUIRED", "No valid Turso token in vault.");
41
- process.stderr.write(
42
- "\n Create a Platform API token at https://app.turso.tech/settings/api-tokens\n Paste it here: ",
43
- );
44
- const token = (await readLine()).trim();
44
+ if (!ctx.interactive)
45
+ throw new StackError("TURSO_AUTH_REQUIRED", "No valid Turso token in vault.");
46
+ const token = await promptSecret(ctx, {
47
+ message: "Paste your Turso Platform API token",
48
+ howTo: "Create a Platform API token at https://app.turso.tech/settings/api-tokens",
49
+ });
45
50
  const identity = await fetchIdentity(token);
46
51
  if (!identity) throw new StackError("TURSO_AUTH_INVALID", "Turso rejected that token.");
47
- await addSecret(PLATFORM_TOKEN_SECRET, token);
52
+ await storeProviderSecret(ctx, PLATFORM_TOKEN_SECRET, token);
48
53
  return { token, identity };
49
54
  },
50
55
 
51
56
  async provision(ctx, auth, opts: ProvisionOpts): Promise<Resource> {
52
57
  const orgs = await fetchOrganizations(auth.token);
53
- if (orgs.length === 0) throw new StackError("TURSO_NO_ORGS", "Turso returned no organizations.");
58
+ if (orgs.length === 0)
59
+ throw new StackError("TURSO_NO_ORGS", "Turso returned no organizations.");
54
60
  const orgSlug = (opts.hints?.orgSlug as string | undefined) ?? orgs[0].slug;
55
61
  if (opts.existingResourceId) {
56
62
  const [existingOrg, dbName] = opts.existingResourceId.split("/");
@@ -88,7 +94,7 @@ const turso: Provider = {
88
94
  };
89
95
  },
90
96
 
91
- async materialize(_ctx, resource, auth): Promise<Materialized> {
97
+ async materialize(ctx, resource, auth): Promise<Materialized> {
92
98
  const [orgSlug, dbName] = resource.id.split("/");
93
99
  const dbToken = await mintDbToken(auth.token, orgSlug, dbName);
94
100
  const hostname =
@@ -104,8 +110,8 @@ const turso: Provider = {
104
110
  };
105
111
  },
106
112
 
107
- async healthcheck(_ctx, entry: ServiceEntry): Promise<HealthStatus> {
108
- const token = await tryRevealSecret(PLATFORM_TOKEN_SECRET);
113
+ async healthcheck(ctx, entry: ServiceEntry): Promise<HealthStatus> {
114
+ const token = await tryRevealSecret(PLATFORM_TOKEN_SECRET, ctx);
109
115
  if (!token) return { kind: "error", detail: `${PLATFORM_TOKEN_SECRET} missing` };
110
116
  if (!entry.resource_id) return { kind: "warn", detail: "no resource_id" };
111
117
  const [orgSlug, dbName] = entry.resource_id.split("/");
@@ -123,6 +129,36 @@ const turso: Provider = {
123
129
  const [orgSlug, dbName] = entry.resource_id.split("/");
124
130
  return `https://app.turso.tech/${orgSlug}/databases/${dbName}`;
125
131
  },
132
+
133
+ async deprovision(_ctx: ProviderContext, auth: AuthHandle, resourceId: string): Promise<void> {
134
+ // Retain the original org/db selector; extra segments remain a separate routing risk.
135
+ const [orgSlug, dbName] = resourceId.split("/");
136
+ if (!orgSlug || !dbName) {
137
+ throw new StackError(
138
+ "TURSO_DELETE_ID_INVALID",
139
+ "Turso resource deletion requires an organization and database.",
140
+ );
141
+ }
142
+ let res: Response;
143
+ try {
144
+ res = await fetch(`${API}/organizations/${orgSlug}/databases/${dbName}`, {
145
+ method: "DELETE",
146
+ headers: { Authorization: `Bearer ${auth.token}` },
147
+ });
148
+ } catch {
149
+ throw new StackError(
150
+ "TURSO_DELETE_UNCONFIRMED",
151
+ "Turso resource deletion could not be confirmed.",
152
+ );
153
+ }
154
+ // Admit documented HTTP200 completion or HTTP404 absence; other statuses remain unconfirmed.
155
+ // https://docs.turso.tech/api-reference/databases/delete
156
+ if (res.status === 200 || res.status === 404) return;
157
+ throw new StackError(
158
+ "TURSO_DELETE_FAILED",
159
+ `Turso resource deletion failed (HTTP ${res.status}).`,
160
+ );
161
+ },
126
162
  };
127
163
 
128
164
  export default turso;
@@ -170,28 +206,3 @@ async function mintDbToken(
170
206
  const body = (await res.json()) as { jwt: string };
171
207
  return body.jwt;
172
208
  }
173
-
174
- async function tryRevealSecret(key: string): Promise<string | undefined> {
175
- try {
176
- const v = await revealSecret(key);
177
- return v.length > 0 ? v : undefined;
178
- } catch {
179
- return undefined;
180
- }
181
- }
182
-
183
- async function readLine(): Promise<string> {
184
- return new Promise((resolve) => {
185
- let buf = "";
186
- const onData = (chunk: Buffer) => {
187
- buf += chunk.toString();
188
- if (buf.includes("\n")) {
189
- process.stdin.off("data", onData);
190
- process.stdin.pause();
191
- resolve(buf.split("\n")[0]);
192
- }
193
- };
194
- process.stdin.resume();
195
- process.stdin.on("data", onData);
196
- });
197
- }
@@ -1,8 +1,6 @@
1
1
  import type { ServiceEntry } from "../config.ts";
2
2
  import { StackError } from "../errors.ts";
3
- import { addSecret } from "../phantom.ts";
4
3
  import { fetchWithRetry } from "../http.ts";
5
- import { readLine, tryRevealSecret } from "./_helpers.ts";
6
4
  import type {
7
5
  AuthHandle,
8
6
  HealthStatus,
@@ -12,6 +10,8 @@ import type {
12
10
  ProvisionOpts,
13
11
  Resource,
14
12
  } from "./_base.ts";
13
+ import { acceptVerifiedExisting, storeProviderSecret } from "./_credential-stage.ts";
14
+ import { promptSecret, tryRevealSecret } from "./_helpers.ts";
15
15
 
16
16
  /**
17
17
  * Vercel provider.
@@ -33,21 +33,24 @@ const vercel: Provider = {
33
33
  docs: "https://vercel.com/docs/rest-api",
34
34
 
35
35
  async login(ctx: ProviderContext): Promise<AuthHandle> {
36
- const cached = await tryRevealSecret(TOKEN_SECRET);
36
+ const cached = await tryRevealSecret(TOKEN_SECRET, ctx);
37
37
  if (cached) {
38
38
  const identity = await fetchUser(cached);
39
- if (identity) return { token: cached, identity };
39
+ if (identity) {
40
+ acceptVerifiedExisting(ctx, TOKEN_SECRET, cached);
41
+ return { token: cached, identity };
42
+ }
40
43
  ctx.log({ level: "warn", msg: "Cached Vercel token invalid; re-entering." });
41
44
  }
42
45
  if (!ctx.interactive)
43
46
  throw new StackError("VERCEL_AUTH_REQUIRED", "No valid Vercel token in vault.");
44
- process.stderr.write(
45
- "\n Create a Vercel token at https://vercel.com/account/tokens\n Paste it here: ",
46
- );
47
- const token = (await readLine()).trim();
47
+ const token = await promptSecret(ctx, {
48
+ message: "Paste your Vercel token",
49
+ howTo: "Create a Vercel token at https://vercel.com/account/tokens",
50
+ });
48
51
  const identity = await fetchUser(token);
49
52
  if (!identity) throw new StackError("VERCEL_AUTH_INVALID", "Vercel rejected that token.");
50
- await addSecret(TOKEN_SECRET, token);
53
+ await storeProviderSecret(ctx, TOKEN_SECRET, token);
51
54
  return { token, identity };
52
55
  },
53
56
 
@@ -55,7 +58,10 @@ const vercel: Provider = {
55
58
  if (opts.existingResourceId) {
56
59
  const project = await fetchProject(auth.token, opts.existingResourceId);
57
60
  if (!project)
58
- throw new StackError("VERCEL_PROJECT_NOT_FOUND", `No Vercel project ${opts.existingResourceId}.`);
61
+ throw new StackError(
62
+ "VERCEL_PROJECT_NOT_FOUND",
63
+ `No Vercel project ${opts.existingResourceId}.`,
64
+ );
59
65
  return { id: project.id, displayName: project.name };
60
66
  }
61
67
  const name = `stack-${Date.now().toString(36)}`;
@@ -75,7 +81,7 @@ const vercel: Provider = {
75
81
  return { id: project.id, displayName: project.name };
76
82
  },
77
83
 
78
- async materialize(_ctx, resource, auth): Promise<Materialized> {
84
+ async materialize(ctx, resource, auth): Promise<Materialized> {
79
85
  return {
80
86
  secrets: { [TOKEN_SECRET]: auth.token },
81
87
  urls: {
@@ -84,8 +90,8 @@ const vercel: Provider = {
84
90
  };
85
91
  },
86
92
 
87
- async healthcheck(_ctx, entry: ServiceEntry): Promise<HealthStatus> {
88
- const token = await tryRevealSecret(TOKEN_SECRET);
93
+ async healthcheck(ctx, entry: ServiceEntry): Promise<HealthStatus> {
94
+ const token = await tryRevealSecret(TOKEN_SECRET, ctx);
89
95
  if (!token) return { kind: "error", detail: `${TOKEN_SECRET} missing from vault` };
90
96
  if (!entry.resource_id) return { kind: "warn", detail: "no resource_id" };
91
97
  const start = Date.now();
@@ -99,11 +105,33 @@ const vercel: Provider = {
99
105
  ? `https://vercel.com/dashboard/${entry.resource_id}`
100
106
  : "https://vercel.com/dashboard";
101
107
  },
108
+
109
+ async deprovision(_ctx: ProviderContext, auth: AuthHandle, resourceId: string): Promise<void> {
110
+ let res: Response;
111
+ try {
112
+ res = await fetch(`${API}/v9/projects/${resourceId}`, {
113
+ method: "DELETE",
114
+ headers: authHeaders(auth.token),
115
+ });
116
+ } catch {
117
+ throw new StackError(
118
+ "VERCEL_DELETE_UNCONFIRMED",
119
+ "Vercel resource deletion could not be confirmed.",
120
+ );
121
+ }
122
+ // Admit only documented completion statuses; other 2xx/404 are unconfirmed.
123
+ // https://vercel.com/docs/rest-api/projects/delete-a-project
124
+ if (res.status === 204) return;
125
+ throw new StackError(
126
+ "VERCEL_DELETE_FAILED",
127
+ `Vercel resource deletion failed (HTTP ${res.status}).`,
128
+ );
129
+ },
102
130
  };
103
131
 
104
132
  export default vercel;
105
133
 
106
- function authHeaders(token: string): HeadersInit {
134
+ function authHeaders(token: string): NonNullable<RequestInit["headers"]> {
107
135
  return { Authorization: `Bearer ${token}`, "content-type": "application/json" };
108
136
  }
109
137
 
@@ -112,23 +140,25 @@ async function fetchUser(token: string): Promise<Record<string, string> | undefi
112
140
  // Idempotent GET — retry transient failures during login validation + healthcheck.
113
141
  const res = await fetchWithRetry(`${API}/v2/user`, { headers: authHeaders(token) });
114
142
  if (!res.ok) return undefined;
115
- const body = (await res.json()) as { user?: { uid?: string; email?: string; username?: string } };
143
+ const body = (await res.json()) as {
144
+ user?: { uid?: string; email?: string; username?: string };
145
+ };
116
146
  if (!body.user) return undefined;
117
147
  const out: Record<string, string> = {};
118
- for (const [k, v] of Object.entries(body.user))
119
- if (typeof v === "string") out[k] = v;
148
+ for (const [k, v] of Object.entries(body.user)) if (typeof v === "string") out[k] = v;
120
149
  return out;
121
150
  } catch {
122
151
  return undefined;
123
152
  }
124
153
  }
125
154
 
126
- async function fetchProject(token: string, id: string): Promise<{ id: string; name: string } | undefined> {
155
+ async function fetchProject(
156
+ token: string,
157
+ id: string,
158
+ ): Promise<{ id: string; name: string } | undefined> {
127
159
  // Idempotent GET — healthcheck reads this on every `stack status` call.
128
160
  const res = await fetchWithRetry(`${API}/v9/projects/${id}`, { headers: authHeaders(token) });
129
161
  if (res.status === 404) return undefined;
130
162
  if (!res.ok) return undefined;
131
163
  return (await res.json()) as { id: string; name: string };
132
164
  }
133
-
134
-
@@ -0,0 +1,27 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+ import { verifyFetch } from "./_helpers.ts";
3
+
4
+ export default makeApiKeyProvider({
5
+ name: "workos",
6
+ displayName: "WorkOS",
7
+ category: "auth",
8
+ docs: "https://workos.com/docs/reference/api",
9
+ secretName: "WORKOS_API_KEY",
10
+ howTo: "Find your API key in the WorkOS dashboard → API Keys.",
11
+ dashboard: "https://dashboard.workos.com",
12
+ async verify(key) {
13
+ try {
14
+ const res = await verifyFetch("https://api.workos.com/organizations?limit=1", {
15
+ headers: {
16
+ Authorization: `Bearer ${key}`,
17
+ "Content-Type": "application/json",
18
+ },
19
+ });
20
+ if (!res.ok) return undefined;
21
+ const body = (await res.json()) as { data?: unknown[] };
22
+ return { organizations: String(body.data?.length ?? 0) };
23
+ } catch {
24
+ return undefined;
25
+ }
26
+ },
27
+ });
package/src/registry.ts CHANGED
@@ -2,12 +2,7 @@ import { existsSync, mkdirSync } from "node:fs";
2
2
  import { readFile, writeFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { basename, resolve } from "node:path";
5
- import {
6
- CONFIG_FILENAME,
7
- hasConfig,
8
- readConfig,
9
- resolveConfigPath,
10
- } from "./config.ts";
5
+ import { CONFIG_FILENAME, hasConfig, readConfig, resolveConfigPath } from "./config.ts";
11
6
 
12
7
  /**
13
8
  * Machine-global registry of every Stack-enabled project this user has
@@ -82,13 +77,14 @@ export async function registerProject(cwd: string): Promise<RegistryEntry | unde
82
77
  return entry;
83
78
  }
84
79
 
85
- export async function unregisterProject(path: string): Promise<void> {
80
+ /** Drop `path` from the registry. Returns false (and writes nothing) when it was not registered. */
81
+ export async function unregisterProject(path: string): Promise<boolean> {
86
82
  const resolved = resolve(path);
87
83
  const file = await readRegistryRaw();
88
- await writeRegistryRaw({
89
- version: "1",
90
- projects: file.projects.filter((p) => p.path !== resolved),
91
- });
84
+ const kept = file.projects.filter((p) => p.path !== resolved);
85
+ if (kept.length === file.projects.length) return false;
86
+ await writeRegistryRaw({ version: "1", projects: kept });
87
+ return true;
92
88
  }
93
89
 
94
90
  export async function listProjects(opts: { prune?: boolean } = {}): Promise<RegistryEntry[]> {