@ashlr/stack-core 0.1.1

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -0
  3. package/package.json +57 -0
  4. package/src/__tests__/_harness.ts +146 -0
  5. package/src/__tests__/api-key-providers.test.ts +237 -0
  6. package/src/__tests__/catalog-index.test.ts +103 -0
  7. package/src/__tests__/circuit-breaker.test.ts +112 -0
  8. package/src/__tests__/clone-url-validation.test.ts +47 -0
  9. package/src/__tests__/cloudflare-provider.test.ts +74 -0
  10. package/src/__tests__/completion-ci.test.ts +62 -0
  11. package/src/__tests__/config-split.test.ts +138 -0
  12. package/src/__tests__/config.test.ts +66 -0
  13. package/src/__tests__/deps.test.ts +58 -0
  14. package/src/__tests__/detect-source.test.ts +147 -0
  15. package/src/__tests__/detect.test.ts +67 -0
  16. package/src/__tests__/github-provider.test.ts +65 -0
  17. package/src/__tests__/helpers.test.ts +38 -0
  18. package/src/__tests__/http.test.ts +235 -0
  19. package/src/__tests__/import-integration.test.ts +87 -0
  20. package/src/__tests__/inference.test.ts +326 -0
  21. package/src/__tests__/neon-provider.test.ts +79 -0
  22. package/src/__tests__/openai-provider.test.ts +54 -0
  23. package/src/__tests__/phantom-harness.test.ts +44 -0
  24. package/src/__tests__/phantom-wire.test.ts +85 -0
  25. package/src/__tests__/pipeline-integration.test.ts +95 -0
  26. package/src/__tests__/pipeline-recovery.test.ts +106 -0
  27. package/src/__tests__/pipeline.test.ts +92 -0
  28. package/src/__tests__/recipe.test.ts +164 -0
  29. package/src/__tests__/recommend-apply-e2e.test.ts +222 -0
  30. package/src/__tests__/registry.test.ts +78 -0
  31. package/src/__tests__/secret-redaction.test.ts +170 -0
  32. package/src/__tests__/security.test.ts +80 -0
  33. package/src/__tests__/supabase-provider.test.ts +108 -0
  34. package/src/__tests__/templates.test.ts +26 -0
  35. package/src/__tests__/turso-provider.test.ts +71 -0
  36. package/src/ai/catalog-index.ts +256 -0
  37. package/src/ai/circuit-breaker.ts +116 -0
  38. package/src/ai/cost-tracker.ts +103 -0
  39. package/src/ai/inference.ts +470 -0
  40. package/src/ai/phantom-wire.ts +184 -0
  41. package/src/ai/recipe.ts +178 -0
  42. package/src/catalog.ts +430 -0
  43. package/src/config.ts +293 -0
  44. package/src/detect-source.ts +344 -0
  45. package/src/detect.ts +207 -0
  46. package/src/env.ts +35 -0
  47. package/src/errors.ts +29 -0
  48. package/src/http.ts +212 -0
  49. package/src/index.ts +61 -0
  50. package/src/mcp-writer.ts +50 -0
  51. package/src/oauth.ts +184 -0
  52. package/src/phantom.ts +156 -0
  53. package/src/pipeline.ts +131 -0
  54. package/src/providers/_api-key.ts +122 -0
  55. package/src/providers/_base.ts +96 -0
  56. package/src/providers/_helpers.ts +81 -0
  57. package/src/providers/anthropic.ts +27 -0
  58. package/src/providers/aws.ts +198 -0
  59. package/src/providers/braintrust.ts +33 -0
  60. package/src/providers/clerk.ts +23 -0
  61. package/src/providers/cloudflare.ts +146 -0
  62. package/src/providers/convex.ts +28 -0
  63. package/src/providers/deepseek.ts +24 -0
  64. package/src/providers/firebase.ts +35 -0
  65. package/src/providers/fly.ts +24 -0
  66. package/src/providers/github.ts +180 -0
  67. package/src/providers/index.ts +59 -0
  68. package/src/providers/linear.ts +46 -0
  69. package/src/providers/mailgun.ts +26 -0
  70. package/src/providers/modal.ts +27 -0
  71. package/src/providers/neon.ts +175 -0
  72. package/src/providers/openai.ts +24 -0
  73. package/src/providers/posthog.ts +30 -0
  74. package/src/providers/postmark.ts +27 -0
  75. package/src/providers/railway.ts +35 -0
  76. package/src/providers/render.ts +31 -0
  77. package/src/providers/replicate.ts +30 -0
  78. package/src/providers/resend.ts +24 -0
  79. package/src/providers/sendgrid.ts +24 -0
  80. package/src/providers/sentry.ts +183 -0
  81. package/src/providers/stripe.ts +33 -0
  82. package/src/providers/supabase.ts +309 -0
  83. package/src/providers/turso.ts +197 -0
  84. package/src/providers/upstash.ts +32 -0
  85. package/src/providers/vercel.ts +134 -0
  86. package/src/providers/xai.ts +24 -0
  87. package/src/registry.ts +114 -0
  88. package/src/templates.ts +39 -0
