@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,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified inference layer for Stack's AI recommender.
|
|
3
|
+
*
|
|
4
|
+
* Two backends, one interface:
|
|
5
|
+
*
|
|
6
|
+
* 1. ClaudeMCPBackend — "detection-only". When Stack runs as an MCP tool
|
|
7
|
+
* (inside Claude Code), we do NOT call an external LLM ourselves.
|
|
8
|
+
* Instead we return the catalog + project context as structured data
|
|
9
|
+
* and let Claude synthesize the Recipe. Stack does not own Anthropic
|
|
10
|
+
* API keys; those belong to Claude.
|
|
11
|
+
*
|
|
12
|
+
* 2. LocalSLMBackend — when Stack runs standalone (no MCP host), talk to
|
|
13
|
+
* a local OpenAI-compatible server. Default to LM Studio at :1234,
|
|
14
|
+
* fall back to Ollama at :11434. Zero remote calls, zero SDKs — raw
|
|
15
|
+
* `fetch` over `/v1/chat/completions`.
|
|
16
|
+
*
|
|
17
|
+
* Selection is driven by `STACK_MCP_MODE` / `MCP_CLIENT` env vars; the
|
|
18
|
+
* loose coupling keeps the CLI entrypoint dumb and lets the MCP server
|
|
19
|
+
* force-pick its own backend when needed.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { CircuitBreaker } from "./circuit-breaker.ts";
|
|
23
|
+
import { type CostTracker, defaultCostTracker } from "./cost-tracker.ts";
|
|
24
|
+
|
|
25
|
+
// ── Public types ───────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface InferenceRequest {
|
|
28
|
+
/** Original free-text query from the user. */
|
|
29
|
+
query: string;
|
|
30
|
+
/** Serialized catalog hits (name + blurb + howTo) to ground the model. */
|
|
31
|
+
catalogContext: string;
|
|
32
|
+
/** Optional detected project metadata — package.json name, frameworks. */
|
|
33
|
+
projectContext?: {
|
|
34
|
+
pkg?: string;
|
|
35
|
+
frameworks?: string[];
|
|
36
|
+
notes?: string;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RecipeProvider {
|
|
41
|
+
/** Provider name matching `catalog.ts` (e.g. "neon", "clerk"). */
|
|
42
|
+
name: string;
|
|
43
|
+
/** Short WHY from the model — shown to the user before apply. */
|
|
44
|
+
rationale: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RecipeDraft {
|
|
48
|
+
providers: RecipeProvider[];
|
|
49
|
+
notes?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type InferenceMode = "synth" | "mcp-delegated";
|
|
53
|
+
|
|
54
|
+
export interface InferenceUsage {
|
|
55
|
+
inputTokens: number;
|
|
56
|
+
outputTokens: number;
|
|
57
|
+
costUsd: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface InferenceResult {
|
|
61
|
+
mode: InferenceMode;
|
|
62
|
+
/** Populated when `mode === "synth"`. */
|
|
63
|
+
recipe?: RecipeDraft;
|
|
64
|
+
/** Raw assistant message — handy for debugging / telemetry. */
|
|
65
|
+
raw?: string;
|
|
66
|
+
/** Populated when `mode === "mcp-delegated"`: payload for Claude to synthesize. */
|
|
67
|
+
delegatedPayload?: unknown;
|
|
68
|
+
usage?: InferenceUsage;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface InferenceBackend {
|
|
72
|
+
/** Stable identifier — "claude-mcp", "lm-studio", "ollama". */
|
|
73
|
+
name: string;
|
|
74
|
+
infer(req: InferenceRequest): Promise<InferenceResult>;
|
|
75
|
+
healthy(): Promise<boolean>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class NoInferenceBackendError extends Error {
|
|
79
|
+
readonly code = "NO_INFERENCE_BACKEND" as const;
|
|
80
|
+
constructor(
|
|
81
|
+
message: string,
|
|
82
|
+
readonly attempted: string[] = [],
|
|
83
|
+
) {
|
|
84
|
+
super(message);
|
|
85
|
+
this.name = "NoInferenceBackendError";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Claude via MCP backend ─────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The MCP backend never calls an LLM itself — its "inference" step is to
|
|
93
|
+
* hand the grounding payload back to the MCP client (Claude Code). The
|
|
94
|
+
* tool response becomes Claude's synth input.
|
|
95
|
+
*/
|
|
96
|
+
export class ClaudeMCPBackend implements InferenceBackend {
|
|
97
|
+
readonly name = "claude-mcp";
|
|
98
|
+
|
|
99
|
+
async infer(req: InferenceRequest): Promise<InferenceResult> {
|
|
100
|
+
return {
|
|
101
|
+
mode: "mcp-delegated",
|
|
102
|
+
delegatedPayload: {
|
|
103
|
+
query: req.query,
|
|
104
|
+
catalogContext: req.catalogContext,
|
|
105
|
+
projectContext: req.projectContext ?? null,
|
|
106
|
+
// Claude-facing instruction — the MCP tool handler forwards this
|
|
107
|
+
// so the model knows exactly what shape to produce.
|
|
108
|
+
instruction:
|
|
109
|
+
"Using the provided catalogContext, pick 1-5 providers that best satisfy the query. Return a RecipeDraft { providers: [{ name, rationale }], notes? } where each name matches a provider in the catalog.",
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** MCP backend is always "healthy" — it's just a pass-through. */
|
|
115
|
+
async healthy(): Promise<boolean> {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Local SLM backend (OpenAI-compatible) ──────────────────────────────
|
|
121
|
+
|
|
122
|
+
export interface LocalSLMEndpoint {
|
|
123
|
+
name: string;
|
|
124
|
+
/** Base URL ending in `/v1` — we'll POST `/chat/completions`. */
|
|
125
|
+
baseUrl: string;
|
|
126
|
+
/** Model name to pass in the chat payload. */
|
|
127
|
+
model: string;
|
|
128
|
+
/** Optional bearer token. LM Studio + Ollama ignore this; leave blank. */
|
|
129
|
+
apiKey?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface LocalSLMBackendOptions {
|
|
133
|
+
endpoints?: LocalSLMEndpoint[];
|
|
134
|
+
/** Per-request timeout in ms (default 30s). */
|
|
135
|
+
timeoutMs?: number;
|
|
136
|
+
costTracker?: CostTracker;
|
|
137
|
+
circuitBreakerThreshold?: number;
|
|
138
|
+
circuitBreakerWindowMs?: number;
|
|
139
|
+
/** Override fetch — used by tests. Defaults to `globalThis.fetch`. */
|
|
140
|
+
fetchImpl?: typeof fetch;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const LM_STUDIO_URL = "http://localhost:1234/v1";
|
|
144
|
+
const OLLAMA_URL = "http://localhost:11434/v1";
|
|
145
|
+
|
|
146
|
+
const DEFAULT_LM_STUDIO: LocalSLMEndpoint = {
|
|
147
|
+
name: "lm-studio",
|
|
148
|
+
baseUrl: process.env.STACK_LM_URL ?? LM_STUDIO_URL,
|
|
149
|
+
model: process.env.STACK_LM_MODEL ?? "local-model",
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const DEFAULT_OLLAMA: LocalSLMEndpoint = {
|
|
153
|
+
name: "ollama",
|
|
154
|
+
baseUrl: OLLAMA_URL,
|
|
155
|
+
model: process.env.STACK_OLLAMA_MODEL ?? "llama3.1",
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const SYSTEM_PROMPT = [
|
|
159
|
+
"You are Stack, a CLI that recommends third-party providers for a dev project.",
|
|
160
|
+
"You will be given a catalog excerpt (grounding) and a user query.",
|
|
161
|
+
"Respond with ONLY a JSON object of shape:",
|
|
162
|
+
'{"providers":[{"name":"<catalog-name>","rationale":"<why>"}],"notes":"<optional>"}',
|
|
163
|
+
"Pick 1–5 providers. Every `name` MUST match an entry in the catalog excerpt exactly.",
|
|
164
|
+
"Do not wrap the JSON in markdown fences. Do not add prose before or after.",
|
|
165
|
+
].join(" ");
|
|
166
|
+
|
|
167
|
+
export class LocalSLMBackend implements InferenceBackend {
|
|
168
|
+
readonly name = "local-slm";
|
|
169
|
+
private readonly endpoints: LocalSLMEndpoint[];
|
|
170
|
+
private readonly timeoutMs: number;
|
|
171
|
+
private readonly costTracker: CostTracker;
|
|
172
|
+
private readonly breakers = new Map<string, CircuitBreaker>();
|
|
173
|
+
private readonly fetchImpl: typeof fetch;
|
|
174
|
+
private lastUsedEndpoint: string | null = null;
|
|
175
|
+
|
|
176
|
+
constructor(opts: LocalSLMBackendOptions = {}) {
|
|
177
|
+
this.endpoints = opts.endpoints ?? [DEFAULT_LM_STUDIO, DEFAULT_OLLAMA];
|
|
178
|
+
this.timeoutMs = opts.timeoutMs ?? 30_000;
|
|
179
|
+
this.costTracker = opts.costTracker ?? defaultCostTracker;
|
|
180
|
+
// `bind` keeps the right receiver without picking up Bun's
|
|
181
|
+
// `fetch.preconnect` augmentation, which is incompatible with our
|
|
182
|
+
// constructor-arg type.
|
|
183
|
+
this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
184
|
+
for (const ep of this.endpoints) {
|
|
185
|
+
this.breakers.set(
|
|
186
|
+
ep.name,
|
|
187
|
+
new CircuitBreaker({
|
|
188
|
+
threshold: opts.circuitBreakerThreshold ?? 3,
|
|
189
|
+
windowMs: opts.circuitBreakerWindowMs ?? 60_000,
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
// Local backends are free — register explicit zero rate so future
|
|
193
|
+
// non-local endpoints that reuse this tracker can't accidentally
|
|
194
|
+
// inherit our "it's free" assumption.
|
|
195
|
+
this.costTracker.setRate(ep.name, { inputPer1M: 0, outputPer1M: 0 });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Endpoint used by the most recent successful `infer` call. */
|
|
200
|
+
get lastEndpoint(): string | null {
|
|
201
|
+
return this.lastUsedEndpoint;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async healthy(): Promise<boolean> {
|
|
205
|
+
for (const ep of this.endpoints) {
|
|
206
|
+
if (await this.pingEndpoint(ep)) return true;
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async infer(req: InferenceRequest): Promise<InferenceResult> {
|
|
212
|
+
const attempted: string[] = [];
|
|
213
|
+
let lastError: Error | null = null;
|
|
214
|
+
|
|
215
|
+
for (const ep of this.endpoints) {
|
|
216
|
+
const breaker = this.breakers.get(ep.name);
|
|
217
|
+
if (!breaker) continue; // unreachable — constructor seeds a breaker per endpoint
|
|
218
|
+
if (!breaker.canRequest()) {
|
|
219
|
+
attempted.push(`${ep.name} (circuit-open)`);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
attempted.push(ep.name);
|
|
223
|
+
try {
|
|
224
|
+
const result = await this.callEndpoint(ep, req);
|
|
225
|
+
breaker.recordSuccess();
|
|
226
|
+
this.lastUsedEndpoint = ep.name;
|
|
227
|
+
return result;
|
|
228
|
+
} catch (err) {
|
|
229
|
+
breaker.recordFailure();
|
|
230
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
throw new NoInferenceBackendError(
|
|
235
|
+
`No local inference endpoint reachable${lastError ? ` (last error: ${lastError.message})` : ""}`,
|
|
236
|
+
attempted,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async callEndpoint(
|
|
241
|
+
ep: LocalSLMEndpoint,
|
|
242
|
+
req: InferenceRequest,
|
|
243
|
+
): Promise<InferenceResult> {
|
|
244
|
+
const controller = new AbortController();
|
|
245
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
246
|
+
|
|
247
|
+
const userPrompt = buildUserPrompt(req);
|
|
248
|
+
const body = {
|
|
249
|
+
model: ep.model,
|
|
250
|
+
messages: [
|
|
251
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
252
|
+
{ role: "user", content: userPrompt },
|
|
253
|
+
],
|
|
254
|
+
temperature: 0.2,
|
|
255
|
+
stream: false,
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const headers: Record<string, string> = {
|
|
259
|
+
"content-type": "application/json",
|
|
260
|
+
};
|
|
261
|
+
if (ep.apiKey) headers.authorization = `Bearer ${ep.apiKey}`;
|
|
262
|
+
|
|
263
|
+
let res: Response;
|
|
264
|
+
try {
|
|
265
|
+
res = await this.fetchImpl(`${ep.baseUrl}/chat/completions`, {
|
|
266
|
+
method: "POST",
|
|
267
|
+
headers,
|
|
268
|
+
body: JSON.stringify(body),
|
|
269
|
+
signal: controller.signal,
|
|
270
|
+
});
|
|
271
|
+
} finally {
|
|
272
|
+
clearTimeout(timer);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!res.ok) {
|
|
276
|
+
const text = await safeText(res);
|
|
277
|
+
throw new Error(`${ep.name} HTTP ${res.status}: ${text.slice(0, 200)}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const json = (await res.json()) as ChatCompletionResponse;
|
|
281
|
+
const raw = json.choices?.[0]?.message?.content ?? "";
|
|
282
|
+
if (!raw) {
|
|
283
|
+
throw new Error(`${ep.name} returned empty assistant message`);
|
|
284
|
+
}
|
|
285
|
+
const recipe = parseRecipeDraft(raw);
|
|
286
|
+
|
|
287
|
+
const inputTokens = json.usage?.prompt_tokens ?? estimateTokens(userPrompt);
|
|
288
|
+
const outputTokens = json.usage?.completion_tokens ?? estimateTokens(raw);
|
|
289
|
+
const usageRecord = this.costTracker.recordUsage(ep.name, inputTokens, outputTokens);
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
mode: "synth",
|
|
293
|
+
recipe,
|
|
294
|
+
raw,
|
|
295
|
+
usage: {
|
|
296
|
+
inputTokens,
|
|
297
|
+
outputTokens,
|
|
298
|
+
costUsd: usageRecord.costUsd,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private async pingEndpoint(ep: LocalSLMEndpoint): Promise<boolean> {
|
|
304
|
+
const controller = new AbortController();
|
|
305
|
+
const timer = setTimeout(() => controller.abort(), 2_000);
|
|
306
|
+
try {
|
|
307
|
+
const res = await this.fetchImpl(`${ep.baseUrl}/models`, {
|
|
308
|
+
method: "GET",
|
|
309
|
+
signal: controller.signal,
|
|
310
|
+
});
|
|
311
|
+
return res.ok;
|
|
312
|
+
} catch {
|
|
313
|
+
return false;
|
|
314
|
+
} finally {
|
|
315
|
+
clearTimeout(timer);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── Selection ──────────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
export interface GetInferenceBackendOptions {
|
|
323
|
+
/** Force local even when MCP env vars are set. */
|
|
324
|
+
preferLocal?: boolean;
|
|
325
|
+
/** Injected for tests. */
|
|
326
|
+
env?: Record<string, string | undefined>;
|
|
327
|
+
localOptions?: LocalSLMBackendOptions;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function isMcpMode(env: Record<string, string | undefined>): boolean {
|
|
331
|
+
return Boolean(env.STACK_MCP_MODE || env.MCP_CLIENT);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Pick the right backend for the current process.
|
|
336
|
+
*
|
|
337
|
+
* Decision order:
|
|
338
|
+
* 1. `preferLocal` — always local (CLI `stack recommend --local` path).
|
|
339
|
+
* 2. MCP env vars set → Claude delegated mode.
|
|
340
|
+
* 3. Otherwise → local SLM (fail loudly if unreachable).
|
|
341
|
+
*
|
|
342
|
+
* We eagerly health-check the local path so that callers get a typed
|
|
343
|
+
* `NoInferenceBackendError` up front rather than a cryptic fetch error
|
|
344
|
+
* deep inside the inference call.
|
|
345
|
+
*/
|
|
346
|
+
export async function getInferenceBackend(
|
|
347
|
+
opts: GetInferenceBackendOptions = {},
|
|
348
|
+
): Promise<InferenceBackend> {
|
|
349
|
+
const env = opts.env ?? (process.env as Record<string, string | undefined>);
|
|
350
|
+
|
|
351
|
+
if (!opts.preferLocal && isMcpMode(env)) {
|
|
352
|
+
return new ClaudeMCPBackend();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const local = new LocalSLMBackend(opts.localOptions);
|
|
356
|
+
if (!(await local.healthy())) {
|
|
357
|
+
throw new NoInferenceBackendError(
|
|
358
|
+
`No local inference endpoint reachable. Start LM Studio (${LM_STUDIO_URL.replace("/v1", "")}) or Ollama (${OLLAMA_URL.replace("/v1", "")}), or run Stack inside Claude Code (MCP mode).`,
|
|
359
|
+
(opts.localOptions?.endpoints ?? [DEFAULT_LM_STUDIO, DEFAULT_OLLAMA]).map((e) => e.name),
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
return local;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
interface ChatCompletionResponse {
|
|
368
|
+
choices?: Array<{ message?: { content?: string } }>;
|
|
369
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function buildUserPrompt(req: InferenceRequest): string {
|
|
373
|
+
const parts: string[] = [];
|
|
374
|
+
parts.push(`Query: ${req.query}`);
|
|
375
|
+
if (req.projectContext) {
|
|
376
|
+
parts.push(`Project context: ${JSON.stringify(req.projectContext)}`);
|
|
377
|
+
}
|
|
378
|
+
parts.push("Catalog excerpt:");
|
|
379
|
+
parts.push(req.catalogContext);
|
|
380
|
+
parts.push(
|
|
381
|
+
'Respond with JSON only — shape: {"providers":[{"name":"","rationale":""}],"notes":""}',
|
|
382
|
+
);
|
|
383
|
+
return parts.join("\n\n");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Extract the first JSON object from an assistant message. Small SLMs often
|
|
388
|
+
* wrap output in ```json fences or pad with prose despite instructions —
|
|
389
|
+
* be tolerant without falling back to `eval`.
|
|
390
|
+
*/
|
|
391
|
+
export function parseRecipeDraft(raw: string): RecipeDraft {
|
|
392
|
+
const candidate = extractJsonObject(raw);
|
|
393
|
+
if (!candidate) {
|
|
394
|
+
throw new Error("Inference response did not contain a JSON object");
|
|
395
|
+
}
|
|
396
|
+
let parsed: unknown;
|
|
397
|
+
try {
|
|
398
|
+
parsed = JSON.parse(candidate);
|
|
399
|
+
} catch (err) {
|
|
400
|
+
throw new Error(`Inference response was not valid JSON: ${(err as Error).message}`);
|
|
401
|
+
}
|
|
402
|
+
if (!parsed || typeof parsed !== "object") {
|
|
403
|
+
throw new Error("Inference response JSON was not an object");
|
|
404
|
+
}
|
|
405
|
+
const obj = parsed as Record<string, unknown>;
|
|
406
|
+
const providersRaw = obj.providers;
|
|
407
|
+
if (!Array.isArray(providersRaw)) {
|
|
408
|
+
throw new Error("Inference response missing `providers` array");
|
|
409
|
+
}
|
|
410
|
+
const providers: RecipeProvider[] = [];
|
|
411
|
+
for (const entry of providersRaw) {
|
|
412
|
+
if (!entry || typeof entry !== "object") continue;
|
|
413
|
+
const e = entry as Record<string, unknown>;
|
|
414
|
+
const name = typeof e.name === "string" ? e.name.trim() : "";
|
|
415
|
+
const rationale = typeof e.rationale === "string" ? e.rationale.trim() : "";
|
|
416
|
+
if (!name) continue;
|
|
417
|
+
providers.push({ name, rationale });
|
|
418
|
+
}
|
|
419
|
+
if (providers.length === 0) {
|
|
420
|
+
throw new Error("Inference response contained no usable providers");
|
|
421
|
+
}
|
|
422
|
+
const notes = typeof obj.notes === "string" ? obj.notes : undefined;
|
|
423
|
+
return { providers, notes };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function extractJsonObject(raw: string): string | null {
|
|
427
|
+
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
428
|
+
const scan = fenced?.[1] ?? raw;
|
|
429
|
+
const start = scan.indexOf("{");
|
|
430
|
+
if (start === -1) return null;
|
|
431
|
+
let depth = 0;
|
|
432
|
+
let inString = false;
|
|
433
|
+
let isEscaped = false;
|
|
434
|
+
for (let i = start; i < scan.length; i++) {
|
|
435
|
+
const ch = scan.charAt(i);
|
|
436
|
+
if (isEscaped) {
|
|
437
|
+
isEscaped = false;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (ch === "\\") {
|
|
441
|
+
isEscaped = true;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (ch === '"') {
|
|
445
|
+
inString = !inString;
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (inString) continue;
|
|
449
|
+
if (ch === "{") depth++;
|
|
450
|
+
else if (ch === "}") {
|
|
451
|
+
depth--;
|
|
452
|
+
if (depth === 0) return scan.slice(start, i + 1);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Rough character-based token estimate — used when the server omits usage. */
|
|
459
|
+
function estimateTokens(text: string): number {
|
|
460
|
+
// ~4 chars/token is the standard back-of-envelope for English + code.
|
|
461
|
+
return Math.max(1, Math.ceil(text.length / 4));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function safeText(res: Response): Promise<string> {
|
|
465
|
+
try {
|
|
466
|
+
return await res.text();
|
|
467
|
+
} catch {
|
|
468
|
+
return "";
|
|
469
|
+
}
|
|
470
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recipe → Phantom envelope + webhook stub materializer.
|
|
3
|
+
*
|
|
4
|
+
* For each provider in a Recipe:
|
|
5
|
+
* 1. Pre-create Phantom envelopes (one per declared secret) so `stack add`
|
|
6
|
+
* slots its real values into an already-rotating slot.
|
|
7
|
+
* 2. For webhook-capable providers (Stripe, Clerk, Supabase, GitHub),
|
|
8
|
+
* scaffold a commented-out handler stub in `.stack/webhooks/<provider>.ts`
|
|
9
|
+
* so the user has somewhere concrete to land the signature verification.
|
|
10
|
+
*
|
|
11
|
+
* This is the Stack moat: `stack apply` ends with envelopes + stubs waiting
|
|
12
|
+
* for the real credentials, which the provider-specific `addCommand` flow
|
|
13
|
+
* then fills in. An opt-out (`--no-wire`) exists for users who want pure
|
|
14
|
+
* provisioning without Phantom-assisted rotation.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
18
|
+
import { dirname, resolve } from "node:path";
|
|
19
|
+
import { findProviderRef } from "../catalog.ts";
|
|
20
|
+
import {
|
|
21
|
+
assertPhantomInstalled,
|
|
22
|
+
exec as phantomExec,
|
|
23
|
+
PhantomExecResult,
|
|
24
|
+
} from "../phantom.ts";
|
|
25
|
+
import { PhantomNotInstalledError } from "../errors.ts";
|
|
26
|
+
import type { Recipe } from "./recipe.ts";
|
|
27
|
+
|
|
28
|
+
export interface WireResult {
|
|
29
|
+
/** `PROVIDER:SECRET_NAME` entries successfully added to Phantom. */
|
|
30
|
+
envelopes: string[];
|
|
31
|
+
/** Absolute paths of webhook stub files created. */
|
|
32
|
+
webhooks: string[];
|
|
33
|
+
/** Names of providers skipped (phantom missing, unknown provider, etc). */
|
|
34
|
+
skipped: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const PLACEHOLDER = "TODO_STACK_APPLY";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Providers with webhooks worth pre-wiring. Keep this list tight — each entry
|
|
41
|
+
* gets a hand-maintained stub in `webhookStubFor`. Adding a new provider here
|
|
42
|
+
* without also adding its stub produces an empty file, which is worse than
|
|
43
|
+
* no file.
|
|
44
|
+
*/
|
|
45
|
+
const WEBHOOK_PROVIDERS = new Set(["stripe", "clerk", "supabase", "github"]);
|
|
46
|
+
|
|
47
|
+
export async function wirePhantomForRecipe(
|
|
48
|
+
recipe: Recipe,
|
|
49
|
+
opts: { cwd?: string; noWire?: boolean } = {},
|
|
50
|
+
): Promise<WireResult> {
|
|
51
|
+
const result: WireResult = { envelopes: [], webhooks: [], skipped: [] };
|
|
52
|
+
if (opts.noWire) return result;
|
|
53
|
+
|
|
54
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
await assertPhantomInstalled();
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err instanceof PhantomNotInstalledError) {
|
|
60
|
+
// Phantom missing → skip envelope creation but still drop webhook stubs
|
|
61
|
+
// (they're just files). Surface the provider names so the CLI can tell
|
|
62
|
+
// the user why nothing landed in the vault.
|
|
63
|
+
for (const p of recipe.providers) result.skipped.push(p.name);
|
|
64
|
+
for (const p of recipe.providers) {
|
|
65
|
+
if (WEBHOOK_PROVIDERS.has(p.name)) {
|
|
66
|
+
const path = await writeWebhookStub(cwd, p.name);
|
|
67
|
+
result.webhooks.push(path);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const { name } of recipe.providers) {
|
|
76
|
+
const ref = findProviderRef(name);
|
|
77
|
+
if (!ref) {
|
|
78
|
+
result.skipped.push(name);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const secret of ref.secrets) {
|
|
83
|
+
const added = await addEnvelope(secret, cwd);
|
|
84
|
+
if (added) result.envelopes.push(`${name}:${secret}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (WEBHOOK_PROVIDERS.has(name)) {
|
|
88
|
+
const path = await writeWebhookStub(cwd, name);
|
|
89
|
+
result.webhooks.push(path);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Shell `phantom add <KEY> <placeholder>` with opportunistic `--rotate`.
|
|
98
|
+
* Try with `--rotate` first; if the running Phantom doesn't support the flag,
|
|
99
|
+
* fall back to a flagless add. Returns true when the envelope was written.
|
|
100
|
+
*/
|
|
101
|
+
async function addEnvelope(key: string, cwd: string): Promise<boolean> {
|
|
102
|
+
const rotateAttempt = await tryExec(["add", key, PLACEHOLDER, "--rotate"], cwd);
|
|
103
|
+
if (rotateAttempt.code === 0) return true;
|
|
104
|
+
|
|
105
|
+
// Heuristic for "Phantom rejected --rotate because it's an older build":
|
|
106
|
+
// stderr mentions the flag name or "unknown option". On any other failure,
|
|
107
|
+
// we already logged it via stderr — the caller's noWire path can skip.
|
|
108
|
+
const stderrLower = rotateAttempt.stderr.toLowerCase();
|
|
109
|
+
const looksLikeUnknownFlag =
|
|
110
|
+
stderrLower.includes("--rotate") ||
|
|
111
|
+
stderrLower.includes("unknown option") ||
|
|
112
|
+
stderrLower.includes("unrecognized");
|
|
113
|
+
|
|
114
|
+
if (!looksLikeUnknownFlag) return false;
|
|
115
|
+
|
|
116
|
+
const fallback = await tryExec(["add", key, PLACEHOLDER], cwd);
|
|
117
|
+
return fallback.code === 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function tryExec(args: string[], cwd: string): Promise<PhantomExecResult> {
|
|
121
|
+
try {
|
|
122
|
+
return await phantomExec(args, { cwd, allowFailure: true });
|
|
123
|
+
} catch {
|
|
124
|
+
return { stdout: "", stderr: "", code: 1 };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function writeWebhookStub(cwd: string, provider: string): Promise<string> {
|
|
129
|
+
const path = resolve(cwd, ".stack", "webhooks", `${provider}.ts`);
|
|
130
|
+
await mkdir(dirname(path), { recursive: true });
|
|
131
|
+
await writeFile(path, webhookStubFor(provider), "utf-8");
|
|
132
|
+
return path;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function webhookStubFor(provider: string): string {
|
|
136
|
+
const base = `// Auto-generated by \`stack apply\` — webhook handler stub for ${provider}.
|
|
137
|
+
// Fill in the secret retrieval + signature verification before deploying.
|
|
138
|
+
//
|
|
139
|
+
// Secret envelope lives in Phantom; read it at call time with:
|
|
140
|
+
// phantom reveal ${webhookSecretKeyFor(provider)}
|
|
141
|
+
//
|
|
142
|
+
// export async function handle${capitalize(provider)}Webhook(req: Request): Promise<Response> {
|
|
143
|
+
// const signature = req.headers.get("${signatureHeaderFor(provider)}");
|
|
144
|
+
// const body = await req.text();
|
|
145
|
+
// // TODO: verify(signature, body) before trusting.
|
|
146
|
+
// return new Response("ok", { status: 200 });
|
|
147
|
+
// }
|
|
148
|
+
`;
|
|
149
|
+
return base;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function webhookSecretKeyFor(provider: string): string {
|
|
153
|
+
switch (provider) {
|
|
154
|
+
case "stripe":
|
|
155
|
+
return "STRIPE_WEBHOOK_SECRET";
|
|
156
|
+
case "clerk":
|
|
157
|
+
return "CLERK_WEBHOOK_SECRET";
|
|
158
|
+
case "supabase":
|
|
159
|
+
return "SUPABASE_WEBHOOK_SECRET";
|
|
160
|
+
case "github":
|
|
161
|
+
return "GITHUB_WEBHOOK_SECRET";
|
|
162
|
+
default:
|
|
163
|
+
return `${provider.toUpperCase()}_WEBHOOK_SECRET`;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function signatureHeaderFor(provider: string): string {
|
|
168
|
+
switch (provider) {
|
|
169
|
+
case "stripe":
|
|
170
|
+
return "stripe-signature";
|
|
171
|
+
case "clerk":
|
|
172
|
+
return "svix-signature";
|
|
173
|
+
case "supabase":
|
|
174
|
+
return "x-supabase-signature";
|
|
175
|
+
case "github":
|
|
176
|
+
return "x-hub-signature-256";
|
|
177
|
+
default:
|
|
178
|
+
return "x-webhook-signature";
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function capitalize(s: string): string {
|
|
183
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
184
|
+
}
|