@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/swap.ts ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Swap-pair registry for `stack swap <from> <to>`.
3
+ *
4
+ * A SwapPair declares that two providers are functionally equivalent and can be
5
+ * swapped. Optional `aliases` preserve env-key compat so consuming code doesn't
6
+ * need to change after the swap (e.g. if the old provider wrote SUPABASE_URL but
7
+ * the new one writes DATABASE_URL, alias DATABASE_URL → SUPABASE_URL so both
8
+ * names exist in Phantom during the transition).
9
+ *
10
+ * Aliases are one-directional: they apply only when `from` is the source side.
11
+ * To support bidirectional swaps, add both directions explicitly.
12
+ */
13
+
14
+ export interface SwapPair {
15
+ from: string;
16
+ to: string;
17
+ /**
18
+ * Env-key aliases created after provisioning `to`.
19
+ * Key = the NEW provider's env key, Value = the OLD provider's env key to
20
+ * mirror it under, so downstream code referencing the old key still works.
21
+ *
22
+ * Example: { DATABASE_URL: "SUPABASE_URL" } means after swapping supabase → neon,
23
+ * the neon DATABASE_URL value is ALSO written to SUPABASE_URL in Phantom.
24
+ */
25
+ aliases?: Record<string, string>;
26
+ }
27
+
28
+ export const SWAP_PAIRS: SwapPair[] = [
29
+ // ── Auth ──────────────────────────────────────────────────────────────────
30
+ { from: "clerk", to: "auth0" },
31
+ { from: "auth0", to: "clerk" },
32
+ { from: "clerk", to: "workos" },
33
+ { from: "workos", to: "clerk" },
34
+ { from: "auth0", to: "workos" },
35
+ { from: "workos", to: "auth0" },
36
+
37
+ // ── Database ──────────────────────────────────────────────────────────────
38
+ // supabase uses SUPABASE_URL; neon uses DATABASE_URL — alias so existing
39
+ // SUPABASE_URL references resolve after moving to neon.
40
+ {
41
+ from: "supabase",
42
+ to: "neon",
43
+ aliases: { DATABASE_URL: "SUPABASE_URL" },
44
+ },
45
+ {
46
+ from: "neon",
47
+ to: "supabase",
48
+ aliases: {
49
+ SUPABASE_URL: "DATABASE_URL",
50
+ SUPABASE_ANON_KEY: "DATABASE_URL", // best-effort compat; user should update code
51
+ },
52
+ },
53
+
54
+ // ── Email ─────────────────────────────────────────────────────────────────
55
+ { from: "resend", to: "sendgrid" },
56
+ { from: "sendgrid", to: "resend" },
57
+ { from: "resend", to: "mailgun" },
58
+ { from: "mailgun", to: "resend" },
59
+ { from: "resend", to: "postmark" },
60
+ { from: "postmark", to: "resend" },
61
+ { from: "sendgrid", to: "mailgun" },
62
+ { from: "mailgun", to: "sendgrid" },
63
+ { from: "sendgrid", to: "postmark" },
64
+ { from: "postmark", to: "sendgrid" },
65
+ { from: "mailgun", to: "postmark" },
66
+ { from: "postmark", to: "mailgun" },
67
+
68
+ // ── Analytics ─────────────────────────────────────────────────────────────
69
+ { from: "posthog", to: "mixpanel" },
70
+ { from: "mixpanel", to: "posthog" },
71
+ { from: "posthog", to: "plausible" },
72
+ { from: "plausible", to: "posthog" },
73
+ { from: "mixpanel", to: "plausible" },
74
+ { from: "plausible", to: "mixpanel" },
75
+
76
+ // ── Observability ─────────────────────────────────────────────────────────
77
+ { from: "datadog", to: "grafana" },
78
+ { from: "grafana", to: "datadog" },
79
+
80
+ // ── Deploy ────────────────────────────────────────────────────────────────
81
+ { from: "vercel", to: "railway" },
82
+ { from: "railway", to: "vercel" },
83
+ { from: "vercel", to: "fly" },
84
+ { from: "fly", to: "vercel" },
85
+ { from: "vercel", to: "render" },
86
+ { from: "render", to: "vercel" },
87
+ { from: "railway", to: "fly" },
88
+ { from: "fly", to: "railway" },
89
+ { from: "railway", to: "render" },
90
+ { from: "render", to: "railway" },
91
+ { from: "fly", to: "render" },
92
+ { from: "render", to: "fly" },
93
+ ];
94
+
95
+ /**
96
+ * Find the registered swap pair for `from` → `to`, if one exists.
97
+ */
98
+ export function findSwap(from: string, to: string): SwapPair | undefined {
99
+ return SWAP_PAIRS.find((p) => p.from === from && p.to === to);
100
+ }
101
+
102
+ /**
103
+ * Return all known `to` candidates for a given `from` provider.
104
+ * Useful for suggestions when no exact pair is found.
105
+ */
106
+ export function suggestSwaps(from: string): string[] {
107
+ return SWAP_PAIRS.filter((p) => p.from === from).map((p) => p.to);
108
+ }
@@ -0,0 +1,267 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import * as readline from "node:readline";
6
+
7
+ /**
8
+ * Opt-in anonymous telemetry for Stack CLI.
9
+ *
10
+ * PRIVACY CONTRACT (enforced here, not just documented):
11
+ * - No data is ever sent unless the user explicitly opted in via promptFirstRun()
12
+ * or enable(). The STACK_TELEMETRY=1 env var alone does NOT enable telemetry —
13
+ * it only un-suppresses a prior opt-in.
14
+ * - STACK_TELEMETRY=0 always forces telemetry off regardless of stored config.
15
+ * - emit() is fire-and-forget: 1s AbortController timeout, never throws, never blocks.
16
+ * - Payload is defined by TelemetryEvent. No cwd, project names, provider choices
17
+ * for specific stacks, secret values, or Phantom data are ever included.
18
+ */
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Public types
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export interface TelemetryEvent {
25
+ type: "command" | "error";
26
+ /** e.g. "add", "scan" */
27
+ command: string;
28
+ exitCode: number;
29
+ durationMs: number;
30
+ /** Anonymous UUID per process invocation. */
31
+ runId: string;
32
+ /** Anonymous UUID per machine, generated once on first write and persisted. */
33
+ installId: string;
34
+ stackVersion: string;
35
+ platform: string;
36
+ // Explicitly absent: cwd, project names, secret values, env values,
37
+ // provider chosen for a specific project, stack.toml contents.
38
+ }
39
+
40
+ export interface TelemetryConfig {
41
+ enabled: boolean;
42
+ /** ISO timestamp of first-run prompt. */
43
+ promptedAt?: string;
44
+ installId?: string;
45
+ /** Override for self-hosted endpoint. */
46
+ endpoint?: string;
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Constants
51
+ // ---------------------------------------------------------------------------
52
+
53
+ const STACK_VERSION = "0.2.0";
54
+ const EMIT_TIMEOUT_MS = 1_000;
55
+
56
+ // Per-process anonymous run ID — generated once, never persisted.
57
+ const RUN_ID: string = crypto.randomUUID();
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Config path — lazy, with an override hook for tests.
61
+ // ---------------------------------------------------------------------------
62
+
63
+ let _configDirOverride: string | undefined;
64
+
65
+ /**
66
+ * Override the config directory. FOR TESTS ONLY.
67
+ * Pass `undefined` to restore the default.
68
+ */
69
+ export function __setConfigDirForTesting(dir: string | undefined): void {
70
+ _configDirOverride = dir;
71
+ }
72
+
73
+ function configDir(): string {
74
+ return _configDirOverride ?? join(homedir(), ".ashlr", "stack");
75
+ }
76
+
77
+ function configPath(): string {
78
+ return join(configDir(), "config.json");
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Config I/O
83
+ // ---------------------------------------------------------------------------
84
+
85
+ async function readTelemetryConfig(): Promise<TelemetryConfig | null> {
86
+ const path = configPath();
87
+ if (!existsSync(path)) return null;
88
+ try {
89
+ const raw = await readFile(path, "utf-8");
90
+ return JSON.parse(raw) as TelemetryConfig;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ async function writeTelemetryConfig(config: TelemetryConfig): Promise<void> {
97
+ await mkdir(configDir(), { recursive: true });
98
+ await writeFile(configPath(), `${JSON.stringify(config, null, 2)}\n`, "utf-8");
99
+ }
100
+
101
+ async function getOrCreateConfig(): Promise<TelemetryConfig> {
102
+ const existing = await readTelemetryConfig();
103
+ if (existing) return existing;
104
+ const fresh: TelemetryConfig = { enabled: false };
105
+ await writeTelemetryConfig(fresh);
106
+ return fresh;
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Env-var overrides
111
+ // ---------------------------------------------------------------------------
112
+
113
+ function envForceOff(): boolean {
114
+ return process.env.STACK_TELEMETRY === "0";
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Public API
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /**
122
+ * Returns true only if the user has previously opted in AND
123
+ * STACK_TELEMETRY=0 is not set.
124
+ *
125
+ * STACK_TELEMETRY=1 does NOT enable telemetry on its own — it only prevents
126
+ * a "0" override from suppressing an already-enabled config. Opt-in is sticky:
127
+ * it requires an affirmative action (promptFirstRun or enable()).
128
+ */
129
+ export async function isEnabled(): Promise<boolean> {
130
+ if (envForceOff()) return false;
131
+ const config = await readTelemetryConfig();
132
+ // Opt-in must come from stored config — env var alone is never sufficient.
133
+ return config?.enabled === true;
134
+ }
135
+
136
+ /**
137
+ * Displays a first-run consent prompt on TTY. Returns the user's choice.
138
+ * Persists the result (including installId on opt-in) to config.json.
139
+ * No-ops (returns false) when stdout is not a TTY.
140
+ */
141
+ export async function promptFirstRun(): Promise<boolean> {
142
+ const existing = await readTelemetryConfig();
143
+ // Already prompted — don't ask again.
144
+ if (existing?.promptedAt !== undefined) return existing.enabled;
145
+ // Only prompt on interactive terminals.
146
+ if (!process.stdout.isTTY) {
147
+ await writeTelemetryConfig({ enabled: false, promptedAt: new Date().toISOString() });
148
+ return false;
149
+ }
150
+
151
+ const rl = readline.createInterface({
152
+ input: process.stdin,
153
+ output: process.stdout,
154
+ });
155
+
156
+ const answer = await new Promise<string>((resolve) => {
157
+ rl.question(
158
+ "\nShare anonymous usage telemetry (command + duration, no paths/secrets)?\n" +
159
+ "Note: the telemetry backend is not yet live — opting in now pre-authorizes\n" +
160
+ "collection when it launches. No data is sent until then. [y/N] ",
161
+ (ans) => {
162
+ rl.close();
163
+ resolve(ans.trim().toLowerCase());
164
+ },
165
+ );
166
+ });
167
+
168
+ const opted = answer === "y" || answer === "yes";
169
+ const installId = opted ? crypto.randomUUID() : undefined;
170
+ await writeTelemetryConfig({
171
+ enabled: opted,
172
+ promptedAt: new Date().toISOString(),
173
+ ...(installId ? { installId } : {}),
174
+ });
175
+ return opted;
176
+ }
177
+
178
+ /**
179
+ * Fire-and-forget telemetry emit. Never throws. Never blocks the caller.
180
+ * If telemetry is disabled, returns immediately without any I/O.
181
+ */
182
+ export async function emit(
183
+ event: Partial<TelemetryEvent> & { type: TelemetryEvent["type"]; command: string },
184
+ ): Promise<void> {
185
+ // Fast path: env says off.
186
+ if (envForceOff()) return;
187
+
188
+ let config: TelemetryConfig | null;
189
+ try {
190
+ config = await readTelemetryConfig();
191
+ } catch {
192
+ return;
193
+ }
194
+
195
+ // No config or not opted-in: no-op. STACK_TELEMETRY=1 alone never enables.
196
+ if (!config?.enabled) return;
197
+
198
+ // Resolve endpoint: env override → persisted config → no-op.
199
+ // The hosted backend is not yet live; emit is a no-op until an endpoint is
200
+ // configured. This prevents fire-and-forget POSTs to a non-existent domain.
201
+ const endpoint = process.env.STACK_TELEMETRY_ENDPOINT?.trim() || config.endpoint?.trim() || null;
202
+ if (!endpoint) return;
203
+
204
+ const installId = await ensureInstallId(config);
205
+
206
+ const payload: TelemetryEvent = {
207
+ type: event.type,
208
+ command: event.command,
209
+ exitCode: event.exitCode ?? 0,
210
+ durationMs: event.durationMs ?? 0,
211
+ runId: RUN_ID,
212
+ installId,
213
+ stackVersion: STACK_VERSION,
214
+ platform: process.platform,
215
+ };
216
+
217
+ // Fire-and-forget: deliberately not awaited at the call site.
218
+ // We wrap in void and swallow all errors.
219
+ void (async () => {
220
+ try {
221
+ const controller = new AbortController();
222
+ const timer = setTimeout(() => controller.abort(), EMIT_TIMEOUT_MS);
223
+ await fetch(endpoint, {
224
+ method: "POST",
225
+ headers: { "content-type": "application/json" },
226
+ body: JSON.stringify(payload),
227
+ signal: controller.signal,
228
+ });
229
+ clearTimeout(timer);
230
+ } catch {
231
+ // Intentionally swallowed — telemetry must never surface errors to users.
232
+ }
233
+ })();
234
+ }
235
+
236
+ /** Permanently disable telemetry and persist the change. */
237
+ export async function disable(): Promise<void> {
238
+ const config = await getOrCreateConfig();
239
+ await writeTelemetryConfig({ ...config, enabled: false });
240
+ }
241
+
242
+ /** Enable telemetry (only meaningful after the user has explicitly opted in). */
243
+ export async function enable(): Promise<void> {
244
+ const config = await getOrCreateConfig();
245
+ const installId = config.installId ?? crypto.randomUUID();
246
+ await writeTelemetryConfig({
247
+ ...config,
248
+ enabled: true,
249
+ installId,
250
+ promptedAt: config.promptedAt ?? new Date().toISOString(),
251
+ });
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Internal helpers
256
+ // ---------------------------------------------------------------------------
257
+
258
+ /**
259
+ * Ensures an installId exists in config.json and returns it.
260
+ * Generates one if missing (guards against manual config edits).
261
+ */
262
+ async function ensureInstallId(config: TelemetryConfig): Promise<string> {
263
+ if (config.installId) return config.installId;
264
+ const installId = crypto.randomUUID();
265
+ await writeTelemetryConfig({ ...config, installId });
266
+ return installId;
267
+ }
package/src/templates.ts CHANGED
@@ -13,10 +13,14 @@ import type { StackConfig } from "./config.ts";
13
13
 
14
14
  export function resolveTemplatesDir(): string | undefined {
15
15
  const candidates = [
16
+ // Explicit override — useful in tests or custom installs.
17
+ process.env.STACK_TEMPLATES_DIR ?? "",
16
18
  resolve(join(process.cwd(), "templates")),
17
- // When running from source inside the monorepo:
19
+ // When running from source inside the monorepo (packages/core/src → root):
20
+ resolve(join(fileURLToPath(dirname(import.meta.url)), "..", "..", "..", "templates")),
21
+ // Legacy: one extra level up (kept for backward compat with old dist layouts).
18
22
  resolve(join(fileURLToPath(dirname(import.meta.url)), "..", "..", "..", "..", "templates")),
19
- ];
23
+ ].filter(Boolean);
20
24
  return candidates.find((p) => existsSync(p));
21
25
  }
22
26