@@ -0,0 +1,81 @@
1
+ import { revealSecret } from "../phantom.ts";
2
+ import { fetchWithRetry, type RetryOptions } from "../http.ts";
3
+
4
+ /**
5
+ * Shared helpers for every hand-written provider. Before this existed, each
6
+ * provider shipped a byte-for-byte copy of `readLine` and `tryRevealSecret` —
7
+ * one source of truth keeps behaviour consistent.
8
+ */
9
+
10
+ export async function tryRevealSecret(key: string): Promise<string | undefined> {
11
+ try {
12
+ const value = await revealSecret(key);
13
+ return value.length > 0 ? value : undefined;
14
+ } catch {
15
+ return undefined;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Read a single line from stdin. Used by every provider's PAT-paste fallback
21
+ * when no OAuth client is configured. Resumes stdin, reads until newline, then
22
+ * pauses stdin so the parent process doesn't hang waiting for EOF.
23
+ */
24
+ export async function readLine(): Promise<string> {
25
+ return new Promise((resolve) => {
26
+ let buf = "";
27
+ const onData = (chunk: Buffer) => {
28
+ buf += chunk.toString();
29
+ if (buf.includes("\n")) {
30
+ process.stdin.off("data", onData);
31
+ process.stdin.pause();
32
+ resolve(buf.split("\n")[0]);
33
+ }
34
+ };
35
+ process.stdin.resume();
36
+ process.stdin.on("data", onData);
37
+ });
38
+ }
39
+
40
+ /**
41
+ * Build a standard Authorization + content-type + Accept header set for
42
+ * Bearer-token providers. Providers with custom auth header shapes (Anthropic,
43
+ * Upstash, Linear, Supabase PKCE) inline their own.
44
+ */
45
+ export function bearerJsonHeaders(token: string): HeadersInit {
46
+ return {
47
+ Authorization: `Bearer ${token}`,
48
+ "content-type": "application/json",
49
+ Accept: "application/json",
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Idempotent GET wrapper for the `verify` closures used by api-key providers
55
+ * (and for any other read-only provider call). Thin shim around
56
+ * `fetchWithRetry` that forces `idempotent: true` — callers are always
57
+ * verifying a credential via a read-only endpoint, so retrying transient
58
+ * 429/5xx is always safe. Keeps each provider's `verify` free of retry glue.
59
+ */
60
+ export function verifyFetch(
61
+ input: RequestInfo | URL,
62
+ init?: RequestInit,
63
+ opts?: RetryOptions,
64
+ ): Promise<Response> {
65
+ return fetchWithRetry(input, init, { ...opts, idempotent: true });
66
+ }
67
+
68
+ /**
69
+ * Redact all but the last `keepLast` characters of a secret for safe display.
70
+ * Always show at least a few asterisks so log lines that include the redacted
71
+ * value still read clearly. Short strings get fully hidden — the suffix alone
72
+ * could be enough to bruteforce a 6-char token.
73
+ *
74
+ * Usage: `ctx.log({ msg: \`token ${scrub(t)} rejected\` })` prints
75
+ * `token ****abcd rejected`, never the raw secret.
76
+ */
77
+ export function scrub(value: string, keepLast: number = 4): string {
78
+ if (!value) return "";
79
+ if (value.length <= keepLast) return "****";
80
+ return `****${value.slice(-keepLast)}`;
81
+ }
@@ -0,0 +1,27 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+ import { verifyFetch } from "./_helpers.ts";
3
+
4
+ export default makeApiKeyProvider({
5
+ name: "anthropic",
6
+ displayName: "Anthropic",
7
+ category: "ai",
8
+ docs: "https://docs.anthropic.com/en/api",
9
+ secretName: "ANTHROPIC_API_KEY",
10
+ howTo: "Create a key at https://console.anthropic.com/settings/keys",
11
+ dashboard: "https://console.anthropic.com",
12
+ async verify(key) {
13
+ try {
14
+ const res = await verifyFetch("https://api.anthropic.com/v1/models", {
15
+ headers: {
16
+ "x-api-key": key,
17
+ "anthropic-version": "2023-06-01",
18
+ },
19
+ });
20
+ if (!res.ok) return undefined;
21
+ const body = (await res.json()) as { data?: Array<{ id: string }> };
22
+ return { models: String(body.data?.length ?? 0) };
23
+ } catch {
24
+ return undefined;
25
+ }
26
+ },
27
+ });
@@ -0,0 +1,198 @@
1
+ import type { ServiceEntry } from "../config.ts";
2
+ import { createHash, createHmac } from "node:crypto";
3
+ import { StackError } from "../errors.ts";
4
+ import { addSecret } from "../phantom.ts";
5
+ import { readLine, tryRevealSecret } from "./_helpers.ts";
6
+ import type {
7
+ AuthHandle,
8
+ HealthStatus,
9
+ Materialized,
10
+ Provider,
11
+ ProviderContext,
12
+ Resource,
13
+ } from "./_base.ts";
14
+
15
+ /**
16
+ * AWS — v1 accepts an IAM access key pair (access key id + secret access key).
17
+ * Verifies via STS GetCallerIdentity using a hand-rolled SigV4 signer so we
18
+ * don't pull in the AWS SDK (it's a big dep for one call). Stores keys in the
19
+ * Phantom vault and optionally writes ~/.aws/credentials for use by the AWS
20
+ * CLI / SDKs outside Stack.
21
+ */
22
+
23
+ const SECRET_ID_KEY = "AWS_ACCESS_KEY_ID";
24
+ const SECRET_SECRET_KEY = "AWS_SECRET_ACCESS_KEY";
25
+ const SECRET_REGION = "AWS_REGION";
26
+
27
+ const aws: Provider = {
28
+ name: "aws",
29
+ displayName: "AWS",
30
+ category: "cloud",
31
+ authKind: "api_key",
32
+ docs: "https://docs.aws.amazon.com/iam/",
33
+
34
+ async login(ctx: ProviderContext): Promise<AuthHandle> {
35
+ const cachedId = await tryRevealSecret(SECRET_ID_KEY);
36
+ const cachedSecret = await tryRevealSecret(SECRET_SECRET_KEY);
37
+ if (cachedId && cachedSecret) {
38
+ const identity = await callStsIdentity(cachedId, cachedSecret, "us-east-1");
39
+ if (identity) return { token: `${cachedId}:${cachedSecret}`, identity };
40
+ ctx.log({ level: "warn", msg: "Cached AWS credentials invalid." });
41
+ }
42
+ if (!ctx.interactive)
43
+ throw new StackError("AWS_AUTH_REQUIRED", "No valid AWS credentials in vault.");
44
+ process.stderr.write(
45
+ "\n Create an IAM access key at https://console.aws.amazon.com/iam/home#/security_credentials\n AWS_ACCESS_KEY_ID: ",
46
+ );
47
+ const accessKeyId = (await readLine()).trim();
48
+ process.stderr.write(" AWS_SECRET_ACCESS_KEY: ");
49
+ const secretAccessKey = (await readLine()).trim();
50
+ const identity = await callStsIdentity(accessKeyId, secretAccessKey, "us-east-1");
51
+ if (!identity) throw new StackError("AWS_AUTH_INVALID", "AWS rejected those credentials.");
52
+ await addSecret(SECRET_ID_KEY, accessKeyId);
53
+ await addSecret(SECRET_SECRET_KEY, secretAccessKey);
54
+ return { token: `${accessKeyId}:${secretAccessKey}`, identity };
55
+ },
56
+
57
+ async provision(_ctx, auth, opts): Promise<Resource> {
58
+ // v1: attach to the account the keys belong to.
59
+ const region = (opts.hints?.region as string | undefined) ?? "us-east-1";
60
+ return {
61
+ id: auth.identity?.Account ?? "unknown",
62
+ displayName: auth.identity?.Arn ?? "AWS account",
63
+ region,
64
+ };
65
+ },
66
+
67
+ async materialize(_ctx, resource, auth): Promise<Materialized> {
68
+ const sep = auth.token.indexOf(":");
69
+ if (sep <= 0 || sep === auth.token.length - 1) {
70
+ throw new StackError(
71
+ "AWS_AUTH_MALFORMED",
72
+ "AWS auth handle is malformed (expected accessKeyId:secretAccessKey).",
73
+ );
74
+ }
75
+ const accessKeyId = auth.token.slice(0, sep);
76
+ const secretAccessKey = auth.token.slice(sep + 1);
77
+ if (!accessKeyId || !secretAccessKey) {
78
+ throw new StackError(
79
+ "AWS_AUTH_MALFORMED",
80
+ "AWS auth handle missing access key id or secret access key.",
81
+ );
82
+ }
83
+ await addSecret(SECRET_REGION, resource.region ?? "us-east-1");
84
+ return {
85
+ secrets: {
86
+ AWS_ACCESS_KEY_ID: accessKeyId,
87
+ AWS_SECRET_ACCESS_KEY: secretAccessKey,
88
+ AWS_REGION: resource.region ?? "us-east-1",
89
+ },
90
+ };
91
+ },
92
+
93
+ async healthcheck(_ctx, _entry: ServiceEntry): Promise<HealthStatus> {
94
+ const id = await tryRevealSecret(SECRET_ID_KEY);
95
+ const secret = await tryRevealSecret(SECRET_SECRET_KEY);
96
+ const region = (await tryRevealSecret(SECRET_REGION)) ?? "us-east-1";
97
+ if (!id || !secret) return { kind: "error", detail: "AWS credentials missing from vault" };
98
+ const start = Date.now();
99
+ const identity = await callStsIdentity(id, secret, region);
100
+ const latencyMs = Date.now() - start;
101
+ return identity ? { kind: "ok", latencyMs } : { kind: "error", detail: "credentials invalid" };
102
+ },
103
+
104
+ dashboardUrl(): string {
105
+ return "https://console.aws.amazon.com";
106
+ },
107
+ };
108
+
109
+ export default aws;
110
+
111
+ /**
112
+ * Minimal SigV4 signer for a single call: STS GetCallerIdentity. Scoped tight
113
+ * on purpose — any more and we should pull in @aws-sdk/client-sts.
114
+ */
115
+ async function callStsIdentity(
116
+ accessKeyId: string,
117
+ secretAccessKey: string,
118
+ region: string,
119
+ ): Promise<Record<string, string> | undefined> {
120
+ // us-east-1 uses the unregional endpoint; everything else uses sts.<region>.amazonaws.com.
121
+ const host = region === "us-east-1" ? "sts.amazonaws.com" : `sts.${region}.amazonaws.com`;
122
+ const service = "sts";
123
+ const method = "POST";
124
+ const now = new Date();
125
+ const amzDate = toAmzDate(now);
126
+ const datestamp = amzDate.slice(0, 8);
127
+ const payload = "Action=GetCallerIdentity&Version=2011-06-15";
128
+ const payloadHash = createHash("sha256").update(payload).digest("hex");
129
+
130
+ const canonicalHeaders =
131
+ `content-type:application/x-www-form-urlencoded\n` +
132
+ `host:${host}\n` +
133
+ `x-amz-date:${amzDate}\n`;
134
+ const signedHeaders = "content-type;host;x-amz-date";
135
+ const canonicalRequest = [
136
+ method,
137
+ "/",
138
+ "",
139
+ canonicalHeaders,
140
+ signedHeaders,
141
+ payloadHash,
142
+ ].join("\n");
143
+ const scope = `${datestamp}/${region}/${service}/aws4_request`;
144
+ const stringToSign = [
145
+ "AWS4-HMAC-SHA256",
146
+ amzDate,
147
+ scope,
148
+ createHash("sha256").update(canonicalRequest).digest("hex"),
149
+ ].join("\n");
150
+
151
+ const kDate = hmac(`AWS4${secretAccessKey}`, datestamp);
152
+ const kRegion = hmac(kDate, region);
153
+ const kService = hmac(kRegion, service);
154
+ const kSigning = hmac(kService, "aws4_request");
155
+ const signature = createHmac("sha256", kSigning).update(stringToSign).digest("hex");
156
+
157
+ const authorization =
158
+ `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, ` +
159
+ `SignedHeaders=${signedHeaders}, Signature=${signature}`;
160
+
161
+ try {
162
+ const res = await fetch(`https://${host}/`, {
163
+ method,
164
+ headers: {
165
+ "content-type": "application/x-www-form-urlencoded",
166
+ "x-amz-date": amzDate,
167
+ Authorization: authorization,
168
+ },
169
+ body: payload,
170
+ });
171
+ if (!res.ok) return undefined;
172
+ const xml = await res.text();
173
+ const arn = matchXml(xml, "Arn");
174
+ const userId = matchXml(xml, "UserId");
175
+ const account = matchXml(xml, "Account");
176
+ if (!arn) return undefined;
177
+ return {
178
+ Arn: arn,
179
+ ...(userId ? { UserId: userId } : {}),
180
+ ...(account ? { Account: account } : {}),
181
+ };
182
+ } catch {
183
+ return undefined;
184
+ }
185
+ }
186
+
187
+ function hmac(key: string | Buffer, value: string): Buffer {
188
+ return createHmac("sha256", key).update(value).digest();
189
+ }
190
+
191
+ function toAmzDate(d: Date): string {
192
+ return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
193
+ }
194
+
195
+ function matchXml(xml: string, tag: string): string | undefined {
196
+ const m = xml.match(new RegExp(`<${tag}>([^<]+)<\\/${tag}>`));
197
+ return m?.[1];
198
+ }
@@ -0,0 +1,33 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+ import { verifyFetch } from "./_helpers.ts";
3
+
4
+ /**
5
+ * Braintrust — LLM eval + observability. v1 accepts a Braintrust API key
6
+ * (create at https://www.braintrust.dev/app/settings/api-keys) and verifies
7
+ * via /v1/organization (hits `https://api.braintrust.dev`).
8
+ */
9
+ export default makeApiKeyProvider({
10
+ name: "braintrust",
11
+ displayName: "Braintrust",
12
+ category: "ai",
13
+ docs: "https://www.braintrust.dev/docs",
14
+ secretName: "BRAINTRUST_API_KEY",
15
+ howTo: "Create a key at https://www.braintrust.dev/app/settings/api-keys",
16
+ dashboard: "https://www.braintrust.dev/app",
17
+ async verify(key) {
18
+ try {
19
+ const res = await verifyFetch("https://api.braintrust.dev/v1/organization", {
20
+ headers: { Authorization: `Bearer ${key}` },
21
+ });
22
+ if (!res.ok) return undefined;
23
+ const body = (await res.json()) as {
24
+ objects?: Array<{ id?: string; name?: string }>;
25
+ };
26
+ const first = body.objects?.[0];
27
+ if (!first?.id) return undefined;
28
+ return { id: first.id, name: first.name ?? "" };
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ },
33
+ });
@@ -0,0 +1,23 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+ import { verifyFetch } from "./_helpers.ts";
3
+
4
+ export default makeApiKeyProvider({
5
+ name: "clerk",
6
+ displayName: "Clerk",
7
+ category: "auth",
8
+ docs: "https://clerk.com/docs",
9
+ secretName: "CLERK_SECRET_KEY",
10
+ howTo: "Grab your secret key from https://dashboard.clerk.com → API Keys",
11
+ dashboard: "https://dashboard.clerk.com",
12
+ async verify(key) {
13
+ try {
14
+ const res = await verifyFetch("https://api.clerk.com/v1/jwks", {
15
+ headers: { Authorization: `Bearer ${key}` },
16
+ });
17
+ if (!res.ok) return undefined;
18
+ return { verified: "true" };
19
+ } catch {
20
+ return undefined;
21
+ }
22
+ },
23
+ });
@@ -0,0 +1,146 @@
1
+ import type { ServiceEntry } from "../config.ts";
2
+ import { StackError } from "../errors.ts";
3
+ import { addSecret, revealSecret } from "../phantom.ts";
4
+ import { fetchWithRetry } from "../http.ts";
5
+ import type {
6
+ AuthHandle,
7
+ HealthStatus,
8
+ Materialized,
9
+ Provider,
10
+ ProviderContext,
11
+ ProvisionOpts,
12
+ Resource,
13
+ } from "./_base.ts";
14
+
15
+ /**
16
+ * Cloudflare — Workers / Pages / R2 / D1 / KV all hang off one Account API
17
+ * token. v1 uses a user-created token (users make one at
18
+ * https://dash.cloudflare.com/profile/api-tokens with minimal scopes). Auto-
19
+ * provisioning a new Worker is intentionally deferred — most real workflows
20
+ * start from a template or wrangler scaffold.
21
+ */
22
+
23
+ const API = "https://api.cloudflare.com/client/v4";
24
+ const TOKEN_SECRET = "CLOUDFLARE_API_TOKEN";
25
+
26
+ const cloudflare: Provider = {
27
+ name: "cloudflare",
28
+ displayName: "Cloudflare",
29
+ category: "deploy",
30
+ authKind: "pat",
31
+ docs: "https://developers.cloudflare.com/api",
32
+
33
+ async login(ctx: ProviderContext): Promise<AuthHandle> {
34
+ const cached = await tryRevealSecret(TOKEN_SECRET);
35
+ if (cached) {
36
+ const identity = await verifyToken(cached);
37
+ if (identity) return { token: cached, identity };
38
+ ctx.log({ level: "warn", msg: "Cached Cloudflare token invalid." });
39
+ }
40
+ if (!ctx.interactive)
41
+ throw new StackError("CLOUDFLARE_AUTH_REQUIRED", "No valid Cloudflare token in vault.");
42
+ process.stderr.write(
43
+ "\n Create an API token at https://dash.cloudflare.com/profile/api-tokens\n Scopes: Account · Workers Scripts / Pages / R2 / D1 as needed\n Paste it here: ",
44
+ );
45
+ const token = (await readLine()).trim();
46
+ const identity = await verifyToken(token);
47
+ if (!identity) throw new StackError("CLOUDFLARE_AUTH_INVALID", "Cloudflare rejected that token.");
48
+ await addSecret(TOKEN_SECRET, token);
49
+ return { token, identity };
50
+ },
51
+
52
+ async provision(ctx, auth, opts: ProvisionOpts): Promise<Resource> {
53
+ const accounts = await fetchAccounts(auth.token);
54
+ if (accounts.length === 0)
55
+ throw new StackError("CLOUDFLARE_NO_ACCOUNT", "Token has no account scope.");
56
+ const accountId = (opts.hints?.accountId as string | undefined) ?? accounts[0].id;
57
+ if (accounts.length > 1) {
58
+ ctx.log({
59
+ level: "warn",
60
+ msg: `Multiple Cloudflare accounts; defaulting to "${accounts[0].name}". Pass --use <account-id> to pick.`,
61
+ });
62
+ }
63
+ return {
64
+ id: opts.existingResourceId ?? accountId,
65
+ displayName: accounts.find((a) => a.id === accountId)?.name ?? accountId,
66
+ meta: { account_id: accountId },
67
+ };
68
+ },
69
+
70
+ async materialize(_ctx, resource, auth): Promise<Materialized> {
71
+ return {
72
+ secrets: {
73
+ CLOUDFLARE_API_TOKEN: auth.token,
74
+ CLOUDFLARE_ACCOUNT_ID: resource.id,
75
+ },
76
+ urls: { dashboard: `https://dash.cloudflare.com/${resource.id}` },
77
+ };
78
+ },
79
+
80
+ async healthcheck(_ctx, _entry: ServiceEntry): Promise<HealthStatus> {
81
+ const token = await tryRevealSecret(TOKEN_SECRET);
82
+ if (!token) return { kind: "error", detail: `${TOKEN_SECRET} missing from vault` };
83
+ const start = Date.now();
84
+ const identity = await verifyToken(token);
85
+ const latencyMs = Date.now() - start;
86
+ return identity ? { kind: "ok", latencyMs } : { kind: "error", detail: "token invalid" };
87
+ },
88
+
89
+ dashboardUrl(entry: ServiceEntry): string {
90
+ return entry.resource_id
91
+ ? `https://dash.cloudflare.com/${entry.resource_id}`
92
+ : "https://dash.cloudflare.com";
93
+ },
94
+ };
95
+
96
+ export default cloudflare;
97
+
98
+ async function verifyToken(token: string): Promise<Record<string, string> | undefined> {
99
+ try {
100
+ // Idempotent GET — healthcheck hits this on every loop.
101
+ const res = await fetchWithRetry(`${API}/user/tokens/verify`, {
102
+ headers: { Authorization: `Bearer ${token}` },
103
+ });
104
+ if (!res.ok) return undefined;
105
+ const body = (await res.json()) as { result?: { id?: string; status?: string } };
106
+ if (body.result?.status !== "active") return undefined;
107
+ return { token_id: body.result.id ?? "" };
108
+ } catch {
109
+ return undefined;
110
+ }
111
+ }
112
+
113
+ async function fetchAccounts(token: string): Promise<Array<{ id: string; name: string }>> {
114
+ // Idempotent GET — retry transient failures on `stack add` startup.
115
+ const res = await fetchWithRetry(`${API}/accounts`, {
116
+ headers: { Authorization: `Bearer ${token}` },
117
+ });
118
+ if (!res.ok) return [];
119
+ const body = (await res.json()) as { result?: Array<{ id: string; name: string }> };
120
+ return body.result ?? [];
121
+ }
122
+
123
+ async function tryRevealSecret(key: string): Promise<string | undefined> {
124
+ try {
125
+ const v = await revealSecret(key);
126
+ return v.length > 0 ? v : undefined;
127
+ } catch {
128
+ return undefined;
129
+ }
130
+ }
131
+
132
+ async function readLine(): Promise<string> {
133
+ return new Promise((resolve) => {
134
+ let buf = "";
135
+ const onData = (chunk: Buffer) => {
136
+ buf += chunk.toString();
137
+ if (buf.includes("\n")) {
138
+ process.stdin.off("data", onData);
139
+ process.stdin.pause();
140
+ resolve(buf.split("\n")[0]);
141
+ }
142
+ };
143
+ process.stdin.resume();
144
+ process.stdin.on("data", onData);
145
+ });
146
+ }
@@ -0,0 +1,28 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+
3
+ /**
4
+ * Convex — reactive backend with subscriptions + scheduled functions. v1 uses
5
+ * a deploy key (users create one at https://dashboard.convex.dev → Project
6
+ * Settings → Deploy Keys). Creating Convex deployments programmatically
7
+ * requires their deploy CLI with a browser flow — out of scope for v1.
8
+ */
9
+ export default makeApiKeyProvider({
10
+ name: "convex",
11
+ displayName: "Convex",
12
+ category: "database",
13
+ docs: "https://docs.convex.dev",
14
+ secretName: "CONVEX_DEPLOY_KEY",
15
+ howTo:
16
+ "Create a deploy key at https://dashboard.convex.dev (Project → Settings → Deploy Keys).",
17
+ dashboard: "https://dashboard.convex.dev",
18
+ async verify(key) {
19
+ // Convex deploy keys have the shape prod:<team>:<project>|<token>. No
20
+ // public validate-only endpoint, so we do a shape check and defer real
21
+ // validation to first CLI use (convex deploy / npx convex dev).
22
+ if (!key.includes(":") || !key.includes("|")) return undefined;
23
+ const [prefix] = key.split("|");
24
+ const parts = prefix.split(":");
25
+ if (parts.length < 3) return undefined;
26
+ return { environment: parts[0], team: parts[1], project: parts[2] };
27
+ },
28
+ });
@@ -0,0 +1,24 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+ import { verifyFetch } from "./_helpers.ts";
3
+
4
+ export default makeApiKeyProvider({
5
+ name: "deepseek",
6
+ displayName: "DeepSeek",
7
+ category: "ai",
8
+ docs: "https://api-docs.deepseek.com",
9
+ secretName: "DEEPSEEK_API_KEY",
10
+ howTo: "Create a key at https://platform.deepseek.com/api_keys",
11
+ dashboard: "https://platform.deepseek.com",
12
+ async verify(key) {
13
+ try {
14
+ const res = await verifyFetch("https://api.deepseek.com/v1/models", {
15
+ headers: { Authorization: `Bearer ${key}` },
16
+ });
17
+ if (!res.ok) return undefined;
18
+ const body = (await res.json()) as { data?: Array<{ id: string }> };
19
+ return { models: String(body.data?.length ?? 0) };
20
+ } catch {
21
+ return undefined;
22
+ }
23
+ },
24
+ });
@@ -0,0 +1,35 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+
3
+ /**
4
+ * Firebase — v1 accepts a service-account JSON (users download from
5
+ * https://console.firebase.google.com → Project Settings → Service Accounts
6
+ * → Generate new private key). We store the entire JSON blob as a single
7
+ * secret and shape-check it has the fields Firebase SDKs need.
8
+ */
9
+ export default makeApiKeyProvider({
10
+ name: "firebase",
11
+ displayName: "Firebase",
12
+ category: "database",
13
+ docs: "https://firebase.google.com/docs/admin/setup",
14
+ secretName: "FIREBASE_SERVICE_ACCOUNT_JSON",
15
+ howTo:
16
+ "Download a Service Account JSON at https://console.firebase.google.com → Settings → Service Accounts",
17
+ dashboard: "https://console.firebase.google.com",
18
+ async verify(blob) {
19
+ try {
20
+ const parsed = JSON.parse(blob) as {
21
+ type?: string;
22
+ project_id?: string;
23
+ client_email?: string;
24
+ };
25
+ if (parsed.type !== "service_account") return undefined;
26
+ if (!parsed.project_id || !parsed.client_email) return undefined;
27
+ return {
28
+ project_id: parsed.project_id,
29
+ client_email: parsed.client_email,
30
+ };
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ },
35
+ });
@@ -0,0 +1,24 @@
1
+ import { makeApiKeyProvider } from "./_api-key.ts";
2
+ import { verifyFetch } from "./_helpers.ts";
3
+
4
+ export default makeApiKeyProvider({
5
+ name: "fly",
6
+ displayName: "Fly.io",
7
+ category: "deploy",
8
+ docs: "https://fly.io/docs/machines/api",
9
+ secretName: "FLY_API_TOKEN",
10
+ howTo: "Run `fly auth token` or create one at https://fly.io/user/personal_access_tokens",
11
+ dashboard: "https://fly.io/dashboard",
12
+ async verify(key) {
13
+ try {
14
+ const res = await verifyFetch("https://api.machines.dev/v1/apps", {
15
+ headers: { Authorization: `Bearer ${key}` },
16
+ });
17
+ if (!res.ok) return undefined;
18
+ const body = (await res.json()) as { apps?: Array<{ name: string }> };
19
+ return { apps: String(body.apps?.length ?? 0) };
20
+ } catch {
21
+ return undefined;
22
+ }
23
+ },
24
+ });