@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.
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/package.json +57 -0
- package/src/__tests__/_harness.ts +146 -0
- package/src/__tests__/api-key-providers.test.ts +237 -0
- package/src/__tests__/catalog-index.test.ts +103 -0
- package/src/__tests__/circuit-breaker.test.ts +112 -0
- package/src/__tests__/clone-url-validation.test.ts +47 -0
- package/src/__tests__/cloudflare-provider.test.ts +74 -0
- package/src/__tests__/completion-ci.test.ts +62 -0
- package/src/__tests__/config-split.test.ts +138 -0
- package/src/__tests__/config.test.ts +66 -0
- package/src/__tests__/deps.test.ts +58 -0
- package/src/__tests__/detect-source.test.ts +147 -0
- package/src/__tests__/detect.test.ts +67 -0
- package/src/__tests__/github-provider.test.ts +65 -0
- package/src/__tests__/helpers.test.ts +38 -0
- package/src/__tests__/http.test.ts +235 -0
- package/src/__tests__/import-integration.test.ts +87 -0
- package/src/__tests__/inference.test.ts +326 -0
- package/src/__tests__/neon-provider.test.ts +79 -0
- package/src/__tests__/openai-provider.test.ts +54 -0
- package/src/__tests__/phantom-harness.test.ts +44 -0
- package/src/__tests__/phantom-wire.test.ts +85 -0
- package/src/__tests__/pipeline-integration.test.ts +95 -0
- package/src/__tests__/pipeline-recovery.test.ts +106 -0
- package/src/__tests__/pipeline.test.ts +92 -0
- package/src/__tests__/recipe.test.ts +164 -0
- package/src/__tests__/recommend-apply-e2e.test.ts +222 -0
- package/src/__tests__/registry.test.ts +78 -0
- package/src/__tests__/secret-redaction.test.ts +170 -0
- package/src/__tests__/security.test.ts +80 -0
- package/src/__tests__/supabase-provider.test.ts +108 -0
- package/src/__tests__/templates.test.ts +26 -0
- package/src/__tests__/turso-provider.test.ts +71 -0
- package/src/ai/catalog-index.ts +256 -0
- package/src/ai/circuit-breaker.ts +116 -0
- package/src/ai/cost-tracker.ts +103 -0
- package/src/ai/inference.ts +470 -0
- package/src/ai/phantom-wire.ts +184 -0
- package/src/ai/recipe.ts +178 -0
- package/src/catalog.ts +430 -0
- package/src/config.ts +293 -0
- package/src/detect-source.ts +344 -0
- package/src/detect.ts +207 -0
- package/src/env.ts +35 -0
- package/src/errors.ts +29 -0
- package/src/http.ts +212 -0
- package/src/index.ts +61 -0
- package/src/mcp-writer.ts +50 -0
- package/src/oauth.ts +184 -0
- package/src/phantom.ts +156 -0
- package/src/pipeline.ts +131 -0
- package/src/providers/_api-key.ts +122 -0
- package/src/providers/_base.ts +96 -0
- package/src/providers/_helpers.ts +81 -0
- package/src/providers/anthropic.ts +27 -0
- package/src/providers/aws.ts +198 -0
- package/src/providers/braintrust.ts +33 -0
- package/src/providers/clerk.ts +23 -0
- package/src/providers/cloudflare.ts +146 -0
- package/src/providers/convex.ts +28 -0
- package/src/providers/deepseek.ts +24 -0
- package/src/providers/firebase.ts +35 -0
- package/src/providers/fly.ts +24 -0
- package/src/providers/github.ts +180 -0
- package/src/providers/index.ts +59 -0
- package/src/providers/linear.ts +46 -0
- package/src/providers/mailgun.ts +26 -0
- package/src/providers/modal.ts +27 -0
- package/src/providers/neon.ts +175 -0
- package/src/providers/openai.ts +24 -0
- package/src/providers/posthog.ts +30 -0
- package/src/providers/postmark.ts +27 -0
- package/src/providers/railway.ts +35 -0
- package/src/providers/render.ts +31 -0
- package/src/providers/replicate.ts +30 -0
- package/src/providers/resend.ts +24 -0
- package/src/providers/sendgrid.ts +24 -0
- package/src/providers/sentry.ts +183 -0
- package/src/providers/stripe.ts +33 -0
- package/src/providers/supabase.ts +309 -0
- package/src/providers/turso.ts +197 -0
- package/src/providers/upstash.ts +32 -0
- package/src/providers/vercel.ts +134 -0
- package/src/providers/xai.ts +24 -0
- package/src/registry.ts +114 -0
- package/src/templates.ts +39 -0
|
@@ -0,0 +1,197 @@
|
|
|
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
|
+
* Turso — LibSQL-backed edge database. v1 uses a user-created Platform API
|
|
17
|
+
* token (https://app.turso.tech/settings/api-tokens). Provision creates a new
|
|
18
|
+
* database; materialize surfaces the URL + an auto-issued auth token.
|
|
19
|
+
*
|
|
20
|
+
* API reference: https://docs.turso.tech/api-reference/introduction
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const API = "https://api.turso.tech/v1";
|
|
24
|
+
const PLATFORM_TOKEN_SECRET = "TURSO_PLATFORM_TOKEN";
|
|
25
|
+
|
|
26
|
+
const turso: Provider = {
|
|
27
|
+
name: "turso",
|
|
28
|
+
displayName: "Turso",
|
|
29
|
+
category: "database",
|
|
30
|
+
authKind: "pat",
|
|
31
|
+
docs: "https://docs.turso.tech",
|
|
32
|
+
|
|
33
|
+
async login(ctx: ProviderContext): Promise<AuthHandle> {
|
|
34
|
+
const cached = await tryRevealSecret(PLATFORM_TOKEN_SECRET);
|
|
35
|
+
if (cached) {
|
|
36
|
+
const identity = await fetchIdentity(cached);
|
|
37
|
+
if (identity) return { token: cached, identity };
|
|
38
|
+
ctx.log({ level: "warn", msg: "Cached Turso token invalid." });
|
|
39
|
+
}
|
|
40
|
+
if (!ctx.interactive) throw new StackError("TURSO_AUTH_REQUIRED", "No valid Turso token in vault.");
|
|
41
|
+
process.stderr.write(
|
|
42
|
+
"\n Create a Platform API token at https://app.turso.tech/settings/api-tokens\n Paste it here: ",
|
|
43
|
+
);
|
|
44
|
+
const token = (await readLine()).trim();
|
|
45
|
+
const identity = await fetchIdentity(token);
|
|
46
|
+
if (!identity) throw new StackError("TURSO_AUTH_INVALID", "Turso rejected that token.");
|
|
47
|
+
await addSecret(PLATFORM_TOKEN_SECRET, token);
|
|
48
|
+
return { token, identity };
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
async provision(ctx, auth, opts: ProvisionOpts): Promise<Resource> {
|
|
52
|
+
const orgs = await fetchOrganizations(auth.token);
|
|
53
|
+
if (orgs.length === 0) throw new StackError("TURSO_NO_ORGS", "Turso returned no organizations.");
|
|
54
|
+
const orgSlug = (opts.hints?.orgSlug as string | undefined) ?? orgs[0].slug;
|
|
55
|
+
if (opts.existingResourceId) {
|
|
56
|
+
const [existingOrg, dbName] = opts.existingResourceId.split("/");
|
|
57
|
+
return {
|
|
58
|
+
id: opts.existingResourceId,
|
|
59
|
+
displayName: dbName,
|
|
60
|
+
meta: { org_slug: existingOrg, db_name: dbName },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const dbName = `stack-${Date.now().toString(36)}`;
|
|
64
|
+
const group = (opts.hints?.group as string | undefined) ?? "default";
|
|
65
|
+
const res = await fetch(`${API}/organizations/${orgSlug}/databases`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: {
|
|
68
|
+
Authorization: `Bearer ${auth.token}`,
|
|
69
|
+
"content-type": "application/json",
|
|
70
|
+
},
|
|
71
|
+
body: JSON.stringify({ name: dbName, group }),
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok)
|
|
74
|
+
throw new StackError(
|
|
75
|
+
"TURSO_CREATE_FAILED",
|
|
76
|
+
`Turso DB creation failed (${res.status}): ${await res.text()}`,
|
|
77
|
+
);
|
|
78
|
+
const body = (await res.json()) as { database: { Name: string; Hostname: string } };
|
|
79
|
+
ctx.log({ level: "info", msg: `Created Turso DB ${body.database.Name}.` });
|
|
80
|
+
return {
|
|
81
|
+
id: `${orgSlug}/${body.database.Name}`,
|
|
82
|
+
displayName: body.database.Name,
|
|
83
|
+
meta: {
|
|
84
|
+
org_slug: orgSlug,
|
|
85
|
+
db_name: body.database.Name,
|
|
86
|
+
hostname: body.database.Hostname,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
async materialize(_ctx, resource, auth): Promise<Materialized> {
|
|
92
|
+
const [orgSlug, dbName] = resource.id.split("/");
|
|
93
|
+
const dbToken = await mintDbToken(auth.token, orgSlug, dbName);
|
|
94
|
+
const hostname =
|
|
95
|
+
(resource.meta?.hostname as string | undefined) ?? `${dbName}-${orgSlug}.turso.io`;
|
|
96
|
+
const libsqlUrl = `libsql://${hostname}`;
|
|
97
|
+
return {
|
|
98
|
+
secrets: {
|
|
99
|
+
TURSO_DATABASE_URL: libsqlUrl,
|
|
100
|
+
TURSO_AUTH_TOKEN: dbToken,
|
|
101
|
+
TURSO_PLATFORM_TOKEN: auth.token,
|
|
102
|
+
},
|
|
103
|
+
urls: { dashboard: `https://app.turso.tech/${orgSlug}/databases/${dbName}` },
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async healthcheck(_ctx, entry: ServiceEntry): Promise<HealthStatus> {
|
|
108
|
+
const token = await tryRevealSecret(PLATFORM_TOKEN_SECRET);
|
|
109
|
+
if (!token) return { kind: "error", detail: `${PLATFORM_TOKEN_SECRET} missing` };
|
|
110
|
+
if (!entry.resource_id) return { kind: "warn", detail: "no resource_id" };
|
|
111
|
+
const [orgSlug, dbName] = entry.resource_id.split("/");
|
|
112
|
+
const start = Date.now();
|
|
113
|
+
// Idempotent GET — healthcheck loops on this.
|
|
114
|
+
const res = await fetchWithRetry(`${API}/organizations/${orgSlug}/databases/${dbName}`, {
|
|
115
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
116
|
+
});
|
|
117
|
+
const latencyMs = Date.now() - start;
|
|
118
|
+
return res.ok ? { kind: "ok", latencyMs } : { kind: "error", detail: `HTTP ${res.status}` };
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
dashboardUrl(entry: ServiceEntry): string {
|
|
122
|
+
if (!entry.resource_id) return "https://app.turso.tech";
|
|
123
|
+
const [orgSlug, dbName] = entry.resource_id.split("/");
|
|
124
|
+
return `https://app.turso.tech/${orgSlug}/databases/${dbName}`;
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export default turso;
|
|
129
|
+
|
|
130
|
+
async function fetchIdentity(token: string): Promise<Record<string, string> | undefined> {
|
|
131
|
+
try {
|
|
132
|
+
// Idempotent GET — retry transient failures during token validation.
|
|
133
|
+
const res = await fetchWithRetry(`${API}/auth/validate`, {
|
|
134
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
135
|
+
});
|
|
136
|
+
if (!res.ok) return undefined;
|
|
137
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
138
|
+
const out: Record<string, string> = {};
|
|
139
|
+
for (const [k, v] of Object.entries(body)) if (typeof v === "string") out[k] = v;
|
|
140
|
+
return out;
|
|
141
|
+
} catch {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function fetchOrganizations(token: string): Promise<Array<{ slug: string; name: string }>> {
|
|
147
|
+
// Idempotent GET — retry transient 429/5xx.
|
|
148
|
+
const res = await fetchWithRetry(`${API}/organizations`, {
|
|
149
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
150
|
+
});
|
|
151
|
+
if (!res.ok) return [];
|
|
152
|
+
return (await res.json()) as Array<{ slug: string; name: string }>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function mintDbToken(
|
|
156
|
+
platformToken: string,
|
|
157
|
+
orgSlug: string,
|
|
158
|
+
dbName: string,
|
|
159
|
+
): Promise<string> {
|
|
160
|
+
const res = await fetch(`${API}/organizations/${orgSlug}/databases/${dbName}/auth/tokens`, {
|
|
161
|
+
method: "POST",
|
|
162
|
+
headers: { Authorization: `Bearer ${platformToken}`, "content-type": "application/json" },
|
|
163
|
+
body: JSON.stringify({ expiration: "never", authorization: "full-access" }),
|
|
164
|
+
});
|
|
165
|
+
if (!res.ok)
|
|
166
|
+
throw new StackError(
|
|
167
|
+
"TURSO_TOKEN_FAILED",
|
|
168
|
+
`Turso token mint failed (${res.status}): ${await res.text()}`,
|
|
169
|
+
);
|
|
170
|
+
const body = (await res.json()) as { jwt: string };
|
|
171
|
+
return body.jwt;
|
|
172
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
import { verifyFetch } from "./_helpers.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Upstash uses HTTP Basic auth with email + Management API key. For v1 we
|
|
6
|
+
* accept a single pasted string in the form "email:token" which becomes the
|
|
7
|
+
* base64 Authorization header. Thicker provisioning (create Redis / Kafka /
|
|
8
|
+
* QStash) lands in a future wave.
|
|
9
|
+
*/
|
|
10
|
+
export default makeApiKeyProvider({
|
|
11
|
+
name: "upstash",
|
|
12
|
+
displayName: "Upstash",
|
|
13
|
+
category: "database",
|
|
14
|
+
docs: "https://upstash.com/docs/devops/developer-api",
|
|
15
|
+
secretName: "UPSTASH_MANAGEMENT_TOKEN",
|
|
16
|
+
howTo:
|
|
17
|
+
"Grab a Management API key at https://console.upstash.com/account/api; paste as email:token",
|
|
18
|
+
dashboard: "https://console.upstash.com",
|
|
19
|
+
async verify(key) {
|
|
20
|
+
try {
|
|
21
|
+
const basic = Buffer.from(key).toString("base64");
|
|
22
|
+
const res = await verifyFetch("https://api.upstash.com/v2/redis/databases", {
|
|
23
|
+
headers: { Authorization: `Basic ${basic}` },
|
|
24
|
+
});
|
|
25
|
+
if (!res.ok) return undefined;
|
|
26
|
+
const body = (await res.json()) as unknown[];
|
|
27
|
+
return { databases: String(Array.isArray(body) ? body.length : 0) };
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { ServiceEntry } from "../config.ts";
|
|
2
|
+
import { StackError } from "../errors.ts";
|
|
3
|
+
import { addSecret } from "../phantom.ts";
|
|
4
|
+
import { fetchWithRetry } from "../http.ts";
|
|
5
|
+
import { readLine, tryRevealSecret } from "./_helpers.ts";
|
|
6
|
+
import type {
|
|
7
|
+
AuthHandle,
|
|
8
|
+
HealthStatus,
|
|
9
|
+
Materialized,
|
|
10
|
+
Provider,
|
|
11
|
+
ProviderContext,
|
|
12
|
+
ProvisionOpts,
|
|
13
|
+
Resource,
|
|
14
|
+
} from "./_base.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Vercel provider.
|
|
18
|
+
*
|
|
19
|
+
* v1 takes a personal access token (users create one at
|
|
20
|
+
* https://vercel.com/account/tokens). Provision either creates a new project
|
|
21
|
+
* or attaches to an existing one by id. Materialize stores the token + links
|
|
22
|
+
* the project for future `stack sync --platform vercel` calls.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const API = "https://api.vercel.com";
|
|
26
|
+
const TOKEN_SECRET = "VERCEL_TOKEN";
|
|
27
|
+
|
|
28
|
+
const vercel: Provider = {
|
|
29
|
+
name: "vercel",
|
|
30
|
+
displayName: "Vercel",
|
|
31
|
+
category: "deploy",
|
|
32
|
+
authKind: "pat",
|
|
33
|
+
docs: "https://vercel.com/docs/rest-api",
|
|
34
|
+
|
|
35
|
+
async login(ctx: ProviderContext): Promise<AuthHandle> {
|
|
36
|
+
const cached = await tryRevealSecret(TOKEN_SECRET);
|
|
37
|
+
if (cached) {
|
|
38
|
+
const identity = await fetchUser(cached);
|
|
39
|
+
if (identity) return { token: cached, identity };
|
|
40
|
+
ctx.log({ level: "warn", msg: "Cached Vercel token invalid; re-entering." });
|
|
41
|
+
}
|
|
42
|
+
if (!ctx.interactive)
|
|
43
|
+
throw new StackError("VERCEL_AUTH_REQUIRED", "No valid Vercel token in vault.");
|
|
44
|
+
process.stderr.write(
|
|
45
|
+
"\n Create a Vercel token at https://vercel.com/account/tokens\n Paste it here: ",
|
|
46
|
+
);
|
|
47
|
+
const token = (await readLine()).trim();
|
|
48
|
+
const identity = await fetchUser(token);
|
|
49
|
+
if (!identity) throw new StackError("VERCEL_AUTH_INVALID", "Vercel rejected that token.");
|
|
50
|
+
await addSecret(TOKEN_SECRET, token);
|
|
51
|
+
return { token, identity };
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
async provision(ctx, auth, opts: ProvisionOpts): Promise<Resource> {
|
|
55
|
+
if (opts.existingResourceId) {
|
|
56
|
+
const project = await fetchProject(auth.token, opts.existingResourceId);
|
|
57
|
+
if (!project)
|
|
58
|
+
throw new StackError("VERCEL_PROJECT_NOT_FOUND", `No Vercel project ${opts.existingResourceId}.`);
|
|
59
|
+
return { id: project.id, displayName: project.name };
|
|
60
|
+
}
|
|
61
|
+
const name = `stack-${Date.now().toString(36)}`;
|
|
62
|
+
const res = await fetch(`${API}/v11/projects`, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: authHeaders(auth.token),
|
|
65
|
+
body: JSON.stringify({ name, framework: null }),
|
|
66
|
+
});
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
throw new StackError(
|
|
69
|
+
"VERCEL_CREATE_FAILED",
|
|
70
|
+
`Vercel project creation failed (${res.status}): ${await res.text()}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const project = (await res.json()) as { id: string; name: string };
|
|
74
|
+
ctx.log({ level: "info", msg: `Vercel project ${project.name} created.` });
|
|
75
|
+
return { id: project.id, displayName: project.name };
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
async materialize(_ctx, resource, auth): Promise<Materialized> {
|
|
79
|
+
return {
|
|
80
|
+
secrets: { [TOKEN_SECRET]: auth.token },
|
|
81
|
+
urls: {
|
|
82
|
+
project: `https://vercel.com/dashboard/${resource.id}`,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
async healthcheck(_ctx, entry: ServiceEntry): Promise<HealthStatus> {
|
|
88
|
+
const token = await tryRevealSecret(TOKEN_SECRET);
|
|
89
|
+
if (!token) return { kind: "error", detail: `${TOKEN_SECRET} missing from vault` };
|
|
90
|
+
if (!entry.resource_id) return { kind: "warn", detail: "no resource_id" };
|
|
91
|
+
const start = Date.now();
|
|
92
|
+
const project = await fetchProject(token, entry.resource_id);
|
|
93
|
+
const latencyMs = Date.now() - start;
|
|
94
|
+
return project ? { kind: "ok", latencyMs } : { kind: "error", detail: "project not found" };
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
dashboardUrl(entry: ServiceEntry): string {
|
|
98
|
+
return entry.resource_id
|
|
99
|
+
? `https://vercel.com/dashboard/${entry.resource_id}`
|
|
100
|
+
: "https://vercel.com/dashboard";
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export default vercel;
|
|
105
|
+
|
|
106
|
+
function authHeaders(token: string): HeadersInit {
|
|
107
|
+
return { Authorization: `Bearer ${token}`, "content-type": "application/json" };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function fetchUser(token: string): Promise<Record<string, string> | undefined> {
|
|
111
|
+
try {
|
|
112
|
+
// Idempotent GET — retry transient failures during login validation + healthcheck.
|
|
113
|
+
const res = await fetchWithRetry(`${API}/v2/user`, { headers: authHeaders(token) });
|
|
114
|
+
if (!res.ok) return undefined;
|
|
115
|
+
const body = (await res.json()) as { user?: { uid?: string; email?: string; username?: string } };
|
|
116
|
+
if (!body.user) return undefined;
|
|
117
|
+
const out: Record<string, string> = {};
|
|
118
|
+
for (const [k, v] of Object.entries(body.user))
|
|
119
|
+
if (typeof v === "string") out[k] = v;
|
|
120
|
+
return out;
|
|
121
|
+
} catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function fetchProject(token: string, id: string): Promise<{ id: string; name: string } | undefined> {
|
|
127
|
+
// Idempotent GET — healthcheck reads this on every `stack status` call.
|
|
128
|
+
const res = await fetchWithRetry(`${API}/v9/projects/${id}`, { headers: authHeaders(token) });
|
|
129
|
+
if (res.status === 404) return undefined;
|
|
130
|
+
if (!res.ok) return undefined;
|
|
131
|
+
return (await res.json()) as { id: string; name: string };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { makeApiKeyProvider } from "./_api-key.ts";
|
|
2
|
+
import { verifyFetch } from "./_helpers.ts";
|
|
3
|
+
|
|
4
|
+
export default makeApiKeyProvider({
|
|
5
|
+
name: "xai",
|
|
6
|
+
displayName: "xAI",
|
|
7
|
+
category: "ai",
|
|
8
|
+
docs: "https://docs.x.ai/api",
|
|
9
|
+
secretName: "XAI_API_KEY",
|
|
10
|
+
howTo: "Create a key at https://console.x.ai",
|
|
11
|
+
dashboard: "https://console.x.ai",
|
|
12
|
+
async verify(key) {
|
|
13
|
+
try {
|
|
14
|
+
const res = await verifyFetch("https://api.x.ai/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
|
+
});
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, resolve } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
CONFIG_FILENAME,
|
|
7
|
+
hasConfig,
|
|
8
|
+
readConfig,
|
|
9
|
+
resolveConfigPath,
|
|
10
|
+
} from "./config.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Machine-global registry of every Stack-enabled project this user has
|
|
14
|
+
* touched. Lives at ~/.stack/projects.json and is the backbone of cross-project
|
|
15
|
+
* commands like `stack projects list` and `stack doctor --all`.
|
|
16
|
+
*
|
|
17
|
+
* Auto-maintained: every write of .stack.toml (via writeConfig) calls
|
|
18
|
+
* `registerProject(cwd)`. Paths that no longer exist get pruned on next read.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface RegistryEntry {
|
|
22
|
+
/** Absolute canonical path to the project root (where .stack.toml lives). */
|
|
23
|
+
path: string;
|
|
24
|
+
/** Derived from the directory basename. */
|
|
25
|
+
name: string;
|
|
26
|
+
/** project_id from .stack.local.toml. */
|
|
27
|
+
project_id: string;
|
|
28
|
+
/** Template (if any) the project was seeded from. */
|
|
29
|
+
template?: string;
|
|
30
|
+
/** Service names in this project. */
|
|
31
|
+
services: string[];
|
|
32
|
+
/** Last time Stack touched this entry, ISO timestamp. */
|
|
33
|
+
last_seen: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface RegistryFile {
|
|
37
|
+
version: "1";
|
|
38
|
+
projects: RegistryEntry[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function registryPath(): string {
|
|
42
|
+
const base = process.env.STACK_REGISTRY_DIR ?? resolve(homedir(), ".stack");
|
|
43
|
+
return resolve(base, "projects.json");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readRegistryRaw(): Promise<RegistryFile> {
|
|
47
|
+
const path = registryPath();
|
|
48
|
+
if (!existsSync(path)) return { version: "1", projects: [] };
|
|
49
|
+
try {
|
|
50
|
+
const raw = await readFile(path, "utf-8");
|
|
51
|
+
const parsed = JSON.parse(raw) as Partial<RegistryFile>;
|
|
52
|
+
return { version: "1", projects: parsed.projects ?? [] };
|
|
53
|
+
} catch {
|
|
54
|
+
return { version: "1", projects: [] };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function writeRegistryRaw(file: RegistryFile): Promise<void> {
|
|
59
|
+
const path = registryPath();
|
|
60
|
+
const dir = resolve(path, "..");
|
|
61
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
62
|
+
await writeFile(path, `${JSON.stringify(file, null, 2)}\n`, "utf-8");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function registerProject(cwd: string): Promise<RegistryEntry | undefined> {
|
|
66
|
+
const path = resolve(cwd);
|
|
67
|
+
if (!hasConfig(path)) return undefined;
|
|
68
|
+
const config = await readConfig(path);
|
|
69
|
+
const entry: RegistryEntry = {
|
|
70
|
+
path,
|
|
71
|
+
name: basename(path),
|
|
72
|
+
project_id: config.stack.project_id,
|
|
73
|
+
template: config.stack.template,
|
|
74
|
+
services: Object.keys(config.services),
|
|
75
|
+
last_seen: new Date().toISOString(),
|
|
76
|
+
};
|
|
77
|
+
const file = await readRegistryRaw();
|
|
78
|
+
const filtered = file.projects.filter((p) => p.path !== path);
|
|
79
|
+
filtered.push(entry);
|
|
80
|
+
filtered.sort((a, b) => a.name.localeCompare(b.name));
|
|
81
|
+
await writeRegistryRaw({ version: "1", projects: filtered });
|
|
82
|
+
return entry;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function unregisterProject(path: string): Promise<void> {
|
|
86
|
+
const resolved = resolve(path);
|
|
87
|
+
const file = await readRegistryRaw();
|
|
88
|
+
await writeRegistryRaw({
|
|
89
|
+
version: "1",
|
|
90
|
+
projects: file.projects.filter((p) => p.path !== resolved),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function listProjects(opts: { prune?: boolean } = {}): Promise<RegistryEntry[]> {
|
|
95
|
+
const file = await readRegistryRaw();
|
|
96
|
+
if (opts.prune === false) return file.projects;
|
|
97
|
+
// Drop entries whose .stack.toml has been deleted.
|
|
98
|
+
const alive = file.projects.filter((p) => existsSync(resolveConfigPath(p.path)));
|
|
99
|
+
if (alive.length !== file.projects.length) {
|
|
100
|
+
await writeRegistryRaw({ version: "1", projects: alive });
|
|
101
|
+
}
|
|
102
|
+
return alive;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function findProjectByName(name: string): Promise<RegistryEntry | undefined> {
|
|
106
|
+
const projects = await listProjects();
|
|
107
|
+
const exact = projects.find((p) => p.name === name);
|
|
108
|
+
if (exact) return exact;
|
|
109
|
+
return projects.find((p) => p.name.toLowerCase() === name.toLowerCase());
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function configFilename(): string {
|
|
113
|
+
return CONFIG_FILENAME;
|
|
114
|
+
}
|
package/src/templates.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { parse as parseToml } from "smol-toml";
|
|
6
|
+
import type { StackConfig } from "./config.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Starter `.stack.toml` templates live in `templates/<name>/stack.toml` at the
|
|
10
|
+
* monorepo root, and are also published with the CLI so `stack init --template`
|
|
11
|
+
* works globally after install.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export function resolveTemplatesDir(): string | undefined {
|
|
15
|
+
const candidates = [
|
|
16
|
+
resolve(join(process.cwd(), "templates")),
|
|
17
|
+
// When running from source inside the monorepo:
|
|
18
|
+
resolve(join(fileURLToPath(dirname(import.meta.url)), "..", "..", "..", "..", "templates")),
|
|
19
|
+
];
|
|
20
|
+
return candidates.find((p) => existsSync(p));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function listTemplates(): string[] {
|
|
24
|
+
const dir = resolveTemplatesDir();
|
|
25
|
+
if (!dir) return [];
|
|
26
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
27
|
+
.filter((entry) => entry.isDirectory())
|
|
28
|
+
.map((entry) => entry.name)
|
|
29
|
+
.sort();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function loadTemplate(name: string): Promise<StackConfig | undefined> {
|
|
33
|
+
const dir = resolveTemplatesDir();
|
|
34
|
+
if (!dir) return undefined;
|
|
35
|
+
const path = join(dir, name, "stack.toml");
|
|
36
|
+
if (!existsSync(path)) return undefined;
|
|
37
|
+
const raw = await readFile(path, "utf-8");
|
|
38
|
+
return parseToml(raw) as unknown as StackConfig;
|
|
39
|
+
}
|