@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
|
@@ -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
|
+
});
|
package/src/providers/sentry.ts
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
package/src/providers/stripe.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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
|
-
|
|
143
|
-
await
|
|
144
|
-
return
|
|
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
|
-
|
|
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(
|
|
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
|
-
`
|
|
190
|
-
|
|
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):
|
|
259
|
+
function authHeaders(token: string): NonNullable<RequestInit["headers"]> {
|
|
231
260
|
return {
|
|
232
261
|
Authorization: `Bearer ${token}`,
|
|
233
262
|
"content-type": "application/json",
|
package/src/providers/turso.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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)
|
|
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(
|
|
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(
|
|
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
|
-
}
|
package/src/providers/vercel.ts
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
45
|
-
"
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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):
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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[]> {
|