@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,178 @@
1
+ /**
2
+ * Recipe (de)serialization.
3
+ *
4
+ * A Recipe is the durable, TOML-on-disk artifact of a recommendation pass.
5
+ * Distinct from `RecipeDraft` (the LLM in-memory shape) — once the user
6
+ * accepts a draft, we freeze it to `<cwd>/.stack/recipes/<id>.toml` so
7
+ * `stack apply` can re-run the same set of providers deterministically.
8
+ */
9
+
10
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
11
+ import { join, resolve } from "node:path";
12
+ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
13
+ import { findProviderRef } from "../catalog.ts";
14
+ import { StackError } from "../errors.ts";
15
+ import type { RetrievalHit } from "./catalog-index.ts";
16
+
17
+ export interface Recipe {
18
+ id: string;
19
+ query: string;
20
+ createdAt: string;
21
+ providers: Array<{
22
+ name: string;
23
+ rationale?: string;
24
+ }>;
25
+ guidance?: string;
26
+ projectContext?: Record<string, unknown>;
27
+ }
28
+
29
+ const RECIPES_SUBDIR = ".stack/recipes";
30
+
31
+ function recipesDir(cwd: string): string {
32
+ return resolve(cwd, RECIPES_SUBDIR);
33
+ }
34
+
35
+ /**
36
+ * Guard recipe ids against path-traversal. `stack apply <id>` forwards the
37
+ * positional directly and `writeRecipe` is also called from MCP (LLM-authored
38
+ * ids), so any `/`, `\`, or `..` would let a caller read/write outside the
39
+ * `.stack/recipes/` sandbox.
40
+ */
41
+ function assertSafeRecipeId(id: string): void {
42
+ if (
43
+ !id ||
44
+ /[\\/]/.test(id) ||
45
+ id.includes("..") ||
46
+ id.includes("\0") ||
47
+ /^[A-Za-z]:/.test(id) || // Windows drive-relative path
48
+ id.startsWith(".") ||
49
+ id.length > 200
50
+ ) {
51
+ throw new StackError(
52
+ "INVALID_RECIPE_ID",
53
+ `Recipe id "${id}" contains illegal path characters. Use kebab-case, no separators.`,
54
+ );
55
+ }
56
+ }
57
+
58
+ function recipePath(cwd: string, id: string): string {
59
+ assertSafeRecipeId(id);
60
+ return join(recipesDir(cwd), `${id}.toml`);
61
+ }
62
+
63
+ /**
64
+ * Kebab-case slug from the first ~40 chars of the query. Keeps filenames
65
+ * readable without making them ugly when the query is long.
66
+ */
67
+ export function slugifyQuery(query: string): string {
68
+ const slug = query
69
+ .toLowerCase()
70
+ .slice(0, 40)
71
+ .replace(/[^a-z0-9]+/g, "-")
72
+ .replace(/^-+|-+$/g, "");
73
+ return slug || "recipe";
74
+ }
75
+
76
+ function assertKnownProvider(name: string): void {
77
+ if (!findProviderRef(name)) {
78
+ throw new StackError(
79
+ "UNKNOWN_PROVIDER_IN_RECIPE",
80
+ `Recipe references unknown provider "${name}" (not in catalog).`,
81
+ );
82
+ }
83
+ }
84
+
85
+ export async function writeRecipe(recipe: Recipe, cwd: string = process.cwd()): Promise<string> {
86
+ for (const p of recipe.providers) assertKnownProvider(p.name);
87
+ const dir = recipesDir(cwd);
88
+ await mkdir(dir, { recursive: true });
89
+ const path = recipePath(cwd, recipe.id);
90
+ // smol-toml rejects `undefined` fields — strip before serializing.
91
+ const clean: Record<string, unknown> = {
92
+ id: recipe.id,
93
+ query: recipe.query,
94
+ createdAt: recipe.createdAt,
95
+ providers: recipe.providers.map((p) =>
96
+ p.rationale === undefined ? { name: p.name } : { name: p.name, rationale: p.rationale },
97
+ ),
98
+ };
99
+ if (recipe.guidance !== undefined) clean.guidance = recipe.guidance;
100
+ if (recipe.projectContext !== undefined) clean.projectContext = recipe.projectContext;
101
+ await writeFile(path, stringifyToml(clean), "utf-8");
102
+ return path;
103
+ }
104
+
105
+ export async function readRecipe(id: string, cwd: string = process.cwd()): Promise<Recipe> {
106
+ const path = recipePath(cwd, id);
107
+ let text: string;
108
+ try {
109
+ text = await readFile(path, "utf-8");
110
+ } catch (err) {
111
+ throw new StackError("RECIPE_NOT_FOUND", `No recipe "${id}" at ${path}`, { cause: err });
112
+ }
113
+ const raw = parseToml(text) as Record<string, unknown>;
114
+ const recipe = coerceRecipe(raw, id);
115
+ for (const p of recipe.providers) assertKnownProvider(p.name);
116
+ return recipe;
117
+ }
118
+
119
+ export async function listRecipes(cwd: string = process.cwd()): Promise<Recipe[]> {
120
+ const dir = recipesDir(cwd);
121
+ let entries: string[];
122
+ try {
123
+ entries = await readdir(dir);
124
+ } catch {
125
+ return [];
126
+ }
127
+ const out: Recipe[] = [];
128
+ for (const entry of entries) {
129
+ if (!entry.endsWith(".toml")) continue;
130
+ const id = entry.slice(0, -".toml".length);
131
+ try {
132
+ out.push(await readRecipe(id, cwd));
133
+ } catch {
134
+ // Skip corrupt or unknown-provider recipes rather than erroring the
135
+ // whole picker — the user can still `stack apply <other-id>`.
136
+ }
137
+ }
138
+ // Newest first (createdAt descending) so the interactive picker shows the
139
+ // most recent recommendation at the top.
140
+ out.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
141
+ return out;
142
+ }
143
+
144
+ export function recipeFromRetrieval(
145
+ query: string,
146
+ hits: RetrievalHit[],
147
+ guidance?: string,
148
+ ): Recipe {
149
+ return {
150
+ id: slugifyQuery(query),
151
+ query,
152
+ createdAt: new Date().toISOString(),
153
+ providers: hits.map((h) => ({
154
+ name: h.provider.name,
155
+ rationale: h.matched.length > 0 ? h.matched.join(", ") : undefined,
156
+ })),
157
+ guidance,
158
+ };
159
+ }
160
+
161
+ function coerceRecipe(raw: Record<string, unknown>, fallbackId: string): Recipe {
162
+ const id = typeof raw.id === "string" ? raw.id : fallbackId;
163
+ const query = typeof raw.query === "string" ? raw.query : "";
164
+ const createdAt = typeof raw.createdAt === "string" ? raw.createdAt : new Date(0).toISOString();
165
+ const providersRaw = Array.isArray(raw.providers) ? raw.providers : [];
166
+ const providers = providersRaw.map((entry) => {
167
+ const e = entry as Record<string, unknown>;
168
+ const name = typeof e.name === "string" ? e.name : "";
169
+ const rationale = typeof e.rationale === "string" ? e.rationale : undefined;
170
+ return rationale === undefined ? { name } : { name, rationale };
171
+ });
172
+ const recipe: Recipe = { id, query, createdAt, providers };
173
+ if (typeof raw.guidance === "string") recipe.guidance = raw.guidance;
174
+ if (raw.projectContext && typeof raw.projectContext === "object") {
175
+ recipe.projectContext = raw.projectContext as Record<string, unknown>;
176
+ }
177
+ return recipe;
178
+ }
package/src/catalog.ts ADDED
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Canonical provider reference catalog for Stack.
3
+ *
4
+ * This is the single source of truth for provider display metadata — shared
5
+ * between the CLI, the MCP server, the AI recommender (`packages/core/src/ai/`),
6
+ * and the site's docs (`packages/site/src/lib/providers-ref.ts` re-exports
7
+ * from here).
8
+ *
9
+ * Executable provider adapters live in `packages/core/src/providers/*.ts`.
10
+ * When adding a provider, update both (the adapter for behavior, this catalog
11
+ * for metadata/docs/AI context).
12
+ */
13
+
14
+ export type CatalogAuthKind = "oauth_pkce" | "oauth_device" | "pat" | "api_key";
15
+
16
+ export interface ProviderRef {
17
+ /** Matches the CLI provider name (what you type after `stack add`). */
18
+ name: string;
19
+ displayName: string;
20
+ category:
21
+ | "Database"
22
+ | "Deploy"
23
+ | "Cloud"
24
+ | "AI"
25
+ | "Analytics"
26
+ | "Errors"
27
+ | "Payments"
28
+ | "Code"
29
+ | "Tickets"
30
+ | "Email"
31
+ | "Auth"
32
+ | "Features";
33
+ authKind: CatalogAuthKind;
34
+ /** Canonical .env names this provider writes into Phantom. */
35
+ secrets: string[];
36
+ /** Short one-liner describing what you get. */
37
+ blurb: string;
38
+ /** When truthy, Stack wires an MCP entry for this provider. */
39
+ mcp?: {
40
+ name: string;
41
+ /** Marks this MCP entry as pre-alpha / best-effort. */
42
+ preview?: boolean;
43
+ detail: string;
44
+ };
45
+ /** Dashboard URL `stack open <name>` lands on. */
46
+ dashboard: string;
47
+ /** Upstream reference docs URL for when something goes wrong. */
48
+ docs: string;
49
+ /** How auth works, from the user's perspective. One short sentence. */
50
+ howTo: string;
51
+ /** Free-form note about v1 limitations or caveats. */
52
+ notes?: string;
53
+ }
54
+
55
+ /** Back-compat re-export for call sites that still use `AuthKind`. */
56
+ export type AuthKind = CatalogAuthKind;
57
+
58
+ export const PROVIDERS_REF: ProviderRef[] = [
59
+ // ── Database ──────────────────────────────────────────────────────────────
60
+ {
61
+ name: "supabase",
62
+ displayName: "Supabase",
63
+ category: "Database",
64
+ authKind: "oauth_pkce",
65
+ secrets: ["SUPABASE_URL", "SUPABASE_ANON_KEY", "SUPABASE_SERVICE_ROLE_KEY"],
66
+ blurb: "Postgres + Auth + Storage. Full upstream provisioning via the Management API.",
67
+ mcp: {
68
+ name: "supabase",
69
+ detail:
70
+ "@supabase/mcp-server-supabase scoped to the new project ref, with SUPABASE_ACCESS_TOKEN piped from Phantom.",
71
+ },
72
+ dashboard: "https://supabase.com/dashboard",
73
+ docs: "https://supabase.com/docs/reference/api/introduction",
74
+ howTo: "Browser OAuth (PKCE) via the Ashlr Stack app.",
75
+ notes:
76
+ "v1 provisions a project and fetches service keys. Database password is auto-generated and stored in Phantom.",
77
+ },
78
+ {
79
+ name: "neon",
80
+ displayName: "Neon",
81
+ category: "Database",
82
+ authKind: "pat",
83
+ secrets: ["NEON_API_KEY", "DATABASE_URL"],
84
+ blurb: "Serverless Postgres. Creates a project and pools the connection string.",
85
+ dashboard: "https://console.neon.tech",
86
+ docs: "https://neon.tech/docs/reference/api-reference",
87
+ howTo: "Paste a personal API key from https://console.neon.tech/app/settings/api-keys.",
88
+ },
89
+ {
90
+ name: "turso",
91
+ displayName: "Turso",
92
+ category: "Database",
93
+ authKind: "pat",
94
+ secrets: ["TURSO_DATABASE_URL", "TURSO_AUTH_TOKEN", "TURSO_PLATFORM_TOKEN"],
95
+ blurb: "Edge SQLite (libSQL). Creates a database in your default org.",
96
+ dashboard: "https://app.turso.tech",
97
+ docs: "https://docs.turso.tech",
98
+ howTo: "Paste a platform token from https://app.turso.tech/account/api-tokens.",
99
+ },
100
+ {
101
+ name: "convex",
102
+ displayName: "Convex",
103
+ category: "Database",
104
+ authKind: "api_key",
105
+ secrets: ["CONVEX_DEPLOY_KEY"],
106
+ blurb: "Reactive backend-as-a-service. Deploy key stored in Phantom.",
107
+ dashboard: "https://dashboard.convex.dev",
108
+ docs: "https://docs.convex.dev",
109
+ howTo: "Create a deploy key in the Convex dashboard and paste it.",
110
+ },
111
+ {
112
+ name: "upstash",
113
+ displayName: "Upstash",
114
+ category: "Database",
115
+ authKind: "api_key",
116
+ secrets: ["UPSTASH_MANAGEMENT_TOKEN"],
117
+ blurb: "Serverless Redis + Kafka. Management token stored in Phantom.",
118
+ dashboard: "https://console.upstash.com",
119
+ docs: "https://upstash.com/docs/devops/developer-api",
120
+ howTo: "Generate a management token from the Upstash console.",
121
+ },
122
+ {
123
+ name: "firebase",
124
+ displayName: "Firebase",
125
+ category: "Database",
126
+ authKind: "api_key",
127
+ secrets: ["FIREBASE_SERVICE_ACCOUNT_JSON"],
128
+ blurb: "Realtime DB + Auth. Service-account JSON stored verbatim in Phantom.",
129
+ dashboard: "https://console.firebase.google.com",
130
+ docs: "https://firebase.google.com/docs/admin/setup",
131
+ howTo:
132
+ "Paste the full service-account JSON from Firebase → Project settings → Service accounts.",
133
+ },
134
+
135
+ // ── Deploy ────────────────────────────────────────────────────────────────
136
+ {
137
+ name: "vercel",
138
+ displayName: "Vercel",
139
+ category: "Deploy",
140
+ authKind: "pat",
141
+ secrets: ["VERCEL_TOKEN"],
142
+ blurb: "Frontend platform. Stores a scoped access token for deploys + env sync.",
143
+ dashboard: "https://vercel.com/dashboard",
144
+ docs: "https://vercel.com/docs/rest-api",
145
+ howTo: "Paste a personal access token from https://vercel.com/account/tokens.",
146
+ },
147
+ {
148
+ name: "railway",
149
+ displayName: "Railway",
150
+ category: "Deploy",
151
+ authKind: "api_key",
152
+ secrets: ["RAILWAY_TOKEN"],
153
+ blurb: "Infra from a repo. Project-level token stored in Phantom.",
154
+ dashboard: "https://railway.app/dashboard",
155
+ docs: "https://docs.railway.app/reference/public-api",
156
+ howTo: "Paste an API token from https://railway.app/account/tokens.",
157
+ },
158
+ {
159
+ name: "fly",
160
+ displayName: "Fly.io",
161
+ category: "Deploy",
162
+ authKind: "api_key",
163
+ secrets: ["FLY_API_TOKEN"],
164
+ blurb: "VMs at the edge. Machines API token stored in Phantom.",
165
+ dashboard: "https://fly.io/dashboard",
166
+ docs: "https://fly.io/docs/machines/api",
167
+ howTo: "Paste an API token from https://fly.io/user/personal_access_tokens.",
168
+ },
169
+ {
170
+ name: "cloudflare",
171
+ displayName: "Cloudflare",
172
+ category: "Deploy",
173
+ authKind: "pat",
174
+ secrets: ["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID"],
175
+ blurb: "Workers, R2, D1. Account id inferred from the token's scope.",
176
+ dashboard: "https://dash.cloudflare.com",
177
+ docs: "https://developers.cloudflare.com/api",
178
+ howTo: "Create a scoped API token at https://dash.cloudflare.com/profile/api-tokens.",
179
+ },
180
+ {
181
+ name: "render",
182
+ displayName: "Render",
183
+ category: "Deploy",
184
+ authKind: "api_key",
185
+ secrets: ["RENDER_API_KEY"],
186
+ blurb: "Zero-config hosting. API key stored in Phantom.",
187
+ dashboard: "https://dashboard.render.com",
188
+ docs: "https://api-docs.render.com",
189
+ howTo: "Generate a key at https://dashboard.render.com/u/settings#api-keys.",
190
+ },
191
+
192
+ // ── Cloud ─────────────────────────────────────────────────────────────────
193
+ {
194
+ name: "aws",
195
+ displayName: "AWS",
196
+ category: "Cloud",
197
+ authKind: "api_key",
198
+ secrets: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION"],
199
+ blurb: "S3, Lambda, RDS. IAM access keys stored in Phantom.",
200
+ dashboard: "https://console.aws.amazon.com",
201
+ docs: "https://docs.aws.amazon.com/iam/",
202
+ howTo: "Create an IAM access key pair in the AWS console and paste both halves.",
203
+ notes: "v1 stores the keys only — no resource provisioning. Scope the IAM policy narrowly.",
204
+ },
205
+
206
+ // ── AI ────────────────────────────────────────────────────────────────────
207
+ {
208
+ name: "openai",
209
+ displayName: "OpenAI",
210
+ category: "AI",
211
+ authKind: "api_key",
212
+ secrets: ["OPENAI_API_KEY"],
213
+ blurb: "GPT, Realtime, embeddings. Key verified against /v1/models on paste.",
214
+ dashboard: "https://platform.openai.com",
215
+ docs: "https://platform.openai.com/docs/api-reference",
216
+ howTo: "Paste a secret key from https://platform.openai.com/api-keys.",
217
+ },
218
+ {
219
+ name: "anthropic",
220
+ displayName: "Anthropic",
221
+ category: "AI",
222
+ authKind: "api_key",
223
+ secrets: ["ANTHROPIC_API_KEY"],
224
+ blurb: "Claude models + MCP. Key verified against the Messages API on paste.",
225
+ dashboard: "https://console.anthropic.com",
226
+ docs: "https://docs.anthropic.com/en/api",
227
+ howTo: "Paste a secret key from https://console.anthropic.com/settings/keys.",
228
+ },
229
+ {
230
+ name: "xai",
231
+ displayName: "xAI",
232
+ category: "AI",
233
+ authKind: "api_key",
234
+ secrets: ["XAI_API_KEY"],
235
+ blurb: "Grok + tool use. Key verified on paste.",
236
+ dashboard: "https://console.x.ai",
237
+ docs: "https://docs.x.ai/api",
238
+ howTo: "Paste a key from https://console.x.ai.",
239
+ },
240
+ {
241
+ name: "deepseek",
242
+ displayName: "DeepSeek",
243
+ category: "AI",
244
+ authKind: "api_key",
245
+ secrets: ["DEEPSEEK_API_KEY"],
246
+ blurb: "Open-weight models. Key verified on paste.",
247
+ dashboard: "https://platform.deepseek.com",
248
+ docs: "https://api-docs.deepseek.com",
249
+ howTo: "Paste a key from https://platform.deepseek.com/api_keys.",
250
+ },
251
+
252
+ // ── Analytics ─────────────────────────────────────────────────────────────
253
+ {
254
+ name: "posthog",
255
+ displayName: "PostHog",
256
+ category: "Analytics",
257
+ authKind: "api_key",
258
+ secrets: ["POSTHOG_PERSONAL_API_KEY"],
259
+ blurb: "Product analytics + flags. Personal API key stored in Phantom.",
260
+ mcp: {
261
+ name: "posthog",
262
+ detail:
263
+ "SSE MCP at mcp.posthog.com/sse, with the personal key piped through Phantom at call time.",
264
+ },
265
+ dashboard: "https://app.posthog.com",
266
+ docs: "https://posthog.com/docs/api",
267
+ howTo: "Create a personal API key at https://app.posthog.com/me/settings (scope: all).",
268
+ },
269
+
270
+ // ── Errors ────────────────────────────────────────────────────────────────
271
+ {
272
+ name: "sentry",
273
+ displayName: "Sentry",
274
+ category: "Errors",
275
+ authKind: "pat",
276
+ secrets: ["SENTRY_AUTH_TOKEN", "SENTRY_DSN", "SENTRY_ORG", "SENTRY_PROJECT"],
277
+ blurb: "Error + performance tracking. Creates a project and fetches its DSN.",
278
+ mcp: {
279
+ name: "sentry",
280
+ detail:
281
+ "@sentry/mcp-server with SENTRY_AUTH_TOKEN piped from Phantom and SENTRY_HOST defaulting to sentry.io.",
282
+ },
283
+ dashboard: "https://sentry.io",
284
+ docs: "https://docs.sentry.io/api/",
285
+ howTo:
286
+ "Paste an auth token from https://sentry.io/settings/account/api/auth-tokens/ (scopes: project:read, project:write).",
287
+ },
288
+
289
+ // ── Payments ──────────────────────────────────────────────────────────────
290
+ {
291
+ name: "stripe",
292
+ displayName: "Stripe",
293
+ category: "Payments",
294
+ authKind: "api_key",
295
+ secrets: ["STRIPE_SECRET_KEY"],
296
+ blurb: "Billing, subscriptions, tax. Restricted / test-mode secret key stored in Phantom.",
297
+ dashboard: "https://dashboard.stripe.com",
298
+ docs: "https://docs.stripe.com/api",
299
+ howTo:
300
+ "Create a restricted key at https://dashboard.stripe.com/apikeys (use test mode for development).",
301
+ notes:
302
+ "v1 only stores the key. Full Stripe Connect / account linking lands when we register the Ashlr OAuth app.",
303
+ },
304
+
305
+ // ── Code ──────────────────────────────────────────────────────────────────
306
+ {
307
+ name: "github",
308
+ displayName: "GitHub",
309
+ category: "Code",
310
+ authKind: "oauth_device",
311
+ secrets: ["GITHUB_TOKEN"],
312
+ blurb: "Repos, Actions, OAuth. Device-flow token stored in Phantom.",
313
+ mcp: {
314
+ name: "github",
315
+ detail:
316
+ "@modelcontextprotocol/server-github with GITHUB_PERSONAL_ACCESS_TOKEN piped from Phantom.",
317
+ },
318
+ dashboard: "https://github.com",
319
+ docs: "https://docs.github.com/en/rest",
320
+ howTo: "Browser device-code flow — enter the code GitHub shows you, authorise, done.",
321
+ },
322
+
323
+ // ── Tickets ───────────────────────────────────────────────────────────────
324
+ {
325
+ name: "linear",
326
+ displayName: "Linear",
327
+ category: "Tickets",
328
+ authKind: "api_key",
329
+ secrets: ["LINEAR_API_KEY"],
330
+ blurb: "Issues, projects, cycles. Personal API key stored in Phantom.",
331
+ mcp: {
332
+ name: "linear",
333
+ detail: "mcp-linear with LINEAR_API_KEY piped from Phantom.",
334
+ },
335
+ dashboard: "https://linear.app",
336
+ docs: "https://developers.linear.app/docs",
337
+ howTo: "Create a personal API key at https://linear.app/settings/api.",
338
+ },
339
+
340
+ // ── Email ─────────────────────────────────────────────────────────────────
341
+ {
342
+ name: "resend",
343
+ displayName: "Resend",
344
+ category: "Email",
345
+ authKind: "api_key",
346
+ secrets: ["RESEND_API_KEY"],
347
+ blurb: "Transactional + React Email. API key stored in Phantom.",
348
+ dashboard: "https://resend.com",
349
+ docs: "https://resend.com/docs",
350
+ howTo: "Paste an API key from https://resend.com/api-keys.",
351
+ },
352
+ {
353
+ name: "sendgrid",
354
+ displayName: "SendGrid",
355
+ category: "Email",
356
+ authKind: "api_key",
357
+ secrets: ["SENDGRID_API_KEY"],
358
+ blurb: "High-volume transactional mail. API key verified against /v3/scopes on paste.",
359
+ dashboard: "https://app.sendgrid.com",
360
+ docs: "https://docs.sendgrid.com/api-reference",
361
+ howTo: "Paste an API key from https://app.sendgrid.com/settings/api_keys.",
362
+ },
363
+ {
364
+ name: "mailgun",
365
+ displayName: "Mailgun",
366
+ category: "Email",
367
+ authKind: "api_key",
368
+ secrets: ["MAILGUN_API_KEY"],
369
+ blurb: "API-first email + routing. Key verified against /v3/domains on paste (HTTP Basic).",
370
+ dashboard: "https://app.mailgun.com",
371
+ docs: "https://documentation.mailgun.com/docs/mailgun/api-reference/",
372
+ howTo: "Paste an API key from https://app.mailgun.com/settings/api_security.",
373
+ },
374
+ {
375
+ name: "postmark",
376
+ displayName: "Postmark",
377
+ category: "Email",
378
+ authKind: "api_key",
379
+ secrets: ["POSTMARK_ACCOUNT_TOKEN"],
380
+ blurb:
381
+ "Deliverability-focused transactional. Account token verified against /servers on paste.",
382
+ dashboard: "https://account.postmarkapp.com",
383
+ docs: "https://postmarkapp.com/developer/api/overview",
384
+ howTo: "Paste an account token from https://account.postmarkapp.com/api_tokens.",
385
+ },
386
+
387
+ // ── Auth ──────────────────────────────────────────────────────────────────
388
+ {
389
+ name: "clerk",
390
+ displayName: "Clerk",
391
+ category: "Auth",
392
+ authKind: "api_key",
393
+ secrets: ["CLERK_SECRET_KEY"],
394
+ blurb: "Drop-in auth + users. Secret key stored in Phantom.",
395
+ dashboard: "https://dashboard.clerk.com",
396
+ docs: "https://clerk.com/docs",
397
+ howTo: "Paste a secret key from https://dashboard.clerk.com (API Keys).",
398
+ },
399
+ ];
400
+
401
+ export const PROVIDER_CATEGORIES = [
402
+ "Database",
403
+ "Deploy",
404
+ "Cloud",
405
+ "AI",
406
+ "Analytics",
407
+ "Errors",
408
+ "Payments",
409
+ "Code",
410
+ "Tickets",
411
+ "Email",
412
+ "Auth",
413
+ ] as const;
414
+
415
+ export function groupByCategory(): Record<string, ProviderRef[]> {
416
+ const out: Record<string, ProviderRef[]> = {};
417
+ for (const p of PROVIDERS_REF) {
418
+ let bucket = out[p.category];
419
+ if (!bucket) {
420
+ bucket = [];
421
+ out[p.category] = bucket;
422
+ }
423
+ bucket.push(p);
424
+ }
425
+ return out;
426
+ }
427
+
428
+ export function findProviderRef(name: string): ProviderRef | undefined {
429
+ return PROVIDERS_REF.find((p) => p.name === name);
430
+ }