@ashlr/stack-core 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +26 -15
- package/package.json +1 -1
- package/src/__tests__/_harness.ts +67 -7
- package/src/__tests__/agent-onboard.test.ts +279 -0
- package/src/__tests__/api-key-providers.test.ts +6 -2
- package/src/__tests__/base-urls.test.ts +208 -0
- package/src/__tests__/catalog-index.test.ts +14 -20
- package/src/__tests__/catalog.test.ts +31 -0
- package/src/__tests__/cloudflare-provider.test.ts +32 -10
- package/src/__tests__/completion-ci.test.ts +239 -23
- package/src/__tests__/config-own-services.test.ts +183 -0
- package/src/__tests__/config-split.test.ts +272 -2
- package/src/__tests__/config.test.ts +94 -4
- package/src/__tests__/credential-staging.test.ts +660 -0
- package/src/__tests__/deps.test.ts +2 -1
- package/src/__tests__/detect-source.test.ts +183 -4
- package/src/__tests__/fixtures/stack-manifest.v1.json +10 -0
- package/src/__tests__/github-provider.test.ts +3 -2
- package/src/__tests__/helpers.test.ts +24 -4
- package/src/__tests__/http.test.ts +2 -2
- package/src/__tests__/import-integration.test.ts +18 -4
- package/src/__tests__/inference.test.ts +57 -13
- package/src/__tests__/interactive-prompt.test.ts +106 -0
- package/src/__tests__/mcp-mirror.test.ts +116 -0
- package/src/__tests__/neon-provider.test.ts +136 -3
- package/src/__tests__/new-providers.test.ts +470 -0
- package/src/__tests__/no-orphan-modules.test.ts +114 -0
- package/src/__tests__/oauth-port.test.ts +84 -0
- package/src/__tests__/openai-provider.test.ts +1 -1
- package/src/__tests__/phantom-contract.test.ts +177 -0
- package/src/__tests__/phantom-harness.test.ts +226 -8
- package/src/__tests__/phantom-process.test.ts +135 -0
- package/src/__tests__/phantom-project-preflight.test.ts +118 -0
- package/src/__tests__/phantom-wire.test.ts +403 -22
- package/src/__tests__/pipeline-integration.test.ts +33 -1
- package/src/__tests__/pipeline-recovery.test.ts +29 -28
- package/src/__tests__/pipeline-rollback.test.ts +509 -0
- package/src/__tests__/pipeline.test.ts +3 -1
- package/src/__tests__/pm-detect.test.ts +113 -0
- package/src/__tests__/provider-deprovision.test.ts +269 -0
- package/src/__tests__/provider-parity.test.ts +46 -0
- package/src/__tests__/publish-script.test.ts +704 -0
- package/src/__tests__/recipe.test.ts +27 -2
- package/src/__tests__/recommend-apply-e2e.test.ts +29 -27
- package/src/__tests__/registry.test.ts +11 -2
- package/src/__tests__/release-artifacts.test.ts +55 -0
- package/src/__tests__/scan-missing-dir.test.ts +45 -0
- package/src/__tests__/secret-redaction.test.ts +10 -10
- package/src/__tests__/security.test.ts +9 -10
- package/src/__tests__/stack-manifest.test.ts +38 -0
- package/src/__tests__/supabase-provider.test.ts +6 -4
- package/src/__tests__/swap.test.ts +114 -0
- package/src/__tests__/telemetry.test.ts +354 -0
- package/src/__tests__/template-litellm-gateway.test.ts +70 -0
- package/src/__tests__/template-temporal-worker-phantom.test.ts +45 -0
- package/src/__tests__/templates.test.ts +1 -1
- package/src/__tests__/turso-provider.test.ts +2 -5
- package/src/agent-onboard.ts +346 -0
- package/src/ai/catalog-index.ts +68 -10
- package/src/ai/circuit-breaker.ts +3 -6
- package/src/ai/cost-tracker.ts +6 -15
- package/src/ai/inference.ts +19 -11
- package/src/ai/phantom-wire.ts +73 -58
- package/src/ai/recipe.ts +69 -0
- package/src/base-urls.ts +71 -0
- package/src/catalog.ts +281 -2
- package/src/config.ts +31 -18
- package/src/detect-source.ts +50 -9
- package/src/detect.ts +13 -5
- package/src/errors.ts +6 -0
- package/src/http.ts +6 -5
- package/src/index.ts +16 -0
- package/src/mcp-writer.ts +97 -2
- package/src/oauth.ts +85 -53
- package/src/phantom-process.ts +72 -0
- package/src/phantom.ts +319 -30
- package/src/pipeline.ts +177 -68
- package/src/pm-detect.ts +56 -0
- package/src/providers/_api-key.ts +18 -17
- package/src/providers/_base.ts +28 -1
- package/src/providers/_credential-stage.ts +237 -0
- package/src/providers/_helpers.ts +55 -9
- package/src/providers/anthropic.ts +2 -1
- package/src/providers/auth0.ts +27 -0
- package/src/providers/aws.ts +32 -33
- package/src/providers/cloudflare.ts +18 -37
- package/src/providers/convex.ts +1 -2
- package/src/providers/datadog.ts +29 -0
- package/src/providers/digitalocean.ts +30 -0
- package/src/providers/gcp.ts +36 -0
- package/src/providers/github.ts +37 -35
- package/src/providers/grafana.ts +29 -0
- package/src/providers/hetzner.ts +29 -0
- package/src/providers/index.ts +13 -1
- package/src/providers/launchdarkly.ts +33 -0
- package/src/providers/mixpanel.ts +37 -0
- package/src/providers/neon.ts +50 -26
- package/src/providers/openai.ts +2 -1
- package/src/providers/plausible.ts +26 -0
- package/src/providers/sentry.ts +17 -17
- package/src/providers/stripe.ts +1 -2
- package/src/providers/supabase.ts +66 -37
- package/src/providers/turso.ts +49 -38
- package/src/providers/vercel.ts +50 -20
- package/src/providers/workos.ts +27 -0
- package/src/registry.ts +7 -11
- package/src/stack-manifest.ts +39 -0
- package/src/swap.ts +108 -0
- package/src/telemetry.ts +267 -0
- package/src/templates.ts +6 -2
package/src/config.ts
CHANGED
|
@@ -96,10 +96,7 @@ export function hasConfig(cwd: string = process.cwd()): boolean {
|
|
|
96
96
|
|
|
97
97
|
interface ShapeFile {
|
|
98
98
|
stack: { version: string; template?: string };
|
|
99
|
-
services: Record<
|
|
100
|
-
string,
|
|
101
|
-
{ provider: string; secrets: string[]; mcp?: string }
|
|
102
|
-
>;
|
|
99
|
+
services: Record<string, { provider: string; secrets: string[]; mcp?: string }>;
|
|
103
100
|
environments: EnvironmentEntry[];
|
|
104
101
|
}
|
|
105
102
|
|
|
@@ -154,10 +151,7 @@ export async function readConfig(cwd: string = process.cwd()): Promise<StackConf
|
|
|
154
151
|
// Write: split and route to the two files, auto-gitignore the local one
|
|
155
152
|
// ---------------------------------------------------------------------------
|
|
156
153
|
|
|
157
|
-
export async function writeConfig(
|
|
158
|
-
config: StackConfig,
|
|
159
|
-
cwd: string = process.cwd(),
|
|
160
|
-
): Promise<void> {
|
|
154
|
+
export async function writeConfig(config: StackConfig, cwd: string = process.cwd()): Promise<void> {
|
|
161
155
|
const { shape, instance } = split(config);
|
|
162
156
|
|
|
163
157
|
const shapeHeader =
|
|
@@ -169,8 +163,7 @@ export async function writeConfig(
|
|
|
169
163
|
"utf-8",
|
|
170
164
|
);
|
|
171
165
|
|
|
172
|
-
const instanceHeader =
|
|
173
|
-
"# Ashlr Stack — local instance data. Auto-generated; do not commit.\n";
|
|
166
|
+
const instanceHeader = "# Ashlr Stack — local instance data. Auto-generated; do not commit.\n";
|
|
174
167
|
await writeFile(
|
|
175
168
|
resolveLocalConfigPath(cwd),
|
|
176
169
|
instanceHeader + stringifyToml(instance as unknown as Record<string, unknown>),
|
|
@@ -193,6 +186,25 @@ export async function writeConfig(
|
|
|
193
186
|
// Helpers
|
|
194
187
|
// ---------------------------------------------------------------------------
|
|
195
188
|
|
|
189
|
+
/**
|
|
190
|
+
* The service named `name`, or undefined. An own-property lookup, so a name
|
|
191
|
+
* like `constructor` or `toString` that is not configured is not mistaken for
|
|
192
|
+
* an inherited Object.prototype member.
|
|
193
|
+
*/
|
|
194
|
+
export function getService(config: StackConfig, name: string): ServiceEntry | undefined {
|
|
195
|
+
return Object.hasOwn(config.services, name) ? config.services[name] : undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function setOwnService<T>(services: Record<string, T>, name: string, entry: T): void {
|
|
199
|
+
// Service names are data, including __proto__; never invoke prototype setters.
|
|
200
|
+
Object.defineProperty(services, name, {
|
|
201
|
+
value: entry,
|
|
202
|
+
enumerable: true,
|
|
203
|
+
configurable: true,
|
|
204
|
+
writable: true,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
196
208
|
function split(config: StackConfig): { shape: ShapeFile; instance: InstanceFile } {
|
|
197
209
|
const shape: ShapeFile = {
|
|
198
210
|
stack: {
|
|
@@ -207,18 +219,18 @@ function split(config: StackConfig): { shape: ShapeFile; instance: InstanceFile
|
|
|
207
219
|
services: {},
|
|
208
220
|
};
|
|
209
221
|
for (const [name, entry] of Object.entries(config.services)) {
|
|
210
|
-
shape.services
|
|
222
|
+
setOwnService(shape.services, name, {
|
|
211
223
|
provider: entry.provider,
|
|
212
224
|
secrets: entry.secrets,
|
|
213
225
|
...(entry.mcp ? { mcp: entry.mcp } : {}),
|
|
214
|
-
};
|
|
226
|
+
});
|
|
215
227
|
const inst: InstanceFile["services"][string] = {};
|
|
216
228
|
if (entry.resource_id) inst.resource_id = entry.resource_id;
|
|
217
229
|
if (entry.region) inst.region = entry.region;
|
|
218
230
|
if (entry.meta) inst.meta = entry.meta;
|
|
219
231
|
if (entry.created_at) inst.created_at = entry.created_at;
|
|
220
232
|
if (entry.created_by) inst.created_by = entry.created_by;
|
|
221
|
-
if (Object.keys(inst).length > 0) instance.services
|
|
233
|
+
if (Object.keys(inst).length > 0) setOwnService(instance.services, name, inst);
|
|
222
234
|
}
|
|
223
235
|
return { shape, instance };
|
|
224
236
|
}
|
|
@@ -226,8 +238,9 @@ function split(config: StackConfig): { shape: ShapeFile; instance: InstanceFile
|
|
|
226
238
|
function merge(shape: ShapeFile, instance: InstanceFile): StackConfig {
|
|
227
239
|
const services: Record<string, ServiceEntry> = {};
|
|
228
240
|
for (const [name, s] of Object.entries(shape.services ?? {})) {
|
|
229
|
-
const i =
|
|
230
|
-
|
|
241
|
+
const i =
|
|
242
|
+
instance.services && Object.hasOwn(instance.services, name) ? instance.services[name] : {};
|
|
243
|
+
setOwnService(services, name, {
|
|
231
244
|
provider: s.provider,
|
|
232
245
|
secrets: s.secrets,
|
|
233
246
|
...(s.mcp ? { mcp: s.mcp } : {}),
|
|
@@ -236,7 +249,7 @@ function merge(shape: ShapeFile, instance: InstanceFile): StackConfig {
|
|
|
236
249
|
...(i.meta ? { meta: i.meta } : {}),
|
|
237
250
|
created_at: i.created_at ?? "1970-01-01T00:00:00Z",
|
|
238
251
|
...(i.created_by ? { created_by: i.created_by } : {}),
|
|
239
|
-
};
|
|
252
|
+
});
|
|
240
253
|
}
|
|
241
254
|
return {
|
|
242
255
|
stack: {
|
|
@@ -265,7 +278,7 @@ function normalizeLegacy(raw: Record<string, unknown>): StackConfig {
|
|
|
265
278
|
environments,
|
|
266
279
|
};
|
|
267
280
|
for (const [name, entry] of Object.entries(services)) {
|
|
268
|
-
out.services
|
|
281
|
+
setOwnService(out.services, name, {
|
|
269
282
|
provider: (entry.provider as string) ?? name,
|
|
270
283
|
secrets: (entry.secrets as string[]) ?? [],
|
|
271
284
|
...(typeof entry.mcp === "string" ? { mcp: entry.mcp } : {}),
|
|
@@ -274,7 +287,7 @@ function normalizeLegacy(raw: Record<string, unknown>): StackConfig {
|
|
|
274
287
|
...(entry.meta ? { meta: entry.meta as Record<string, unknown> } : {}),
|
|
275
288
|
created_at: (entry.created_at as string) ?? new Date().toISOString(),
|
|
276
289
|
...(typeof entry.created_by === "string" ? { created_by: entry.created_by } : {}),
|
|
277
|
-
};
|
|
290
|
+
});
|
|
278
291
|
}
|
|
279
292
|
return out;
|
|
280
293
|
}
|
package/src/detect-source.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { SourceDirectoryNotFoundError } from "./errors.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Source-level provider detection. Reads package.json / requirements.txt /
|
|
@@ -37,7 +38,8 @@ const JS_RULES: DepRule[] = [
|
|
|
37
38
|
{ provider: "neon", matches: (d) => d.startsWith("@neondatabase/"), confidence: "high" },
|
|
38
39
|
{
|
|
39
40
|
provider: "vercel",
|
|
40
|
-
matches: (d) =>
|
|
41
|
+
matches: (d) =>
|
|
42
|
+
d === "@vercel/analytics" || d === "@vercel/speed-insights" || d === "@vercel/kv",
|
|
41
43
|
confidence: "medium",
|
|
42
44
|
},
|
|
43
45
|
{ provider: "sentry", matches: (d) => d.startsWith("@sentry/"), confidence: "high" },
|
|
@@ -48,7 +50,11 @@ const JS_RULES: DepRule[] = [
|
|
|
48
50
|
},
|
|
49
51
|
{ provider: "openai", matches: (d) => d === "openai", confidence: "high" },
|
|
50
52
|
{ provider: "anthropic", matches: (d) => d === "@anthropic-ai/sdk", confidence: "high" },
|
|
51
|
-
{
|
|
53
|
+
{
|
|
54
|
+
provider: "stripe",
|
|
55
|
+
matches: (d) => d === "stripe" || d === "@stripe/stripe-js",
|
|
56
|
+
confidence: "high",
|
|
57
|
+
},
|
|
52
58
|
{ provider: "clerk", matches: (d) => d.startsWith("@clerk/"), confidence: "high" },
|
|
53
59
|
{ provider: "resend", matches: (d) => d === "resend", confidence: "high" },
|
|
54
60
|
{
|
|
@@ -102,7 +108,11 @@ const JS_RULES: DepRule[] = [
|
|
|
102
108
|
];
|
|
103
109
|
|
|
104
110
|
const PY_RULES: DepRule[] = [
|
|
105
|
-
{
|
|
111
|
+
{
|
|
112
|
+
provider: "supabase",
|
|
113
|
+
matches: (d) => d === "supabase" || d === "supabase-py",
|
|
114
|
+
confidence: "high",
|
|
115
|
+
},
|
|
106
116
|
{ provider: "openai", matches: (d) => d === "openai", confidence: "high" },
|
|
107
117
|
{ provider: "anthropic", matches: (d) => d === "anthropic", confidence: "high" },
|
|
108
118
|
{ provider: "sentry", matches: (d) => d === "sentry-sdk", confidence: "high" },
|
|
@@ -120,18 +130,34 @@ const GO_RULES: DepRule[] = [
|
|
|
120
130
|
matches: (d) => d.startsWith("github.com/supabase-community/"),
|
|
121
131
|
confidence: "high",
|
|
122
132
|
},
|
|
123
|
-
{
|
|
133
|
+
{
|
|
134
|
+
provider: "openai",
|
|
135
|
+
matches: (d) => d === "github.com/sashabaranov/go-openai",
|
|
136
|
+
confidence: "high",
|
|
137
|
+
},
|
|
124
138
|
{
|
|
125
139
|
provider: "anthropic",
|
|
126
140
|
matches: (d) => d.startsWith("github.com/liushuangls/go-anthropic"),
|
|
127
141
|
confidence: "medium",
|
|
128
142
|
},
|
|
129
|
-
{
|
|
130
|
-
|
|
143
|
+
{
|
|
144
|
+
provider: "sentry",
|
|
145
|
+
matches: (d) => d === "github.com/getsentry/sentry-go",
|
|
146
|
+
confidence: "high",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
provider: "stripe",
|
|
150
|
+
matches: (d) => d.startsWith("github.com/stripe/stripe-go"),
|
|
151
|
+
confidence: "high",
|
|
152
|
+
},
|
|
131
153
|
];
|
|
132
154
|
|
|
133
155
|
const RUST_RULES: DepRule[] = [
|
|
134
|
-
{
|
|
156
|
+
{
|
|
157
|
+
provider: "openai",
|
|
158
|
+
matches: (d) => d === "async-openai" || d === "openai",
|
|
159
|
+
confidence: "high",
|
|
160
|
+
},
|
|
135
161
|
{ provider: "sentry", matches: (d) => d === "sentry", confidence: "high" },
|
|
136
162
|
{ provider: "stripe", matches: (d) => d === "async-stripe", confidence: "high" },
|
|
137
163
|
];
|
|
@@ -140,6 +166,18 @@ export async function scanSource(
|
|
|
140
166
|
cwd: string,
|
|
141
167
|
_opts: SourceScanOptions = {},
|
|
142
168
|
): Promise<SourceDetection[]> {
|
|
169
|
+
let root: Awaited<ReturnType<typeof stat>>;
|
|
170
|
+
try {
|
|
171
|
+
root = await stat(cwd);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
174
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
175
|
+
throw new SourceDirectoryNotFoundError(cwd, { cause: error });
|
|
176
|
+
}
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
if (!root.isDirectory()) throw new SourceDirectoryNotFoundError(cwd);
|
|
180
|
+
|
|
143
181
|
const hits = new Map<string, SourceDetection>();
|
|
144
182
|
|
|
145
183
|
const add = (provider: string, signal: string, confidence: DetectionConfidence) => {
|
|
@@ -202,7 +240,10 @@ async function scanPython(
|
|
|
202
240
|
if (existsSync(reqPath)) {
|
|
203
241
|
const text = await readFile(reqPath, "utf-8");
|
|
204
242
|
for (const line of text.split(/\r?\n/)) {
|
|
205
|
-
const pkg = line
|
|
243
|
+
const pkg = line
|
|
244
|
+
.split(/[=<>!~\[]/)[0]
|
|
245
|
+
.trim()
|
|
246
|
+
.toLowerCase();
|
|
206
247
|
if (!pkg || pkg.startsWith("#")) continue;
|
|
207
248
|
for (const rule of PY_RULES) {
|
|
208
249
|
if (rule.matches(pkg)) add(rule.provider, `requirements.txt:${pkg}`, rule.confidence);
|
package/src/detect.ts
CHANGED
|
@@ -55,7 +55,11 @@ const RULES: DetectionRule[] = [
|
|
|
55
55
|
matches: (n) => /^UPSTASH_/.test(n),
|
|
56
56
|
primary: (n) => n === "UPSTASH_MANAGEMENT_TOKEN",
|
|
57
57
|
},
|
|
58
|
-
{
|
|
58
|
+
{
|
|
59
|
+
provider: "openai",
|
|
60
|
+
matches: (n) => /^OPENAI_/.test(n),
|
|
61
|
+
primary: (n) => n === "OPENAI_API_KEY",
|
|
62
|
+
},
|
|
59
63
|
{
|
|
60
64
|
provider: "anthropic",
|
|
61
65
|
matches: (n) => /^ANTHROPIC_/.test(n),
|
|
@@ -96,11 +100,14 @@ const RULES: DetectionRule[] = [
|
|
|
96
100
|
matches: (n) => /^STRIPE_/.test(n),
|
|
97
101
|
primary: (n) => n === "STRIPE_SECRET_KEY",
|
|
98
102
|
},
|
|
99
|
-
{
|
|
103
|
+
{
|
|
104
|
+
provider: "resend",
|
|
105
|
+
matches: (n) => /^RESEND_/.test(n),
|
|
106
|
+
primary: (n) => n === "RESEND_API_KEY",
|
|
107
|
+
},
|
|
100
108
|
{
|
|
101
109
|
provider: "clerk",
|
|
102
|
-
matches: (n) =>
|
|
103
|
-
/^CLERK_/.test(n) || /^NEXT_PUBLIC_CLERK_/.test(n),
|
|
110
|
+
matches: (n) => /^CLERK_/.test(n) || /^NEXT_PUBLIC_CLERK_/.test(n),
|
|
104
111
|
primary: (n) => n === "CLERK_SECRET_KEY",
|
|
105
112
|
},
|
|
106
113
|
{
|
|
@@ -160,7 +167,8 @@ export function groupByProvider(envNames: string[]): Record<string, string[]> {
|
|
|
160
167
|
for (const name of envNames) {
|
|
161
168
|
const provider = detectProvider(name);
|
|
162
169
|
if (!provider) continue;
|
|
163
|
-
(out[provider]
|
|
170
|
+
if (!out[provider]) out[provider] = [];
|
|
171
|
+
out[provider].push(name);
|
|
164
172
|
}
|
|
165
173
|
return out;
|
|
166
174
|
}
|
package/src/errors.ts
CHANGED
|
@@ -27,3 +27,9 @@ export class ConfigNotFoundError extends StackError {
|
|
|
27
27
|
super("CONFIG_NOT_FOUND", `No .stack.toml at ${path}. Run \`stack init\` first.`);
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
export class SourceDirectoryNotFoundError extends StackError {
|
|
32
|
+
constructor(path: string, options?: { cause?: unknown }) {
|
|
33
|
+
super("SOURCE_DIRECTORY_NOT_FOUND", `Source directory not found: ${path}`, options);
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/http.ts
CHANGED
|
@@ -48,7 +48,7 @@ export function defaultRetryOn(res: Response): boolean {
|
|
|
48
48
|
* executed exactly once unless `idempotent: true` is passed.
|
|
49
49
|
*/
|
|
50
50
|
export async function fetchWithRetry(
|
|
51
|
-
input:
|
|
51
|
+
input: string | URL | Request,
|
|
52
52
|
init?: RequestInit,
|
|
53
53
|
opts: RetryOptions = {},
|
|
54
54
|
): Promise<Response> {
|
|
@@ -78,7 +78,6 @@ export async function fetchWithRetry(
|
|
|
78
78
|
const waitMs = computeDelay(res, attempt, baseDelayMs, maxDelayMs);
|
|
79
79
|
await sleep(waitMs, signal);
|
|
80
80
|
attempt++;
|
|
81
|
-
continue;
|
|
82
81
|
} catch (err) {
|
|
83
82
|
// Network-level failure (DNS, connection reset, fetch threw). Only retry
|
|
84
83
|
// for idempotent requests — a POST that "failed" may have actually been
|
|
@@ -89,7 +88,6 @@ export async function fetchWithRetry(
|
|
|
89
88
|
const waitMs = computeDelay(undefined, attempt, baseDelayMs, maxDelayMs);
|
|
90
89
|
await sleep(waitMs, signal);
|
|
91
90
|
attempt++;
|
|
92
|
-
continue;
|
|
93
91
|
}
|
|
94
92
|
}
|
|
95
93
|
|
|
@@ -125,7 +123,7 @@ function computeDelay(
|
|
|
125
123
|
const parsed = parseRetryAfter(header);
|
|
126
124
|
if (parsed !== undefined) return Math.max(0, Math.min(parsed, maxDelayMs * 4));
|
|
127
125
|
}
|
|
128
|
-
const exp = Math.min(baseDelayMs *
|
|
126
|
+
const exp = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
|
|
129
127
|
const jitter = Math.random() * baseDelayMs;
|
|
130
128
|
return exp + jitter;
|
|
131
129
|
}
|
|
@@ -181,7 +179,10 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
|
181
179
|
* `AbortSignal.any` where available; falling back to a manual linkage on older
|
|
182
180
|
* runtimes.
|
|
183
181
|
*/
|
|
184
|
-
function mergeSignal(
|
|
182
|
+
function mergeSignal(
|
|
183
|
+
init: RequestInit | undefined,
|
|
184
|
+
external?: AbortSignal,
|
|
185
|
+
): RequestInit | undefined {
|
|
185
186
|
if (!external) return init;
|
|
186
187
|
const existing = init?.signal ?? undefined;
|
|
187
188
|
if (!existing) return { ...init, signal: external };
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
export * from "./config.ts";
|
|
2
|
+
export { STACK_MANIFEST, type StackManifest, type StackComponent } from "./stack-manifest.ts";
|
|
3
|
+
export {
|
|
4
|
+
isEnabled as isTelemetryEnabled,
|
|
5
|
+
promptFirstRun as promptTelemetryFirstRun,
|
|
6
|
+
emit as emitTelemetry,
|
|
7
|
+
disable as disableTelemetry,
|
|
8
|
+
enable as enableTelemetry,
|
|
9
|
+
type TelemetryEvent,
|
|
10
|
+
type TelemetryConfig,
|
|
11
|
+
} from "./telemetry.ts";
|
|
12
|
+
export { detectPackageManager, installCommand } from "./pm-detect.ts";
|
|
13
|
+
export type { PackageManager } from "./pm-detect.ts";
|
|
2
14
|
export * from "./phantom.ts";
|
|
3
15
|
export * from "./providers/_base.ts";
|
|
4
16
|
export { providers, getProvider, listProviderNames } from "./providers/index.ts";
|
|
17
|
+
export { takeRevealRefusals } from "./providers/_helpers.ts";
|
|
5
18
|
export * from "./errors.ts";
|
|
6
19
|
export * from "./templates.ts";
|
|
7
20
|
export * from "./pipeline.ts";
|
|
@@ -47,6 +60,8 @@ export {
|
|
|
47
60
|
type RecipeDraft,
|
|
48
61
|
type RecipeProvider,
|
|
49
62
|
} from "./ai/inference.ts";
|
|
63
|
+
export { findSwap, suggestSwaps, SWAP_PAIRS } from "./swap.ts";
|
|
64
|
+
export type { SwapPair } from "./swap.ts";
|
|
50
65
|
export {
|
|
51
66
|
CircuitBreaker,
|
|
52
67
|
type CircuitBreakerOptions,
|
|
@@ -59,3 +74,4 @@ export {
|
|
|
59
74
|
type RateCard,
|
|
60
75
|
type UsageRecord,
|
|
61
76
|
} from "./ai/cost-tracker.ts";
|
|
77
|
+
export * from "./agent-onboard.ts";
|
package/src/mcp-writer.ts
CHANGED
|
@@ -10,21 +10,79 @@ interface McpFile {
|
|
|
10
10
|
[key: string]: unknown;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Other MCP clients that read a project-level `mcpServers` JSON file. Stack
|
|
15
|
+
* mirrors entries into these only when the client's directory already exists
|
|
16
|
+
* in the project (the user opted into that client); it never creates them.
|
|
17
|
+
*/
|
|
18
|
+
const MIRROR_TARGETS = [
|
|
19
|
+
{ client: "cursor", dir: ".cursor", file: "mcp.json" },
|
|
20
|
+
{ client: "gemini", dir: ".gemini", file: "settings.json" },
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
type MirrorClient = (typeof MIRROR_TARGETS)[number]["client"];
|
|
24
|
+
|
|
25
|
+
export async function mergeMcpEntry(
|
|
26
|
+
entry: McpServerEntry,
|
|
27
|
+
cwd: string = process.cwd(),
|
|
28
|
+
): Promise<void> {
|
|
14
29
|
const path = join(cwd, MCP_FILENAME);
|
|
15
30
|
const current = await readMcp(path);
|
|
31
|
+
const previous = current.mcpServers[entry.name];
|
|
16
32
|
current.mcpServers[entry.name] = toJsonShape(entry);
|
|
17
33
|
await writeFile(path, `${JSON.stringify(current, null, 2)}\n`, "utf-8");
|
|
34
|
+
|
|
35
|
+
for (const t of MIRROR_TARGETS) {
|
|
36
|
+
if (!existsSync(join(cwd, t.dir))) continue;
|
|
37
|
+
const target = join(cwd, t.dir, t.file);
|
|
38
|
+
try {
|
|
39
|
+
const doc = await readMcp(target);
|
|
40
|
+
const next = toClientShape(entry, t.client);
|
|
41
|
+
const existing = doc.mcpServers[entry.name];
|
|
42
|
+
// Client configs are user-owned: replace a same-name entry only when it
|
|
43
|
+
// is what Stack itself mirrored (now or from the previous .mcp.json
|
|
44
|
+
// entry). A hand-written entry is left as it is.
|
|
45
|
+
if (existing !== undefined) {
|
|
46
|
+
if (sameJson(existing, next)) continue;
|
|
47
|
+
const mirrored = previous && toClientShape(asEntry(entry.name, previous), t.client);
|
|
48
|
+
if (!mirrored || !sameJson(existing, mirrored)) continue;
|
|
49
|
+
}
|
|
50
|
+
doc.mcpServers[entry.name] = next;
|
|
51
|
+
await writeFile(target, `${JSON.stringify(doc, null, 2)}\n`, "utf-8");
|
|
52
|
+
} catch {
|
|
53
|
+
// A client file Stack cannot parse is left untouched: .mcp.json is the
|
|
54
|
+
// source of truth and a broken sibling must not fail provisioning.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
18
57
|
}
|
|
19
58
|
|
|
20
59
|
export async function removeMcpEntry(name: string, cwd: string = process.cwd()): Promise<void> {
|
|
21
60
|
const path = join(cwd, MCP_FILENAME);
|
|
22
61
|
if (!existsSync(path)) return;
|
|
23
62
|
const current = await readMcp(path);
|
|
24
|
-
|
|
63
|
+
const previous = current.mcpServers[name];
|
|
64
|
+
if (previous) {
|
|
25
65
|
delete current.mcpServers[name];
|
|
26
66
|
await writeFile(path, `${JSON.stringify(current, null, 2)}\n`, "utf-8");
|
|
27
67
|
}
|
|
68
|
+
// Without a .mcp.json entry there is nothing to prove a client entry of the
|
|
69
|
+
// same name was written by Stack, so the client configs are not touched.
|
|
70
|
+
if (!previous) return;
|
|
71
|
+
|
|
72
|
+
for (const t of MIRROR_TARGETS) {
|
|
73
|
+
const target = join(cwd, t.dir, t.file);
|
|
74
|
+
if (!existsSync(target)) continue;
|
|
75
|
+
try {
|
|
76
|
+
const doc = await readMcp(target);
|
|
77
|
+
const existing = doc.mcpServers[name];
|
|
78
|
+
if (existing && sameJson(existing, toClientShape(asEntry(name, previous), t.client))) {
|
|
79
|
+
delete doc.mcpServers[name];
|
|
80
|
+
await writeFile(target, `${JSON.stringify(doc, null, 2)}\n`, "utf-8");
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// Unparseable sibling config: leave it alone.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
28
86
|
}
|
|
29
87
|
|
|
30
88
|
async function readMcp(path: string): Promise<McpFile> {
|
|
@@ -48,3 +106,40 @@ function toJsonShape(entry: McpServerEntry): Record<string, unknown> {
|
|
|
48
106
|
if (entry.headers) out.headers = entry.headers;
|
|
49
107
|
return out;
|
|
50
108
|
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Gemini CLI names remote transports differently: `httpUrl` for streamable
|
|
112
|
+
* HTTP and `url` for SSE, and it has no `type` field. Cursor accepts the
|
|
113
|
+
* generic shape.
|
|
114
|
+
*/
|
|
115
|
+
function toClientShape(entry: McpServerEntry, client: MirrorClient): Record<string, unknown> {
|
|
116
|
+
if (client !== "gemini") return toJsonShape(entry);
|
|
117
|
+
const out: Record<string, unknown> = {};
|
|
118
|
+
if (entry.command) out.command = entry.command;
|
|
119
|
+
if (entry.args) out.args = entry.args;
|
|
120
|
+
if (entry.url) out[entry.type === "http" ? "httpUrl" : "url"] = entry.url;
|
|
121
|
+
if (entry.env) out.env = entry.env;
|
|
122
|
+
if (entry.headers) out.headers = entry.headers;
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Rebuild an entry from its `.mcp.json` shape (the inverse of toJsonShape). */
|
|
127
|
+
function asEntry(name: string, json: Record<string, unknown>): McpServerEntry {
|
|
128
|
+
return { ...json, name } as McpServerEntry;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function sameJson(a: unknown, b: unknown): boolean {
|
|
132
|
+
return JSON.stringify(sortKeys(a)) === JSON.stringify(sortKeys(b));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function sortKeys(v: unknown): unknown {
|
|
136
|
+
if (Array.isArray(v)) return v.map(sortKeys);
|
|
137
|
+
if (v && typeof v === "object") {
|
|
138
|
+
return Object.fromEntries(
|
|
139
|
+
Object.entries(v as Record<string, unknown>)
|
|
140
|
+
.sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0))
|
|
141
|
+
.map(([k, val]) => [k, sortKeys(val)]),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return v;
|
|
145
|
+
}
|
package/src/oauth.ts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
3
|
import { createServer } from "node:http";
|
|
3
|
-
import { spawn } from "node:child_process";
|
|
4
|
-
import { platform } from "node:os";
|
|
5
4
|
import type { AddressInfo } from "node:net";
|
|
5
|
+
import { platform } from "node:os";
|
|
6
6
|
import { StackError } from "./errors.ts";
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* The deterministic loopback port Stack tries first for OAuth callbacks.
|
|
10
|
+
* Register `http://127.0.0.1:8787/callback` as the redirect URI in your
|
|
11
|
+
* OAuth app (Supabase, GitHub, etc.) so no wildcard matching is needed.
|
|
12
|
+
* Stack falls back to a random OS-assigned port only when 8787 is in use.
|
|
13
|
+
*/
|
|
14
|
+
export const STACK_OAUTH_PORT = 8787;
|
|
15
|
+
|
|
8
16
|
/**
|
|
9
17
|
* Minimal OAuth PKCE helper suitable for CLI/desktop OAuth flows. Spins up a
|
|
10
18
|
* localhost loopback server, opens the browser to the provider auth URL,
|
|
@@ -102,60 +110,80 @@ async function captureCallback(args: CaptureArgs): Promise<{ code: string; redir
|
|
|
102
110
|
}, args.timeoutMs);
|
|
103
111
|
server.on("close", () => clearTimeout(timer));
|
|
104
112
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
113
|
+
function startListening(preferredPort: number) {
|
|
114
|
+
server.listen(preferredPort, "127.0.0.1", () => {
|
|
115
|
+
const { port } = server.address() as AddressInfo;
|
|
116
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
117
|
+
|
|
118
|
+
server.on("request", (req, res) => {
|
|
119
|
+
if (!req.url) {
|
|
120
|
+
res.writeHead(400).end();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
124
|
+
if (url.pathname !== "/callback") {
|
|
125
|
+
res.writeHead(404).end();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const receivedState = url.searchParams.get("state");
|
|
129
|
+
const code = url.searchParams.get("code");
|
|
130
|
+
const error = url.searchParams.get("error");
|
|
131
|
+
if (error) {
|
|
132
|
+
res
|
|
133
|
+
.writeHead(400, { "content-type": "text/html" })
|
|
134
|
+
.end(htmlPage("error", args.providerName, error));
|
|
135
|
+
server.close();
|
|
136
|
+
reject(new StackError("OAUTH_DENIED", `${args.providerName} auth denied: ${error}`));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (!code || receivedState !== args.state) {
|
|
140
|
+
res
|
|
141
|
+
.writeHead(400, { "content-type": "text/html" })
|
|
142
|
+
.end(htmlPage("error", args.providerName, "state mismatch"));
|
|
143
|
+
server.close();
|
|
144
|
+
reject(
|
|
145
|
+
new StackError("OAUTH_STATE_MISMATCH", "Auth callback state did not match request."),
|
|
146
|
+
);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
123
149
|
res
|
|
124
|
-
.writeHead(
|
|
125
|
-
.end(htmlPage("
|
|
150
|
+
.writeHead(200, { "content-type": "text/html" })
|
|
151
|
+
.end(htmlPage("ok", args.providerName));
|
|
126
152
|
server.close();
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
client_id: args.clientId,
|
|
146
|
-
redirect_uri: redirectUri,
|
|
147
|
-
state: args.state,
|
|
148
|
-
code_challenge: args.challenge,
|
|
149
|
-
code_challenge_method: "S256",
|
|
150
|
-
...(args.scope ? { scope: args.scope } : {}),
|
|
151
|
-
...(args.extraAuthParams ?? {}),
|
|
153
|
+
resolve({ code, redirectUri });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const params = new URLSearchParams({
|
|
157
|
+
response_type: "code",
|
|
158
|
+
client_id: args.clientId,
|
|
159
|
+
redirect_uri: redirectUri,
|
|
160
|
+
state: args.state,
|
|
161
|
+
code_challenge: args.challenge,
|
|
162
|
+
code_challenge_method: "S256",
|
|
163
|
+
...(args.scope ? { scope: args.scope } : {}),
|
|
164
|
+
...(args.extraAuthParams ?? {}),
|
|
165
|
+
});
|
|
166
|
+
const authorizeUrl = `${args.authUrl}?${params.toString()}`;
|
|
167
|
+
|
|
168
|
+
console.log(`\n Opening ${args.providerName} in your browser…`);
|
|
169
|
+
console.log(` If nothing happens, visit:\n ${authorizeUrl}\n`);
|
|
170
|
+
openBrowser(authorizeUrl);
|
|
152
171
|
});
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
server.once("error", (err: NodeJS.ErrnoException) => {
|
|
175
|
+
if (err.code === "EADDRINUSE") {
|
|
176
|
+
console.warn(
|
|
177
|
+
` [stack] Port ${STACK_OAUTH_PORT} in use, falling back to OS-assigned port.`,
|
|
178
|
+
);
|
|
179
|
+
server.removeAllListeners("error");
|
|
180
|
+
startListening(0);
|
|
181
|
+
} else {
|
|
182
|
+
reject(new StackError("OAUTH_SERVER_ERROR", `OAuth server error: ${err.message}`));
|
|
183
|
+
}
|
|
158
184
|
});
|
|
185
|
+
|
|
186
|
+
startListening(STACK_OAUTH_PORT);
|
|
159
187
|
});
|
|
160
188
|
}
|
|
161
189
|
|
|
@@ -169,7 +197,11 @@ function openBrowser(url: string): void {
|
|
|
169
197
|
}
|
|
170
198
|
|
|
171
199
|
function base64url(buf: Buffer | Uint8Array): string {
|
|
172
|
-
return Buffer.from(buf)
|
|
200
|
+
return Buffer.from(buf)
|
|
201
|
+
.toString("base64")
|
|
202
|
+
.replace(/\+/g, "-")
|
|
203
|
+
.replace(/\//g, "_")
|
|
204
|
+
.replace(/=+$/, "");
|
|
173
205
|
}
|
|
174
206
|
|
|
175
207
|
function htmlPage(kind: "ok" | "error", provider: string, detail?: string): string {
|