@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
|
@@ -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
|
* Cloudflare — Workers / Pages / R2 / D1 / KV all hang off one Account API
|
|
@@ -31,21 +32,26 @@ const cloudflare: Provider = {
|
|
|
31
32
|
docs: "https://developers.cloudflare.com/api",
|
|
32
33
|
|
|
33
34
|
async login(ctx: ProviderContext): Promise<AuthHandle> {
|
|
34
|
-
const cached = await tryRevealSecret(TOKEN_SECRET);
|
|
35
|
+
const cached = await tryRevealSecret(TOKEN_SECRET, ctx);
|
|
35
36
|
if (cached) {
|
|
36
37
|
const identity = await verifyToken(cached);
|
|
37
|
-
if (identity)
|
|
38
|
+
if (identity) {
|
|
39
|
+
acceptVerifiedExisting(ctx, TOKEN_SECRET, cached);
|
|
40
|
+
return { token: cached, identity };
|
|
41
|
+
}
|
|
38
42
|
ctx.log({ level: "warn", msg: "Cached Cloudflare token invalid." });
|
|
39
43
|
}
|
|
40
44
|
if (!ctx.interactive)
|
|
41
45
|
throw new StackError("CLOUDFLARE_AUTH_REQUIRED", "No valid Cloudflare token in vault.");
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
const token = await promptSecret(ctx, {
|
|
47
|
+
message: "Paste your Cloudflare API token",
|
|
48
|
+
howTo:
|
|
49
|
+
"Create an API token at https://dash.cloudflare.com/profile/api-tokens (scopes: Account · Workers Scripts / Pages / R2 / D1 as needed)",
|
|
50
|
+
});
|
|
46
51
|
const identity = await verifyToken(token);
|
|
47
|
-
if (!identity)
|
|
48
|
-
|
|
52
|
+
if (!identity)
|
|
53
|
+
throw new StackError("CLOUDFLARE_AUTH_INVALID", "Cloudflare rejected that token.");
|
|
54
|
+
await storeProviderSecret(ctx, TOKEN_SECRET, token);
|
|
49
55
|
return { token, identity };
|
|
50
56
|
},
|
|
51
57
|
|
|
@@ -67,7 +73,7 @@ const cloudflare: Provider = {
|
|
|
67
73
|
};
|
|
68
74
|
},
|
|
69
75
|
|
|
70
|
-
async materialize(
|
|
76
|
+
async materialize(ctx, resource, auth): Promise<Materialized> {
|
|
71
77
|
return {
|
|
72
78
|
secrets: {
|
|
73
79
|
CLOUDFLARE_API_TOKEN: auth.token,
|
|
@@ -77,8 +83,8 @@ const cloudflare: Provider = {
|
|
|
77
83
|
};
|
|
78
84
|
},
|
|
79
85
|
|
|
80
|
-
async healthcheck(
|
|
81
|
-
const token = await tryRevealSecret(TOKEN_SECRET);
|
|
86
|
+
async healthcheck(ctx, _entry: ServiceEntry): Promise<HealthStatus> {
|
|
87
|
+
const token = await tryRevealSecret(TOKEN_SECRET, ctx);
|
|
82
88
|
if (!token) return { kind: "error", detail: `${TOKEN_SECRET} missing from vault` };
|
|
83
89
|
const start = Date.now();
|
|
84
90
|
const identity = await verifyToken(token);
|
|
@@ -119,28 +125,3 @@ async function fetchAccounts(token: string): Promise<Array<{ id: string; name: s
|
|
|
119
125
|
const body = (await res.json()) as { result?: Array<{ id: string; name: string }> };
|
|
120
126
|
return body.result ?? [];
|
|
121
127
|
}
|
|
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
|
-
}
|
package/src/providers/convex.ts
CHANGED
|
@@ -12,8 +12,7 @@ export default makeApiKeyProvider({
|
|
|
12
12
|
category: "database",
|
|
13
13
|
docs: "https://docs.convex.dev",
|
|
14
14
|
secretName: "CONVEX_DEPLOY_KEY",
|
|
15
|
-
howTo:
|
|
16
|
-
"Create a deploy key at https://dashboard.convex.dev (Project → Settings → Deploy Keys).",
|
|
15
|
+
howTo: "Create a deploy key at https://dashboard.convex.dev (Project → Settings → Deploy Keys).",
|
|
17
16
|
dashboard: "https://dashboard.convex.dev",
|
|
18
17
|
async verify(key) {
|
|
19
18
|
// Convex deploy keys have the shape prod:<team>:<project>|<token>. No
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
import { verifyFetch } from "./_helpers.ts";
|
|
3
|
+
|
|
4
|
+
export default makeApiKeyProvider({
|
|
5
|
+
name: "datadog",
|
|
6
|
+
displayName: "Datadog",
|
|
7
|
+
category: "observability",
|
|
8
|
+
docs: "https://docs.datadoghq.com/api/latest/",
|
|
9
|
+
secretName: "DD_API_KEY",
|
|
10
|
+
howTo:
|
|
11
|
+
"Create an API key at https://app.datadoghq.com/organization-settings/api-keys. This adapter validates and stores that key only.",
|
|
12
|
+
dashboard: "https://app.datadoghq.com",
|
|
13
|
+
async verify(key) {
|
|
14
|
+
try {
|
|
15
|
+
// GET /api/v1/validate — requires only the API key in the header.
|
|
16
|
+
const res = await verifyFetch("https://api.datadoghq.com/api/v1/validate", {
|
|
17
|
+
headers: {
|
|
18
|
+
"DD-API-KEY": key,
|
|
19
|
+
Accept: "application/json",
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
if (!res.ok) return undefined;
|
|
23
|
+
const body = (await res.json()) as { valid?: boolean };
|
|
24
|
+
return body.valid ? { valid: "true" } : undefined;
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
import { verifyFetch } from "./_helpers.ts";
|
|
3
|
+
|
|
4
|
+
export default makeApiKeyProvider({
|
|
5
|
+
name: "digitalocean",
|
|
6
|
+
displayName: "DigitalOcean",
|
|
7
|
+
category: "cloud",
|
|
8
|
+
docs: "https://docs.digitalocean.com/reference/api/api-reference/",
|
|
9
|
+
secretName: "DIGITALOCEAN_TOKEN",
|
|
10
|
+
howTo: "Create a personal access token at https://cloud.digitalocean.com/account/api/tokens.",
|
|
11
|
+
dashboard: "https://cloud.digitalocean.com",
|
|
12
|
+
async verify(key) {
|
|
13
|
+
try {
|
|
14
|
+
const res = await verifyFetch("https://api.digitalocean.com/v2/account", {
|
|
15
|
+
headers: {
|
|
16
|
+
Authorization: `Bearer ${key}`,
|
|
17
|
+
Accept: "application/json",
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
if (!res.ok) return undefined;
|
|
21
|
+
const body = (await res.json()) as { account?: { email?: string; uuid?: string } };
|
|
22
|
+
return {
|
|
23
|
+
email: body.account?.email ?? "",
|
|
24
|
+
uuid: body.account?.uuid ?? "",
|
|
25
|
+
};
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
|
|
3
|
+
// GCP uses a service-account JSON blob as the primary credential. We store
|
|
4
|
+
// the raw JSON in Phantom. The verify step does a structural parse only —
|
|
5
|
+
// exchanging the JSON for an OAuth2 access token and then calling the Cloud
|
|
6
|
+
// Resource Manager API requires network round-trips to token.googleapis.com
|
|
7
|
+
// and is deferred to v0.2. The structural check is sufficient to catch copy-
|
|
8
|
+
// paste errors (wrong file, truncated JSON, wrong project).
|
|
9
|
+
export default makeApiKeyProvider({
|
|
10
|
+
name: "gcp",
|
|
11
|
+
displayName: "GCP",
|
|
12
|
+
category: "cloud",
|
|
13
|
+
docs: "https://cloud.google.com/docs/authentication/getting-started",
|
|
14
|
+
secretName: "GCP_SERVICE_ACCOUNT_JSON",
|
|
15
|
+
howTo:
|
|
16
|
+
"Download a service-account JSON key from GCP → IAM & Admin → Service Accounts and paste the full JSON content.",
|
|
17
|
+
dashboard: "https://console.cloud.google.com",
|
|
18
|
+
async verify(key) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(key) as Record<string, unknown>;
|
|
21
|
+
if (
|
|
22
|
+
parsed.type === "service_account" &&
|
|
23
|
+
typeof parsed.project_id === "string" &&
|
|
24
|
+
typeof parsed.client_email === "string"
|
|
25
|
+
) {
|
|
26
|
+
return {
|
|
27
|
+
project_id: parsed.project_id as string,
|
|
28
|
+
client_email: parsed.client_email as string,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
});
|
package/src/providers/github.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import type { ServiceEntry } from "../config.ts";
|
|
2
|
+
import { resolveOAuthClientId } from "../env.ts";
|
|
2
3
|
import { StackError } from "../errors.ts";
|
|
3
|
-
import { addSecret } from "../phantom.ts";
|
|
4
4
|
import { fetchWithRetry } from "../http.ts";
|
|
5
|
-
import { readLine, tryRevealSecret } from "./_helpers.ts";
|
|
6
|
-
import { resolveOAuthClientId } from "../env.ts";
|
|
7
5
|
import type {
|
|
8
6
|
AuthHandle,
|
|
9
7
|
HealthStatus,
|
|
@@ -12,6 +10,8 @@ import type {
|
|
|
12
10
|
ProviderContext,
|
|
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
|
* GitHub — OAuth device flow (no local redirect needed, works in SSH/remote
|
|
@@ -32,33 +32,39 @@ const github: Provider = {
|
|
|
32
32
|
docs: "https://docs.github.com/en/rest",
|
|
33
33
|
|
|
34
34
|
async login(ctx: ProviderContext): Promise<AuthHandle> {
|
|
35
|
-
const cached = await tryRevealSecret(TOKEN_SECRET);
|
|
35
|
+
const cached = await tryRevealSecret(TOKEN_SECRET, ctx);
|
|
36
36
|
if (cached) {
|
|
37
37
|
const identity = await fetchUser(cached);
|
|
38
|
-
if (identity)
|
|
38
|
+
if (identity) {
|
|
39
|
+
acceptVerifiedExisting(ctx, TOKEN_SECRET, cached);
|
|
40
|
+
return { token: cached, identity };
|
|
41
|
+
}
|
|
39
42
|
ctx.log({ level: "warn", msg: "Cached GitHub token invalid; re-authenticating." });
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
const clientId = resolveOAuthClientId("github", process.env.GITHUB_STACK_CLIENT_ID);
|
|
43
46
|
if (clientId) {
|
|
44
|
-
const token = await runDeviceFlow(clientId);
|
|
47
|
+
const token = await runDeviceFlow(ctx, clientId);
|
|
45
48
|
const identity = await fetchUser(token);
|
|
46
49
|
if (!identity)
|
|
47
|
-
throw new StackError(
|
|
48
|
-
|
|
50
|
+
throw new StackError(
|
|
51
|
+
"GITHUB_AUTH_INVALID",
|
|
52
|
+
"GitHub accepted the code but rejected the token.",
|
|
53
|
+
);
|
|
54
|
+
await storeProviderSecret(ctx, TOKEN_SECRET, token);
|
|
49
55
|
return { token, identity };
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
// PAT fallback.
|
|
53
59
|
if (!ctx.interactive)
|
|
54
60
|
throw new StackError("GITHUB_AUTH_REQUIRED", "Set GITHUB_STACK_CLIENT_ID or paste a PAT.");
|
|
55
|
-
|
|
56
|
-
"
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
const token = await promptSecret(ctx, {
|
|
62
|
+
message: "Paste your GitHub PAT",
|
|
63
|
+
howTo: "Create a GitHub PAT at https://github.com/settings/personal-access-tokens/new",
|
|
64
|
+
});
|
|
59
65
|
const identity = await fetchUser(token);
|
|
60
66
|
if (!identity) throw new StackError("GITHUB_AUTH_INVALID", "GitHub rejected that token.");
|
|
61
|
-
await
|
|
67
|
+
await storeProviderSecret(ctx, TOKEN_SECRET, token);
|
|
62
68
|
return { token, identity };
|
|
63
69
|
},
|
|
64
70
|
|
|
@@ -72,7 +78,7 @@ const github: Provider = {
|
|
|
72
78
|
return { id: login, displayName: `@${login}` };
|
|
73
79
|
},
|
|
74
80
|
|
|
75
|
-
async materialize(
|
|
81
|
+
async materialize(ctx, _resource, auth): Promise<Materialized> {
|
|
76
82
|
return {
|
|
77
83
|
secrets: { [TOKEN_SECRET]: auth.token },
|
|
78
84
|
mcp: {
|
|
@@ -85,8 +91,8 @@ const github: Provider = {
|
|
|
85
91
|
};
|
|
86
92
|
},
|
|
87
93
|
|
|
88
|
-
async healthcheck(
|
|
89
|
-
const token = await tryRevealSecret(TOKEN_SECRET);
|
|
94
|
+
async healthcheck(ctx, _entry: ServiceEntry): Promise<HealthStatus> {
|
|
95
|
+
const token = await tryRevealSecret(TOKEN_SECRET, ctx);
|
|
90
96
|
if (!token) return { kind: "error", detail: `${TOKEN_SECRET} missing from vault` };
|
|
91
97
|
const start = Date.now();
|
|
92
98
|
const user = await fetchUser(token);
|
|
@@ -123,7 +129,7 @@ async function fetchUser(token: string): Promise<Record<string, string> | undefi
|
|
|
123
129
|
}
|
|
124
130
|
}
|
|
125
131
|
|
|
126
|
-
async function runDeviceFlow(clientId: string): Promise<string> {
|
|
132
|
+
async function runDeviceFlow(ctx: ProviderContext, clientId: string): Promise<string> {
|
|
127
133
|
// 1. Request device code.
|
|
128
134
|
const startRes = await fetch("https://github.com/login/device/code", {
|
|
129
135
|
method: "POST",
|
|
@@ -132,23 +138,21 @@ async function runDeviceFlow(clientId: string): Promise<string> {
|
|
|
132
138
|
});
|
|
133
139
|
if (!startRes.ok)
|
|
134
140
|
throw new StackError("GITHUB_DEVICE_START_FAILED", `device/code ${startRes.status}`);
|
|
135
|
-
const {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
user_code: string;
|
|
144
|
-
verification_uri: string;
|
|
145
|
-
interval: number;
|
|
146
|
-
expires_in: number;
|
|
147
|
-
};
|
|
141
|
+
const { device_code, user_code, verification_uri, interval, expires_in } =
|
|
142
|
+
(await startRes.json()) as {
|
|
143
|
+
device_code: string;
|
|
144
|
+
user_code: string;
|
|
145
|
+
verification_uri: string;
|
|
146
|
+
interval: number;
|
|
147
|
+
expires_in: number;
|
|
148
|
+
};
|
|
148
149
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
150
|
+
// Route through ctx.log so the CLI pauses its spinner — a bare stderr write
|
|
151
|
+
// here is repainted over by the spinner and the user never sees the code.
|
|
152
|
+
ctx.log({
|
|
153
|
+
level: "info",
|
|
154
|
+
msg: `Visit ${verification_uri} and enter this code: ${user_code} (waiting for approval…)`,
|
|
155
|
+
});
|
|
152
156
|
|
|
153
157
|
// 2. Poll for the token.
|
|
154
158
|
const deadline = Date.now() + expires_in * 1000;
|
|
@@ -176,5 +180,3 @@ async function runDeviceFlow(clientId: string): Promise<string> {
|
|
|
176
180
|
}
|
|
177
181
|
throw new StackError("GITHUB_DEVICE_TIMEOUT", "Device flow timed out.");
|
|
178
182
|
}
|
|
179
|
-
|
|
180
|
-
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
import { verifyFetch } from "./_helpers.ts";
|
|
3
|
+
|
|
4
|
+
// Grafana is self-hosted or Grafana Cloud — we store both the token and the
|
|
5
|
+
// base URL. The secretName for `makeApiKeyProvider` is the token slot; the URL
|
|
6
|
+
// is prompted separately in the howTo message and stored via a custom
|
|
7
|
+
// materialize approach. For now, the key that is "verified" is the API token,
|
|
8
|
+
// and the GRAFANA_URL is a second secret the user must add manually or via
|
|
9
|
+
// `stack add grafana` with a follow-up prompt. This matches the pattern used
|
|
10
|
+
// by Sentry (which also stores multiple secrets).
|
|
11
|
+
export default makeApiKeyProvider({
|
|
12
|
+
name: "grafana",
|
|
13
|
+
displayName: "Grafana",
|
|
14
|
+
category: "observability",
|
|
15
|
+
docs: "https://grafana.com/docs/grafana/latest/developers/http_api/",
|
|
16
|
+
secretName: "GRAFANA_API_KEY",
|
|
17
|
+
howTo:
|
|
18
|
+
"Create a service-account token in Grafana → Administration → Service accounts. You will also need your Grafana base URL (e.g. https://myorg.grafana.net).",
|
|
19
|
+
dashboard: "https://grafana.com",
|
|
20
|
+
async verify(key) {
|
|
21
|
+
// Without the URL we can't verify against the actual instance. We do a
|
|
22
|
+
// structural check: Grafana service-account tokens start with "glsa_".
|
|
23
|
+
// Grafana Cloud API tokens also start with "glc_". Accept both shapes.
|
|
24
|
+
if (key.startsWith("glsa_") || key.startsWith("glc_") || key.length >= 32) {
|
|
25
|
+
return { token_type: key.startsWith("glsa_") ? "service_account" : "api_key" };
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
import { verifyFetch } from "./_helpers.ts";
|
|
3
|
+
|
|
4
|
+
export default makeApiKeyProvider({
|
|
5
|
+
name: "hetzner",
|
|
6
|
+
displayName: "Hetzner",
|
|
7
|
+
category: "cloud",
|
|
8
|
+
docs: "https://docs.hetzner.cloud/",
|
|
9
|
+
secretName: "HETZNER_API_TOKEN",
|
|
10
|
+
howTo: "Create an API token in Hetzner Cloud Console → Project → Security → API Tokens.",
|
|
11
|
+
dashboard: "https://console.hetzner.cloud",
|
|
12
|
+
async verify(key) {
|
|
13
|
+
try {
|
|
14
|
+
// GET /v1/locations is a lightweight public-ish endpoint that returns
|
|
15
|
+
// available datacenters — valid for any token, no side effects.
|
|
16
|
+
const res = await verifyFetch("https://api.hetzner.cloud/v1/locations", {
|
|
17
|
+
headers: {
|
|
18
|
+
Authorization: `Bearer ${key}`,
|
|
19
|
+
Accept: "application/json",
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
if (!res.ok) return undefined;
|
|
23
|
+
const body = (await res.json()) as { locations?: Array<{ name: string }> };
|
|
24
|
+
return { locations: String(body.locations?.length ?? 0) };
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
});
|
package/src/providers/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Provider } from "./_base.ts";
|
|
2
1
|
import { ProviderNotFoundError } from "../errors.ts";
|
|
2
|
+
import type { Provider } from "./_base.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Registry of curated providers. Entries are added in Waves 2–3 as each
|
|
@@ -44,6 +44,18 @@ const registry: Record<string, () => Promise<Provider>> = {
|
|
|
44
44
|
postmark: async () => (await import("./postmark.ts")).default,
|
|
45
45
|
clerk: async () => (await import("./clerk.ts")).default,
|
|
46
46
|
aws: async () => (await import("./aws.ts")).default,
|
|
47
|
+
|
|
48
|
+
// Wave 4 — key-only adapters (coming-soon catalog entries shipped as adapters).
|
|
49
|
+
auth0: async () => (await import("./auth0.ts")).default,
|
|
50
|
+
datadog: async () => (await import("./datadog.ts")).default,
|
|
51
|
+
digitalocean: async () => (await import("./digitalocean.ts")).default,
|
|
52
|
+
gcp: async () => (await import("./gcp.ts")).default,
|
|
53
|
+
grafana: async () => (await import("./grafana.ts")).default,
|
|
54
|
+
hetzner: async () => (await import("./hetzner.ts")).default,
|
|
55
|
+
launchdarkly: async () => (await import("./launchdarkly.ts")).default,
|
|
56
|
+
mixpanel: async () => (await import("./mixpanel.ts")).default,
|
|
57
|
+
plausible: async () => (await import("./plausible.ts")).default,
|
|
58
|
+
workos: async () => (await import("./workos.ts")).default,
|
|
47
59
|
};
|
|
48
60
|
|
|
49
61
|
export const providers = registry;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
import { verifyFetch } from "./_helpers.ts";
|
|
3
|
+
|
|
4
|
+
export default makeApiKeyProvider({
|
|
5
|
+
name: "launchdarkly",
|
|
6
|
+
displayName: "LaunchDarkly",
|
|
7
|
+
category: "featureflags",
|
|
8
|
+
docs: "https://apidocs.launchdarkly.com/",
|
|
9
|
+
secretName: "LAUNCHDARKLY_API_TOKEN",
|
|
10
|
+
howTo:
|
|
11
|
+
"Create an API access token at LaunchDarkly → Account settings → Authorization. You will also need your SDK key from LaunchDarkly → Environments.",
|
|
12
|
+
dashboard: "https://app.launchdarkly.com",
|
|
13
|
+
async verify(key) {
|
|
14
|
+
try {
|
|
15
|
+
// GET /api/v2/caller-identity is a lightweight endpoint that returns
|
|
16
|
+
// metadata about the token without requiring any specific permission scope.
|
|
17
|
+
const res = await verifyFetch("https://app.launchdarkly.com/api/v2/caller-identity", {
|
|
18
|
+
headers: {
|
|
19
|
+
Authorization: key,
|
|
20
|
+
Accept: "application/json",
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok) return undefined;
|
|
24
|
+
const body = (await res.json()) as { accountId?: string; tokenType?: string };
|
|
25
|
+
return {
|
|
26
|
+
accountId: body.accountId ?? "",
|
|
27
|
+
tokenType: body.tokenType ?? "api",
|
|
28
|
+
};
|
|
29
|
+
} catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
import { verifyFetch } from "./_helpers.ts";
|
|
3
|
+
|
|
4
|
+
export default makeApiKeyProvider({
|
|
5
|
+
name: "mixpanel",
|
|
6
|
+
displayName: "Mixpanel",
|
|
7
|
+
category: "analytics",
|
|
8
|
+
docs: "https://developer.mixpanel.com/reference/overview",
|
|
9
|
+
secretName: "MIXPANEL_PROJECT_TOKEN",
|
|
10
|
+
howTo:
|
|
11
|
+
"Find your project token in Mixpanel → Settings → Project Settings. The API secret is on the same page.",
|
|
12
|
+
dashboard: "https://mixpanel.com",
|
|
13
|
+
async verify(key) {
|
|
14
|
+
// Mixpanel's Ingestion API accepts the project token directly. We send a
|
|
15
|
+
// no-op import request to confirm the token is valid without writing data.
|
|
16
|
+
// POST /import with an empty events array returns 200 on a valid token.
|
|
17
|
+
try {
|
|
18
|
+
const res = await verifyFetch("https://api.mixpanel.com/import?strict=1", {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: {
|
|
21
|
+
"Content-Type": "application/json",
|
|
22
|
+
Accept: "application/json",
|
|
23
|
+
Authorization: `Basic ${Buffer.from(`${key}:`).toString("base64")}`,
|
|
24
|
+
},
|
|
25
|
+
body: JSON.stringify([]),
|
|
26
|
+
});
|
|
27
|
+
// 200 = valid token (empty import is fine), 401/403 = invalid
|
|
28
|
+
if (res.status === 200 || res.status === 400) {
|
|
29
|
+
// 400 means "no events" which still confirms the token is valid
|
|
30
|
+
return { project_token: `${key.slice(0, 6)}…` };
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
});
|
package/src/providers/neon.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
|
* Neon — serverless Postgres. v1 uses a Neon API key (users create one at
|
|
@@ -33,20 +33,24 @@ const neon: Provider = {
|
|
|
33
33
|
docs: "https://neon.tech/docs/reference/api-reference",
|
|
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 Neon key invalid; re-entering." });
|
|
41
44
|
}
|
|
42
|
-
if (!ctx.interactive)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
if (!ctx.interactive)
|
|
46
|
+
throw new StackError("NEON_AUTH_REQUIRED", "No valid Neon API key in vault.");
|
|
47
|
+
const token = await promptSecret(ctx, {
|
|
48
|
+
message: "Paste your Neon API key",
|
|
49
|
+
howTo: "Create a Neon API key at https://console.neon.tech/app/settings/api-keys",
|
|
50
|
+
});
|
|
47
51
|
const identity = await fetchIdentity(token);
|
|
48
52
|
if (!identity) throw new StackError("NEON_AUTH_INVALID", "Neon rejected that API key.");
|
|
49
|
-
await
|
|
53
|
+
await storeProviderSecret(ctx, TOKEN_SECRET, token);
|
|
50
54
|
return { token, identity };
|
|
51
55
|
},
|
|
52
56
|
|
|
@@ -54,7 +58,10 @@ const neon: Provider = {
|
|
|
54
58
|
if (opts.existingResourceId) {
|
|
55
59
|
const project = await fetchProject(auth.token, opts.existingResourceId);
|
|
56
60
|
if (!project)
|
|
57
|
-
throw new StackError(
|
|
61
|
+
throw new StackError(
|
|
62
|
+
"NEON_PROJECT_NOT_FOUND",
|
|
63
|
+
`No Neon project ${opts.existingResourceId}.`,
|
|
64
|
+
);
|
|
58
65
|
return toResource(project);
|
|
59
66
|
}
|
|
60
67
|
const name = `stack-${Date.now().toString(36)}`;
|
|
@@ -69,19 +76,16 @@ const neon: Provider = {
|
|
|
69
76
|
"NEON_CREATE_FAILED",
|
|
70
77
|
`Neon project creation failed (${res.status}): ${await res.text()}`,
|
|
71
78
|
);
|
|
72
|
-
const body = (await res.json()) as {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
await addSecret(`NEON_DATABASE_URL_${body.project.id}`, uri);
|
|
79
|
-
await addSecret("DATABASE_URL", uri);
|
|
80
|
-
}
|
|
79
|
+
const body = (await res.json()) as {
|
|
80
|
+
project: NeonProject;
|
|
81
|
+
connection_uris?: Array<{ connection_uri: string }>;
|
|
82
|
+
};
|
|
83
|
+
// Return the created resource before any vault write. materialize() obtains
|
|
84
|
+
// its fresh URI from the API; the pipeline owns persistence and rollback.
|
|
81
85
|
return toResource(body.project);
|
|
82
86
|
},
|
|
83
87
|
|
|
84
|
-
async materialize(
|
|
88
|
+
async materialize(ctx, resource, auth): Promise<Materialized> {
|
|
85
89
|
// Always fetch the connection URI from the API — the vault's DATABASE_URL
|
|
86
90
|
// is a generic key that may belong to another project or another provider.
|
|
87
91
|
// Don't trust it as a cache here.
|
|
@@ -100,8 +104,8 @@ const neon: Provider = {
|
|
|
100
104
|
};
|
|
101
105
|
},
|
|
102
106
|
|
|
103
|
-
async healthcheck(
|
|
104
|
-
const token = await tryRevealSecret(TOKEN_SECRET);
|
|
107
|
+
async healthcheck(ctx, entry: ServiceEntry): Promise<HealthStatus> {
|
|
108
|
+
const token = await tryRevealSecret(TOKEN_SECRET, ctx);
|
|
105
109
|
if (!token) return { kind: "error", detail: `${TOKEN_SECRET} missing from vault` };
|
|
106
110
|
if (!entry.resource_id) return { kind: "warn", detail: "no resource_id" };
|
|
107
111
|
const start = Date.now();
|
|
@@ -115,6 +119,28 @@ const neon: Provider = {
|
|
|
115
119
|
? `https://console.neon.tech/app/projects/${entry.resource_id}`
|
|
116
120
|
: "https://console.neon.tech";
|
|
117
121
|
},
|
|
122
|
+
|
|
123
|
+
async deprovision(_ctx: ProviderContext, auth: AuthHandle, resourceId: string): Promise<void> {
|
|
124
|
+
let res: Response;
|
|
125
|
+
try {
|
|
126
|
+
res = await fetch(`${API}/projects/${resourceId}`, {
|
|
127
|
+
method: "DELETE",
|
|
128
|
+
headers: authHeaders(auth.token),
|
|
129
|
+
});
|
|
130
|
+
} catch {
|
|
131
|
+
// Transport details can contain credentials; absence is unconfirmed.
|
|
132
|
+
throw new StackError(
|
|
133
|
+
"NEON_DELETE_UNCONFIRMED",
|
|
134
|
+
"Neon resource deletion could not be confirmed.",
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (res.status === 404 || res.ok) return;
|
|
138
|
+
// Keep the bounded status, never the remote response body or secret values.
|
|
139
|
+
throw new StackError(
|
|
140
|
+
"NEON_DELETE_FAILED",
|
|
141
|
+
`Neon resource deletion failed (HTTP ${res.status}).`,
|
|
142
|
+
);
|
|
143
|
+
},
|
|
118
144
|
};
|
|
119
145
|
|
|
120
146
|
export default neon;
|
|
@@ -125,7 +151,7 @@ interface NeonProject {
|
|
|
125
151
|
region_id: string;
|
|
126
152
|
}
|
|
127
153
|
|
|
128
|
-
function authHeaders(token: string):
|
|
154
|
+
function authHeaders(token: string): NonNullable<RequestInit["headers"]> {
|
|
129
155
|
return {
|
|
130
156
|
Authorization: `Bearer ${token}`,
|
|
131
157
|
"content-type": "application/json",
|
|
@@ -171,5 +197,3 @@ async function fetchConnectionUri(token: string, projectId: string): Promise<str
|
|
|
171
197
|
function toResource(project: NeonProject): Resource {
|
|
172
198
|
return { id: project.id, displayName: project.name, region: project.region_id };
|
|
173
199
|
}
|
|
174
|
-
|
|
175
|
-
|
package/src/providers/openai.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { openaiBaseUrl } from "../base-urls.ts";
|
|
1
2
|
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
3
|
import { verifyFetch } from "./_helpers.ts";
|
|
3
4
|
|
|
@@ -11,7 +12,7 @@ export default makeApiKeyProvider({
|
|
|
11
12
|
dashboard: "https://platform.openai.com",
|
|
12
13
|
async verify(key) {
|
|
13
14
|
try {
|
|
14
|
-
const res = await verifyFetch(
|
|
15
|
+
const res = await verifyFetch(`${openaiBaseUrl()}/models`, {
|
|
15
16
|
headers: { Authorization: `Bearer ${key}` },
|
|
16
17
|
});
|
|
17
18
|
if (!res.ok) return undefined;
|