@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/telemetry.ts
ADDED
|
@@ -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
|
|