@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
package/src/config.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { appendFile, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
5
|
+
import { ConfigNotFoundError } from "./errors.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Stack config is split across two files:
|
|
9
|
+
*
|
|
10
|
+
* - `.stack.toml` (committed) — SHAPE. Services that define this stack,
|
|
11
|
+
* their canonical secret slot names, and which MCP servers to wire up.
|
|
12
|
+
* Safe to share via git.
|
|
13
|
+
*
|
|
14
|
+
* - `.stack.local.toml` (gitignored) — INSTANCE. Per-developer IDs, timestamps,
|
|
15
|
+
* provider-side resource refs. Auto-added to .gitignore on first write.
|
|
16
|
+
*
|
|
17
|
+
* Callers work with the merged `StackConfig` shape; `readConfig` combines the
|
|
18
|
+
* two files and `writeConfig` splits writes back out. Legacy single-file
|
|
19
|
+
* `.stack.toml` (with instance fields) is read transparently and migrated on
|
|
20
|
+
* the next write.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export const CONFIG_FILENAME = ".stack.toml";
|
|
24
|
+
export const LOCAL_FILENAME = ".stack.local.toml";
|
|
25
|
+
export const CONFIG_VERSION = "1";
|
|
26
|
+
|
|
27
|
+
export interface StackRoot {
|
|
28
|
+
version: string;
|
|
29
|
+
project_id: string;
|
|
30
|
+
template?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ServiceEntry {
|
|
34
|
+
provider: string;
|
|
35
|
+
/** Provider-side resource id (project_ref, project_id, app name, etc.). INSTANCE. */
|
|
36
|
+
resource_id?: string;
|
|
37
|
+
/** Region. Lives in INSTANCE — can differ per developer if they clone + re-provision. */
|
|
38
|
+
region?: string;
|
|
39
|
+
/** Names of secrets stored in Phantom that belong to this service. SHAPE. */
|
|
40
|
+
secrets: string[];
|
|
41
|
+
/** Name of the MCP server entry in .mcp.json, if any. SHAPE. */
|
|
42
|
+
mcp?: string;
|
|
43
|
+
/** Freeform provider-specific fields. INSTANCE. */
|
|
44
|
+
meta?: Record<string, unknown>;
|
|
45
|
+
/** INSTANCE. */
|
|
46
|
+
created_at: string;
|
|
47
|
+
/** INSTANCE. */
|
|
48
|
+
created_by?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface EnvironmentEntry {
|
|
52
|
+
name: string;
|
|
53
|
+
default?: boolean;
|
|
54
|
+
overrides?: Record<string, string>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface StackConfig {
|
|
58
|
+
stack: StackRoot;
|
|
59
|
+
services: Record<string, ServiceEntry>;
|
|
60
|
+
environments: EnvironmentEntry[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function emptyConfig(template?: string): StackConfig {
|
|
64
|
+
return {
|
|
65
|
+
stack: {
|
|
66
|
+
version: CONFIG_VERSION,
|
|
67
|
+
project_id: randomProjectId(),
|
|
68
|
+
...(template ? { template } : {}),
|
|
69
|
+
},
|
|
70
|
+
services: {},
|
|
71
|
+
environments: [{ name: "dev", default: true }],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function randomProjectId(): string {
|
|
76
|
+
const bytes = crypto.getRandomValues(new Uint8Array(6));
|
|
77
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
78
|
+
return `stk_${hex}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function resolveConfigPath(cwd: string = process.cwd()): string {
|
|
82
|
+
return resolve(join(cwd, CONFIG_FILENAME));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function resolveLocalConfigPath(cwd: string = process.cwd()): string {
|
|
86
|
+
return resolve(join(cwd, LOCAL_FILENAME));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function hasConfig(cwd: string = process.cwd()): boolean {
|
|
90
|
+
return existsSync(resolveConfigPath(cwd));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Committed shape fragment
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
interface ShapeFile {
|
|
98
|
+
stack: { version: string; template?: string };
|
|
99
|
+
services: Record<
|
|
100
|
+
string,
|
|
101
|
+
{ provider: string; secrets: string[]; mcp?: string }
|
|
102
|
+
>;
|
|
103
|
+
environments: EnvironmentEntry[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Local instance fragment
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
interface InstanceFile {
|
|
111
|
+
stack: { project_id: string };
|
|
112
|
+
services: Record<
|
|
113
|
+
string,
|
|
114
|
+
{
|
|
115
|
+
resource_id?: string;
|
|
116
|
+
region?: string;
|
|
117
|
+
meta?: Record<string, unknown>;
|
|
118
|
+
created_at?: string;
|
|
119
|
+
created_by?: string;
|
|
120
|
+
}
|
|
121
|
+
>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Read: merge the two files (or migrate a legacy single file)
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
export async function readConfig(cwd: string = process.cwd()): Promise<StackConfig> {
|
|
129
|
+
const shapePath = resolveConfigPath(cwd);
|
|
130
|
+
const localPath = resolveLocalConfigPath(cwd);
|
|
131
|
+
if (!existsSync(shapePath)) throw new ConfigNotFoundError(shapePath);
|
|
132
|
+
|
|
133
|
+
const rawShape = await readFile(shapePath, "utf-8");
|
|
134
|
+
const shape = parseToml(rawShape) as Record<string, unknown>;
|
|
135
|
+
|
|
136
|
+
// Detect legacy single-file config — if the top file carries instance fields
|
|
137
|
+
// (project_id at root, or created_at inside services), treat it as merged.
|
|
138
|
+
const isLegacy =
|
|
139
|
+
(shape.stack as Record<string, unknown> | undefined)?.project_id !== undefined ||
|
|
140
|
+
Object.values((shape.services as Record<string, Record<string, unknown>>) ?? {}).some(
|
|
141
|
+
(s) => s.created_at !== undefined || s.resource_id !== undefined,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
if (isLegacy) return normalizeLegacy(shape);
|
|
145
|
+
|
|
146
|
+
const instance = existsSync(localPath)
|
|
147
|
+
? (parseToml(await readFile(localPath, "utf-8")) as Record<string, unknown>)
|
|
148
|
+
: ({} as Record<string, unknown>);
|
|
149
|
+
|
|
150
|
+
return merge(shape as unknown as ShapeFile, instance as unknown as InstanceFile);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Write: split and route to the two files, auto-gitignore the local one
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
export async function writeConfig(
|
|
158
|
+
config: StackConfig,
|
|
159
|
+
cwd: string = process.cwd(),
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
const { shape, instance } = split(config);
|
|
162
|
+
|
|
163
|
+
const shapeHeader =
|
|
164
|
+
"# Ashlr Stack — committed shape. Defines which services this project uses.\n" +
|
|
165
|
+
"# Safe to share in git; secret *values* live in Phantom, not here.\n";
|
|
166
|
+
await writeFile(
|
|
167
|
+
resolveConfigPath(cwd),
|
|
168
|
+
shapeHeader + stringifyToml(shape as unknown as Record<string, unknown>),
|
|
169
|
+
"utf-8",
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const instanceHeader =
|
|
173
|
+
"# Ashlr Stack — local instance data. Auto-generated; do not commit.\n";
|
|
174
|
+
await writeFile(
|
|
175
|
+
resolveLocalConfigPath(cwd),
|
|
176
|
+
instanceHeader + stringifyToml(instance as unknown as Record<string, unknown>),
|
|
177
|
+
"utf-8",
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
await ensureGitignored(cwd);
|
|
181
|
+
|
|
182
|
+
// Best-effort: auto-register the project in the global registry. Failures
|
|
183
|
+
// here (e.g. a read-only home dir) must not break the write itself.
|
|
184
|
+
try {
|
|
185
|
+
const { registerProject } = await import("./registry.ts");
|
|
186
|
+
await registerProject(cwd);
|
|
187
|
+
} catch {
|
|
188
|
+
/* swallow */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Helpers
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
function split(config: StackConfig): { shape: ShapeFile; instance: InstanceFile } {
|
|
197
|
+
const shape: ShapeFile = {
|
|
198
|
+
stack: {
|
|
199
|
+
version: config.stack.version,
|
|
200
|
+
...(config.stack.template ? { template: config.stack.template } : {}),
|
|
201
|
+
},
|
|
202
|
+
services: {},
|
|
203
|
+
environments: config.environments,
|
|
204
|
+
};
|
|
205
|
+
const instance: InstanceFile = {
|
|
206
|
+
stack: { project_id: config.stack.project_id },
|
|
207
|
+
services: {},
|
|
208
|
+
};
|
|
209
|
+
for (const [name, entry] of Object.entries(config.services)) {
|
|
210
|
+
shape.services[name] = {
|
|
211
|
+
provider: entry.provider,
|
|
212
|
+
secrets: entry.secrets,
|
|
213
|
+
...(entry.mcp ? { mcp: entry.mcp } : {}),
|
|
214
|
+
};
|
|
215
|
+
const inst: InstanceFile["services"][string] = {};
|
|
216
|
+
if (entry.resource_id) inst.resource_id = entry.resource_id;
|
|
217
|
+
if (entry.region) inst.region = entry.region;
|
|
218
|
+
if (entry.meta) inst.meta = entry.meta;
|
|
219
|
+
if (entry.created_at) inst.created_at = entry.created_at;
|
|
220
|
+
if (entry.created_by) inst.created_by = entry.created_by;
|
|
221
|
+
if (Object.keys(inst).length > 0) instance.services[name] = inst;
|
|
222
|
+
}
|
|
223
|
+
return { shape, instance };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function merge(shape: ShapeFile, instance: InstanceFile): StackConfig {
|
|
227
|
+
const services: Record<string, ServiceEntry> = {};
|
|
228
|
+
for (const [name, s] of Object.entries(shape.services ?? {})) {
|
|
229
|
+
const i = instance.services?.[name] ?? {};
|
|
230
|
+
services[name] = {
|
|
231
|
+
provider: s.provider,
|
|
232
|
+
secrets: s.secrets,
|
|
233
|
+
...(s.mcp ? { mcp: s.mcp } : {}),
|
|
234
|
+
...(i.resource_id ? { resource_id: i.resource_id } : {}),
|
|
235
|
+
...(i.region ? { region: i.region } : {}),
|
|
236
|
+
...(i.meta ? { meta: i.meta } : {}),
|
|
237
|
+
created_at: i.created_at ?? "1970-01-01T00:00:00Z",
|
|
238
|
+
...(i.created_by ? { created_by: i.created_by } : {}),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
stack: {
|
|
243
|
+
version: shape.stack?.version ?? CONFIG_VERSION,
|
|
244
|
+
project_id: instance.stack?.project_id ?? randomProjectId(),
|
|
245
|
+
...(shape.stack?.template ? { template: shape.stack.template } : {}),
|
|
246
|
+
},
|
|
247
|
+
services,
|
|
248
|
+
environments: shape.environments?.length
|
|
249
|
+
? shape.environments
|
|
250
|
+
: [{ name: "dev", default: true }],
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function normalizeLegacy(raw: Record<string, unknown>): StackConfig {
|
|
255
|
+
const stackField = (raw.stack ?? {}) as Record<string, unknown>;
|
|
256
|
+
const services = (raw.services ?? {}) as Record<string, Record<string, unknown>>;
|
|
257
|
+
const environments = (raw.environments ?? [{ name: "dev", default: true }]) as EnvironmentEntry[];
|
|
258
|
+
const out: StackConfig = {
|
|
259
|
+
stack: {
|
|
260
|
+
version: (stackField.version as string) ?? CONFIG_VERSION,
|
|
261
|
+
project_id: (stackField.project_id as string) ?? randomProjectId(),
|
|
262
|
+
...(typeof stackField.template === "string" ? { template: stackField.template } : {}),
|
|
263
|
+
},
|
|
264
|
+
services: {},
|
|
265
|
+
environments,
|
|
266
|
+
};
|
|
267
|
+
for (const [name, entry] of Object.entries(services)) {
|
|
268
|
+
out.services[name] = {
|
|
269
|
+
provider: (entry.provider as string) ?? name,
|
|
270
|
+
secrets: (entry.secrets as string[]) ?? [],
|
|
271
|
+
...(typeof entry.mcp === "string" ? { mcp: entry.mcp } : {}),
|
|
272
|
+
...(typeof entry.resource_id === "string" ? { resource_id: entry.resource_id } : {}),
|
|
273
|
+
...(typeof entry.region === "string" ? { region: entry.region } : {}),
|
|
274
|
+
...(entry.meta ? { meta: entry.meta as Record<string, unknown> } : {}),
|
|
275
|
+
created_at: (entry.created_at as string) ?? new Date().toISOString(),
|
|
276
|
+
...(typeof entry.created_by === "string" ? { created_by: entry.created_by } : {}),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function ensureGitignored(cwd: string): Promise<void> {
|
|
283
|
+
const gitignorePath = join(cwd, ".gitignore");
|
|
284
|
+
const needle = LOCAL_FILENAME;
|
|
285
|
+
if (!existsSync(gitignorePath)) {
|
|
286
|
+
await writeFile(gitignorePath, `# Ashlr Stack\n${needle}\n.mcp.json.local\n`, "utf-8");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const current = await readFile(gitignorePath, "utf-8");
|
|
290
|
+
if (current.split(/\r?\n/).some((line) => line.trim() === needle)) return;
|
|
291
|
+
const sep = current.endsWith("\n") ? "" : "\n";
|
|
292
|
+
await appendFile(gitignorePath, `${sep}# Ashlr Stack\n${needle}\n`, "utf-8");
|
|
293
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Source-level provider detection. Reads package.json / requirements.txt /
|
|
7
|
+
* pyproject.toml / go.mod / Cargo.toml plus framework config files and
|
|
8
|
+
* reports which curated providers the project *already uses*.
|
|
9
|
+
*
|
|
10
|
+
* This is the backbone of `stack scan` and zero-config onboarding. It runs on
|
|
11
|
+
* any repo — committed .stack.toml or not — and returns provider names plus
|
|
12
|
+
* evidence strings for auditing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type DetectionConfidence = "high" | "medium" | "low";
|
|
16
|
+
|
|
17
|
+
export interface SourceDetection {
|
|
18
|
+
provider: string;
|
|
19
|
+
confidence: DetectionConfidence;
|
|
20
|
+
signals: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SourceScanOptions {
|
|
24
|
+
/** Skip detection of providers that require hitting the network. Default: true. */
|
|
25
|
+
offlineOnly?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface DepRule {
|
|
29
|
+
provider: string;
|
|
30
|
+
/** Exact match or prefix on the dep name. */
|
|
31
|
+
matches: (dep: string) => boolean;
|
|
32
|
+
confidence: DetectionConfidence;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const JS_RULES: DepRule[] = [
|
|
36
|
+
{ provider: "supabase", matches: (d) => d.startsWith("@supabase/"), confidence: "high" },
|
|
37
|
+
{ provider: "neon", matches: (d) => d.startsWith("@neondatabase/"), confidence: "high" },
|
|
38
|
+
{
|
|
39
|
+
provider: "vercel",
|
|
40
|
+
matches: (d) => d === "@vercel/analytics" || d === "@vercel/speed-insights" || d === "@vercel/kv",
|
|
41
|
+
confidence: "medium",
|
|
42
|
+
},
|
|
43
|
+
{ provider: "sentry", matches: (d) => d.startsWith("@sentry/"), confidence: "high" },
|
|
44
|
+
{
|
|
45
|
+
provider: "posthog",
|
|
46
|
+
matches: (d) => d === "posthog-js" || d === "posthog-node",
|
|
47
|
+
confidence: "high",
|
|
48
|
+
},
|
|
49
|
+
{ provider: "openai", matches: (d) => d === "openai", confidence: "high" },
|
|
50
|
+
{ provider: "anthropic", matches: (d) => d === "@anthropic-ai/sdk", confidence: "high" },
|
|
51
|
+
{ provider: "stripe", matches: (d) => d === "stripe" || d === "@stripe/stripe-js", confidence: "high" },
|
|
52
|
+
{ provider: "clerk", matches: (d) => d.startsWith("@clerk/"), confidence: "high" },
|
|
53
|
+
{ provider: "resend", matches: (d) => d === "resend", confidence: "high" },
|
|
54
|
+
{
|
|
55
|
+
provider: "upstash",
|
|
56
|
+
matches: (d) => d.startsWith("@upstash/"),
|
|
57
|
+
confidence: "high",
|
|
58
|
+
},
|
|
59
|
+
{ provider: "linear", matches: (d) => d === "@linear/sdk", confidence: "high" },
|
|
60
|
+
{
|
|
61
|
+
provider: "cloudflare",
|
|
62
|
+
matches: (d) => d === "wrangler" || d.startsWith("@cloudflare/"),
|
|
63
|
+
confidence: "high",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
provider: "turso",
|
|
67
|
+
matches: (d) => d === "@libsql/client" || d === "@libsql/kysely-libsql",
|
|
68
|
+
confidence: "high",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
provider: "convex",
|
|
72
|
+
matches: (d) => d === "convex" || d.startsWith("@convex-dev/"),
|
|
73
|
+
confidence: "high",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
provider: "firebase",
|
|
77
|
+
matches: (d) => d === "firebase" || d === "firebase-admin" || d.startsWith("@firebase/"),
|
|
78
|
+
confidence: "high",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
provider: "render",
|
|
82
|
+
// No canonical npm package; rely on config files & env detection.
|
|
83
|
+
matches: () => false,
|
|
84
|
+
confidence: "low",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
provider: "replicate",
|
|
88
|
+
matches: (d) => d === "replicate",
|
|
89
|
+
confidence: "high",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
provider: "braintrust",
|
|
93
|
+
matches: (d) => d === "braintrust" || d === "@braintrust/sdk",
|
|
94
|
+
confidence: "high",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
provider: "modal",
|
|
98
|
+
// Modal is Python-first; the dep lives in requirements.txt.
|
|
99
|
+
matches: () => false,
|
|
100
|
+
confidence: "low",
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
const PY_RULES: DepRule[] = [
|
|
105
|
+
{ provider: "supabase", matches: (d) => d === "supabase" || d === "supabase-py", confidence: "high" },
|
|
106
|
+
{ provider: "openai", matches: (d) => d === "openai", confidence: "high" },
|
|
107
|
+
{ provider: "anthropic", matches: (d) => d === "anthropic", confidence: "high" },
|
|
108
|
+
{ provider: "sentry", matches: (d) => d === "sentry-sdk", confidence: "high" },
|
|
109
|
+
{ provider: "stripe", matches: (d) => d === "stripe", confidence: "high" },
|
|
110
|
+
{ provider: "posthog", matches: (d) => d === "posthog", confidence: "high" },
|
|
111
|
+
{ provider: "resend", matches: (d) => d === "resend", confidence: "high" },
|
|
112
|
+
{ provider: "modal", matches: (d) => d === "modal", confidence: "high" },
|
|
113
|
+
{ provider: "replicate", matches: (d) => d === "replicate", confidence: "high" },
|
|
114
|
+
{ provider: "braintrust", matches: (d) => d === "braintrust", confidence: "high" },
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
const GO_RULES: DepRule[] = [
|
|
118
|
+
{
|
|
119
|
+
provider: "supabase",
|
|
120
|
+
matches: (d) => d.startsWith("github.com/supabase-community/"),
|
|
121
|
+
confidence: "high",
|
|
122
|
+
},
|
|
123
|
+
{ provider: "openai", matches: (d) => d === "github.com/sashabaranov/go-openai", confidence: "high" },
|
|
124
|
+
{
|
|
125
|
+
provider: "anthropic",
|
|
126
|
+
matches: (d) => d.startsWith("github.com/liushuangls/go-anthropic"),
|
|
127
|
+
confidence: "medium",
|
|
128
|
+
},
|
|
129
|
+
{ provider: "sentry", matches: (d) => d === "github.com/getsentry/sentry-go", confidence: "high" },
|
|
130
|
+
{ provider: "stripe", matches: (d) => d.startsWith("github.com/stripe/stripe-go"), confidence: "high" },
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
const RUST_RULES: DepRule[] = [
|
|
134
|
+
{ provider: "openai", matches: (d) => d === "async-openai" || d === "openai", confidence: "high" },
|
|
135
|
+
{ provider: "sentry", matches: (d) => d === "sentry", confidence: "high" },
|
|
136
|
+
{ provider: "stripe", matches: (d) => d === "async-stripe", confidence: "high" },
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
export async function scanSource(
|
|
140
|
+
cwd: string,
|
|
141
|
+
_opts: SourceScanOptions = {},
|
|
142
|
+
): Promise<SourceDetection[]> {
|
|
143
|
+
const hits = new Map<string, SourceDetection>();
|
|
144
|
+
|
|
145
|
+
const add = (provider: string, signal: string, confidence: DetectionConfidence) => {
|
|
146
|
+
const existing = hits.get(provider);
|
|
147
|
+
if (!existing) {
|
|
148
|
+
hits.set(provider, { provider, confidence, signals: [signal] });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!existing.signals.includes(signal)) existing.signals.push(signal);
|
|
152
|
+
existing.confidence = rankUp(existing.confidence, confidence);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
await scanPackageJson(cwd, add);
|
|
156
|
+
await scanPython(cwd, add);
|
|
157
|
+
await scanGoMod(cwd, add);
|
|
158
|
+
await scanCargoToml(cwd, add);
|
|
159
|
+
await scanConfigFiles(cwd, add);
|
|
160
|
+
await scanEnvExample(cwd, add);
|
|
161
|
+
|
|
162
|
+
return Array.from(hits.values()).sort((a, b) => {
|
|
163
|
+
const order: Record<DetectionConfidence, number> = { high: 0, medium: 1, low: 2 };
|
|
164
|
+
return order[a.confidence] - order[b.confidence] || a.provider.localeCompare(b.provider);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function rankUp(a: DetectionConfidence, b: DetectionConfidence): DetectionConfidence {
|
|
169
|
+
const order: DetectionConfidence[] = ["low", "medium", "high"];
|
|
170
|
+
return order[Math.max(order.indexOf(a), order.indexOf(b))];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function scanPackageJson(
|
|
174
|
+
cwd: string,
|
|
175
|
+
add: (p: string, s: string, c: DetectionConfidence) => void,
|
|
176
|
+
): Promise<void> {
|
|
177
|
+
const pkgPath = join(cwd, "package.json");
|
|
178
|
+
if (!existsSync(pkgPath)) return;
|
|
179
|
+
try {
|
|
180
|
+
const pkg = JSON.parse(await readFile(pkgPath, "utf-8")) as {
|
|
181
|
+
dependencies?: Record<string, string>;
|
|
182
|
+
devDependencies?: Record<string, string>;
|
|
183
|
+
};
|
|
184
|
+
const allDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
185
|
+
for (const dep of Object.keys(allDeps)) {
|
|
186
|
+
for (const rule of JS_RULES) {
|
|
187
|
+
if (rule.matches(dep)) {
|
|
188
|
+
add(rule.provider, `package.json:${dep}`, rule.confidence);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
/* malformed package.json — skip */
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function scanPython(
|
|
198
|
+
cwd: string,
|
|
199
|
+
add: (p: string, s: string, c: DetectionConfidence) => void,
|
|
200
|
+
): Promise<void> {
|
|
201
|
+
const reqPath = join(cwd, "requirements.txt");
|
|
202
|
+
if (existsSync(reqPath)) {
|
|
203
|
+
const text = await readFile(reqPath, "utf-8");
|
|
204
|
+
for (const line of text.split(/\r?\n/)) {
|
|
205
|
+
const pkg = line.split(/[=<>!~\[]/)[0].trim().toLowerCase();
|
|
206
|
+
if (!pkg || pkg.startsWith("#")) continue;
|
|
207
|
+
for (const rule of PY_RULES) {
|
|
208
|
+
if (rule.matches(pkg)) add(rule.provider, `requirements.txt:${pkg}`, rule.confidence);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const pyprojPath = join(cwd, "pyproject.toml");
|
|
213
|
+
if (existsSync(pyprojPath)) {
|
|
214
|
+
const text = await readFile(pyprojPath, "utf-8");
|
|
215
|
+
// Cheap string matching — the TOML module is fine but overkill here.
|
|
216
|
+
for (const rule of PY_RULES) {
|
|
217
|
+
for (const sample of candidatePyprojectNames(rule)) {
|
|
218
|
+
if (new RegExp(`["'\\s]${escapeRegExp(sample)}["'\\s=><~]`).test(text)) {
|
|
219
|
+
add(rule.provider, `pyproject.toml:${sample}`, rule.confidence);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function candidatePyprojectNames(rule: DepRule): string[] {
|
|
227
|
+
// The rule matches on lowercased dep names; reverse-derive a few
|
|
228
|
+
// canonical package names to scan pyproject text for.
|
|
229
|
+
const samples = [
|
|
230
|
+
"supabase",
|
|
231
|
+
"supabase-py",
|
|
232
|
+
"openai",
|
|
233
|
+
"anthropic",
|
|
234
|
+
"sentry-sdk",
|
|
235
|
+
"stripe",
|
|
236
|
+
"posthog",
|
|
237
|
+
"resend",
|
|
238
|
+
];
|
|
239
|
+
return samples.filter((s) => rule.matches(s));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function scanGoMod(
|
|
243
|
+
cwd: string,
|
|
244
|
+
add: (p: string, s: string, c: DetectionConfidence) => void,
|
|
245
|
+
): Promise<void> {
|
|
246
|
+
const path = join(cwd, "go.mod");
|
|
247
|
+
if (!existsSync(path)) return;
|
|
248
|
+
const text = await readFile(path, "utf-8");
|
|
249
|
+
for (const line of text.split(/\r?\n/)) {
|
|
250
|
+
const trimmed = line.trim();
|
|
251
|
+
if (!trimmed.startsWith("require") && !trimmed.match(/^github\.com/)) continue;
|
|
252
|
+
const tokens = trimmed.split(/\s+/);
|
|
253
|
+
for (const tok of tokens) {
|
|
254
|
+
for (const rule of GO_RULES) {
|
|
255
|
+
if (rule.matches(tok)) add(rule.provider, `go.mod:${tok}`, rule.confidence);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function scanCargoToml(
|
|
262
|
+
cwd: string,
|
|
263
|
+
add: (p: string, s: string, c: DetectionConfidence) => void,
|
|
264
|
+
): Promise<void> {
|
|
265
|
+
const path = join(cwd, "Cargo.toml");
|
|
266
|
+
if (!existsSync(path)) return;
|
|
267
|
+
const text = await readFile(path, "utf-8");
|
|
268
|
+
for (const rule of RUST_RULES) {
|
|
269
|
+
for (const sample of ["openai", "async-openai", "sentry", "async-stripe"]) {
|
|
270
|
+
if (!rule.matches(sample)) continue;
|
|
271
|
+
if (new RegExp(`^${escapeRegExp(sample)}\\s*=`, "m").test(text)) {
|
|
272
|
+
add(rule.provider, `Cargo.toml:${sample}`, rule.confidence);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function scanConfigFiles(
|
|
279
|
+
cwd: string,
|
|
280
|
+
add: (p: string, s: string, c: DetectionConfidence) => void,
|
|
281
|
+
): Promise<void> {
|
|
282
|
+
const files: Array<[string, string, DetectionConfidence]> = [
|
|
283
|
+
["vercel.json", "vercel", "high"],
|
|
284
|
+
[".vercel", "vercel", "medium"],
|
|
285
|
+
["fly.toml", "fly", "high"],
|
|
286
|
+
["railway.json", "railway", "high"],
|
|
287
|
+
["railway.toml", "railway", "high"],
|
|
288
|
+
["supabase/config.toml", "supabase", "high"],
|
|
289
|
+
["sentry.properties", "sentry", "high"],
|
|
290
|
+
["sentry.client.config.ts", "sentry", "high"],
|
|
291
|
+
["sentry.client.config.js", "sentry", "high"],
|
|
292
|
+
["sentry.server.config.ts", "sentry", "high"],
|
|
293
|
+
["sentry.server.config.js", "sentry", "high"],
|
|
294
|
+
["amplify.yml", "aws", "low"],
|
|
295
|
+
["wrangler.toml", "cloudflare", "high"],
|
|
296
|
+
["wrangler.jsonc", "cloudflare", "high"],
|
|
297
|
+
["wrangler.json", "cloudflare", "high"],
|
|
298
|
+
["convex/", "convex", "high"],
|
|
299
|
+
["convex.config.ts", "convex", "high"],
|
|
300
|
+
["firebase.json", "firebase", "high"],
|
|
301
|
+
[".firebaserc", "firebase", "high"],
|
|
302
|
+
["render.yaml", "render", "high"],
|
|
303
|
+
[".github/workflows", "github", "medium"],
|
|
304
|
+
];
|
|
305
|
+
for (const [relpath, provider, confidence] of files) {
|
|
306
|
+
const p = join(cwd, relpath);
|
|
307
|
+
if (existsSync(p)) add(provider, relpath, confidence);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// GitHub Actions: any YAML file is a decent signal.
|
|
311
|
+
const workflows = join(cwd, ".github", "workflows");
|
|
312
|
+
if (existsSync(workflows)) {
|
|
313
|
+
try {
|
|
314
|
+
const files = readdirSync(workflows);
|
|
315
|
+
for (const f of files) {
|
|
316
|
+
if (f.endsWith(".yml") || f.endsWith(".yaml")) {
|
|
317
|
+
add("github", `.github/workflows/${f}`, "high");
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} catch {
|
|
321
|
+
/* best-effort */
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function scanEnvExample(
|
|
327
|
+
cwd: string,
|
|
328
|
+
add: (p: string, s: string, c: DetectionConfidence) => void,
|
|
329
|
+
): Promise<void> {
|
|
330
|
+
for (const candidate of [".env.example", ".env.sample", ".env.template"]) {
|
|
331
|
+
const p = join(cwd, candidate);
|
|
332
|
+
if (!existsSync(p)) continue;
|
|
333
|
+
const text = await readFile(p, "utf-8");
|
|
334
|
+
const { parseEnv, detectProvider } = await import("./detect.ts");
|
|
335
|
+
for (const { key } of parseEnv(text)) {
|
|
336
|
+
const provider = detectProvider(key);
|
|
337
|
+
if (provider) add(provider, `${candidate}:${key}`, "medium");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function escapeRegExp(s: string): string {
|
|
343
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
344
|
+
}
|