@ashlr/stack-core 0.1.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +26 -15
  3. package/package.json +1 -1
  4. package/src/__tests__/_harness.ts +67 -7
  5. package/src/__tests__/agent-onboard.test.ts +217 -0
  6. package/src/__tests__/api-key-providers.test.ts +6 -2
  7. package/src/__tests__/base-urls.test.ts +162 -0
  8. package/src/__tests__/catalog-index.test.ts +14 -20
  9. package/src/__tests__/cloudflare-provider.test.ts +9 -10
  10. package/src/__tests__/completion-ci.test.ts +239 -23
  11. package/src/__tests__/config-own-services.test.ts +183 -0
  12. package/src/__tests__/config-split.test.ts +206 -2
  13. package/src/__tests__/config.test.ts +2 -1
  14. package/src/__tests__/credential-staging.test.ts +659 -0
  15. package/src/__tests__/deps.test.ts +2 -1
  16. package/src/__tests__/detect-source.test.ts +1 -1
  17. package/src/__tests__/github-provider.test.ts +3 -2
  18. package/src/__tests__/helpers.test.ts +24 -4
  19. package/src/__tests__/http.test.ts +2 -2
  20. package/src/__tests__/inference.test.ts +19 -13
  21. package/src/__tests__/interactive-prompt.test.ts +106 -0
  22. package/src/__tests__/mcp-mirror.test.ts +116 -0
  23. package/src/__tests__/neon-provider.test.ts +136 -3
  24. package/src/__tests__/new-providers.test.ts +470 -0
  25. package/src/__tests__/oauth-port.test.ts +84 -0
  26. package/src/__tests__/openai-provider.test.ts +1 -1
  27. package/src/__tests__/phantom-contract.test.ts +177 -0
  28. package/src/__tests__/phantom-harness.test.ts +125 -8
  29. package/src/__tests__/phantom-project-preflight.test.ts +118 -0
  30. package/src/__tests__/phantom-wire.test.ts +31 -20
  31. package/src/__tests__/pipeline-integration.test.ts +1 -1
  32. package/src/__tests__/pipeline-recovery.test.ts +29 -28
  33. package/src/__tests__/pipeline-rollback.test.ts +509 -0
  34. package/src/__tests__/pipeline.test.ts +3 -1
  35. package/src/__tests__/pm-detect.test.ts +113 -0
  36. package/src/__tests__/provider-deprovision.test.ts +269 -0
  37. package/src/__tests__/provider-parity.test.ts +46 -0
  38. package/src/__tests__/publish-script.test.ts +659 -0
  39. package/src/__tests__/recipe.test.ts +4 -2
  40. package/src/__tests__/recommend-apply-e2e.test.ts +14 -27
  41. package/src/__tests__/registry.test.ts +11 -2
  42. package/src/__tests__/release-artifacts.test.ts +55 -0
  43. package/src/__tests__/secret-redaction.test.ts +10 -10
  44. package/src/__tests__/security.test.ts +9 -10
  45. package/src/__tests__/supabase-provider.test.ts +6 -4
  46. package/src/__tests__/swap.test.ts +114 -0
  47. package/src/__tests__/telemetry.test.ts +354 -0
  48. package/src/__tests__/template-litellm-gateway.test.ts +41 -0
  49. package/src/__tests__/template-temporal-worker-phantom.test.ts +31 -0
  50. package/src/__tests__/templates.test.ts +1 -1
  51. package/src/__tests__/turso-provider.test.ts +2 -5
  52. package/src/agent-onboard.ts +346 -0
  53. package/src/ai/catalog-index.ts +68 -10
  54. package/src/ai/circuit-breaker.ts +3 -6
  55. package/src/ai/cost-tracker.ts +6 -15
  56. package/src/ai/inference.ts +19 -11
  57. package/src/ai/phantom-wire.ts +29 -52
  58. package/src/ai/recipe.ts +69 -0
  59. package/src/base-urls.ts +71 -0
  60. package/src/catalog.ts +281 -2
  61. package/src/config.ts +31 -18
  62. package/src/detect-source.ts +36 -8
  63. package/src/detect.ts +13 -5
  64. package/src/http.ts +6 -5
  65. package/src/index.ts +15 -0
  66. package/src/mcp-writer.ts +97 -2
  67. package/src/oauth.ts +85 -53
  68. package/src/phantom.ts +193 -15
  69. package/src/pipeline.ts +177 -68
  70. package/src/pm-detect.ts +56 -0
  71. package/src/providers/_api-key.ts +18 -17
  72. package/src/providers/_base.ts +28 -1
  73. package/src/providers/_credential-stage.ts +237 -0
  74. package/src/providers/_helpers.ts +55 -9
  75. package/src/providers/anthropic.ts +2 -1
  76. package/src/providers/auth0.ts +27 -0
  77. package/src/providers/aws.ts +32 -33
  78. package/src/providers/cloudflare.ts +18 -37
  79. package/src/providers/convex.ts +1 -2
  80. package/src/providers/datadog.ts +29 -0
  81. package/src/providers/digitalocean.ts +30 -0
  82. package/src/providers/gcp.ts +36 -0
  83. package/src/providers/github.ts +37 -35
  84. package/src/providers/grafana.ts +29 -0
  85. package/src/providers/hetzner.ts +29 -0
  86. package/src/providers/index.ts +13 -1
  87. package/src/providers/launchdarkly.ts +33 -0
  88. package/src/providers/mixpanel.ts +37 -0
  89. package/src/providers/neon.ts +50 -26
  90. package/src/providers/openai.ts +2 -1
  91. package/src/providers/plausible.ts +26 -0
  92. package/src/providers/sentry.ts +17 -17
  93. package/src/providers/stripe.ts +1 -2
  94. package/src/providers/supabase.ts +66 -37
  95. package/src/providers/turso.ts +49 -38
  96. package/src/providers/vercel.ts +50 -20
  97. package/src/providers/workos.ts +27 -0
  98. package/src/registry.ts +7 -11
  99. package/src/swap.ts +108 -0
  100. package/src/telemetry.ts +267 -0
  101. 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[name] = {
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[name] = inst;
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 = instance.services?.[name] ?? {};
230
- services[name] = {
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[name] = {
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
  }
@@ -37,7 +37,8 @@ const JS_RULES: DepRule[] = [
37
37
  { provider: "neon", matches: (d) => d.startsWith("@neondatabase/"), confidence: "high" },
38
38
  {
39
39
  provider: "vercel",
40
- matches: (d) => d === "@vercel/analytics" || d === "@vercel/speed-insights" || d === "@vercel/kv",
40
+ matches: (d) =>
41
+ d === "@vercel/analytics" || d === "@vercel/speed-insights" || d === "@vercel/kv",
41
42
  confidence: "medium",
42
43
  },
43
44
  { provider: "sentry", matches: (d) => d.startsWith("@sentry/"), confidence: "high" },
@@ -48,7 +49,11 @@ const JS_RULES: DepRule[] = [
48
49
  },
49
50
  { provider: "openai", matches: (d) => d === "openai", confidence: "high" },
50
51
  { provider: "anthropic", matches: (d) => d === "@anthropic-ai/sdk", confidence: "high" },
51
- { provider: "stripe", matches: (d) => d === "stripe" || d === "@stripe/stripe-js", confidence: "high" },
52
+ {
53
+ provider: "stripe",
54
+ matches: (d) => d === "stripe" || d === "@stripe/stripe-js",
55
+ confidence: "high",
56
+ },
52
57
  { provider: "clerk", matches: (d) => d.startsWith("@clerk/"), confidence: "high" },
53
58
  { provider: "resend", matches: (d) => d === "resend", confidence: "high" },
54
59
  {
@@ -102,7 +107,11 @@ const JS_RULES: DepRule[] = [
102
107
  ];
103
108
 
104
109
  const PY_RULES: DepRule[] = [
105
- { provider: "supabase", matches: (d) => d === "supabase" || d === "supabase-py", confidence: "high" },
110
+ {
111
+ provider: "supabase",
112
+ matches: (d) => d === "supabase" || d === "supabase-py",
113
+ confidence: "high",
114
+ },
106
115
  { provider: "openai", matches: (d) => d === "openai", confidence: "high" },
107
116
  { provider: "anthropic", matches: (d) => d === "anthropic", confidence: "high" },
108
117
  { provider: "sentry", matches: (d) => d === "sentry-sdk", confidence: "high" },
@@ -120,18 +129,34 @@ const GO_RULES: DepRule[] = [
120
129
  matches: (d) => d.startsWith("github.com/supabase-community/"),
121
130
  confidence: "high",
122
131
  },
123
- { provider: "openai", matches: (d) => d === "github.com/sashabaranov/go-openai", confidence: "high" },
132
+ {
133
+ provider: "openai",
134
+ matches: (d) => d === "github.com/sashabaranov/go-openai",
135
+ confidence: "high",
136
+ },
124
137
  {
125
138
  provider: "anthropic",
126
139
  matches: (d) => d.startsWith("github.com/liushuangls/go-anthropic"),
127
140
  confidence: "medium",
128
141
  },
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" },
142
+ {
143
+ provider: "sentry",
144
+ matches: (d) => d === "github.com/getsentry/sentry-go",
145
+ confidence: "high",
146
+ },
147
+ {
148
+ provider: "stripe",
149
+ matches: (d) => d.startsWith("github.com/stripe/stripe-go"),
150
+ confidence: "high",
151
+ },
131
152
  ];
132
153
 
133
154
  const RUST_RULES: DepRule[] = [
134
- { provider: "openai", matches: (d) => d === "async-openai" || d === "openai", confidence: "high" },
155
+ {
156
+ provider: "openai",
157
+ matches: (d) => d === "async-openai" || d === "openai",
158
+ confidence: "high",
159
+ },
135
160
  { provider: "sentry", matches: (d) => d === "sentry", confidence: "high" },
136
161
  { provider: "stripe", matches: (d) => d === "async-stripe", confidence: "high" },
137
162
  ];
@@ -202,7 +227,10 @@ async function scanPython(
202
227
  if (existsSync(reqPath)) {
203
228
  const text = await readFile(reqPath, "utf-8");
204
229
  for (const line of text.split(/\r?\n/)) {
205
- const pkg = line.split(/[=<>!~\[]/)[0].trim().toLowerCase();
230
+ const pkg = line
231
+ .split(/[=<>!~\[]/)[0]
232
+ .trim()
233
+ .toLowerCase();
206
234
  if (!pkg || pkg.startsWith("#")) continue;
207
235
  for (const rule of PY_RULES) {
208
236
  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
- { provider: "openai", matches: (n) => /^OPENAI_/.test(n), primary: (n) => n === "OPENAI_API_KEY" },
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
- { provider: "resend", matches: (n) => /^RESEND_/.test(n), primary: (n) => n === "RESEND_API_KEY" },
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] ??= []).push(name);
170
+ if (!out[provider]) out[provider] = [];
171
+ out[provider].push(name);
164
172
  }
165
173
  return out;
166
174
  }
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: RequestInfo | URL,
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 * Math.pow(2, attempt), maxDelayMs);
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(init: RequestInit | undefined, external?: AbortSignal): RequestInit | undefined {
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,19 @@
1
1
  export * from "./config.ts";
2
+ export {
3
+ isEnabled as isTelemetryEnabled,
4
+ promptFirstRun as promptTelemetryFirstRun,
5
+ emit as emitTelemetry,
6
+ disable as disableTelemetry,
7
+ enable as enableTelemetry,
8
+ type TelemetryEvent,
9
+ type TelemetryConfig,
10
+ } from "./telemetry.ts";
11
+ export { detectPackageManager, installCommand } from "./pm-detect.ts";
12
+ export type { PackageManager } from "./pm-detect.ts";
2
13
  export * from "./phantom.ts";
3
14
  export * from "./providers/_base.ts";
4
15
  export { providers, getProvider, listProviderNames } from "./providers/index.ts";
16
+ export { takeRevealRefusals } from "./providers/_helpers.ts";
5
17
  export * from "./errors.ts";
6
18
  export * from "./templates.ts";
7
19
  export * from "./pipeline.ts";
@@ -47,6 +59,8 @@ export {
47
59
  type RecipeDraft,
48
60
  type RecipeProvider,
49
61
  } from "./ai/inference.ts";
62
+ export { findSwap, suggestSwaps, SWAP_PAIRS } from "./swap.ts";
63
+ export type { SwapPair } from "./swap.ts";
50
64
  export {
51
65
  CircuitBreaker,
52
66
  type CircuitBreakerOptions,
@@ -59,3 +73,4 @@ export {
59
73
  type RateCard,
60
74
  type UsageRecord,
61
75
  } from "./ai/cost-tracker.ts";
76
+ 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
- export async function mergeMcpEntry(entry: McpServerEntry, cwd: string = process.cwd()): Promise<void> {
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
- if (current.mcpServers[name]) {
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
- server.listen(0, "127.0.0.1", () => {
106
- const { port } = server.address() as AddressInfo;
107
- const redirectUri = `http://127.0.0.1:${port}/callback`;
108
-
109
- server.on("request", (req, res) => {
110
- if (!req.url) {
111
- res.writeHead(400).end();
112
- return;
113
- }
114
- const url = new URL(req.url, "http://127.0.0.1");
115
- if (url.pathname !== "/callback") {
116
- res.writeHead(404).end();
117
- return;
118
- }
119
- const receivedState = url.searchParams.get("state");
120
- const code = url.searchParams.get("code");
121
- const error = url.searchParams.get("error");
122
- if (error) {
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(400, { "content-type": "text/html" })
125
- .end(htmlPage("error", args.providerName, error));
150
+ .writeHead(200, { "content-type": "text/html" })
151
+ .end(htmlPage("ok", args.providerName));
126
152
  server.close();
127
- reject(new StackError("OAUTH_DENIED", `${args.providerName} auth denied: ${error}`));
128
- return;
129
- }
130
- if (!code || receivedState !== args.state) {
131
- res
132
- .writeHead(400, { "content-type": "text/html" })
133
- .end(htmlPage("error", args.providerName, "state mismatch"));
134
- server.close();
135
- reject(new StackError("OAUTH_STATE_MISMATCH", "Auth callback state did not match request."));
136
- return;
137
- }
138
- res.writeHead(200, { "content-type": "text/html" }).end(htmlPage("ok", args.providerName));
139
- server.close();
140
- resolve({ code, redirectUri });
141
- });
142
-
143
- const params = new URLSearchParams({
144
- response_type: "code",
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
- const authorizeUrl = `${args.authUrl}?${params.toString()}`;
154
-
155
- console.log(`\n Opening ${args.providerName} in your browser…`);
156
- console.log(` If nothing happens, visit:\n ${authorizeUrl}\n`);
157
- openBrowser(authorizeUrl);
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).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
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 {