@openparachute/hub 0.3.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +284 -0
  3. package/package.json +31 -0
  4. package/src/__tests__/auth.test.ts +101 -0
  5. package/src/__tests__/auto-wire.test.ts +283 -0
  6. package/src/__tests__/cli.test.ts +192 -0
  7. package/src/__tests__/cloudflare-config.test.ts +54 -0
  8. package/src/__tests__/cloudflare-detect.test.ts +68 -0
  9. package/src/__tests__/cloudflare-state.test.ts +92 -0
  10. package/src/__tests__/cloudflare-tunnel.test.ts +207 -0
  11. package/src/__tests__/config.test.ts +18 -0
  12. package/src/__tests__/env-file.test.ts +125 -0
  13. package/src/__tests__/expose-auth-preflight.test.ts +201 -0
  14. package/src/__tests__/expose-cloudflare.test.ts +484 -0
  15. package/src/__tests__/expose-interactive.test.ts +703 -0
  16. package/src/__tests__/expose-last-provider.test.ts +113 -0
  17. package/src/__tests__/expose-off-auto.test.ts +269 -0
  18. package/src/__tests__/expose-state.test.ts +101 -0
  19. package/src/__tests__/expose.test.ts +1581 -0
  20. package/src/__tests__/hub-control.test.ts +346 -0
  21. package/src/__tests__/hub-server.test.ts +157 -0
  22. package/src/__tests__/hub.test.ts +116 -0
  23. package/src/__tests__/install.test.ts +1145 -0
  24. package/src/__tests__/lifecycle.test.ts +608 -0
  25. package/src/__tests__/migrate.test.ts +422 -0
  26. package/src/__tests__/notes-serve.test.ts +135 -0
  27. package/src/__tests__/port-assign.test.ts +178 -0
  28. package/src/__tests__/process-state.test.ts +140 -0
  29. package/src/__tests__/scribe-config.test.ts +193 -0
  30. package/src/__tests__/scribe-provider-interactive.test.ts +361 -0
  31. package/src/__tests__/services-manifest.test.ts +177 -0
  32. package/src/__tests__/status.test.ts +347 -0
  33. package/src/__tests__/tailscale-commands.test.ts +111 -0
  34. package/src/__tests__/tailscale-detect.test.ts +64 -0
  35. package/src/__tests__/vault-auth-status.test.ts +164 -0
  36. package/src/__tests__/vault-tokens-create-interactive.test.ts +183 -0
  37. package/src/__tests__/well-known.test.ts +214 -0
  38. package/src/auto-wire.ts +184 -0
  39. package/src/cli.ts +482 -0
  40. package/src/cloudflare/config.ts +58 -0
  41. package/src/cloudflare/detect.ts +58 -0
  42. package/src/cloudflare/state.ts +96 -0
  43. package/src/cloudflare/tunnel.ts +135 -0
  44. package/src/commands/auth.ts +69 -0
  45. package/src/commands/expose-auth-preflight.ts +217 -0
  46. package/src/commands/expose-cloudflare.ts +329 -0
  47. package/src/commands/expose-interactive.ts +428 -0
  48. package/src/commands/expose-off-auto.ts +199 -0
  49. package/src/commands/expose.ts +522 -0
  50. package/src/commands/install.ts +422 -0
  51. package/src/commands/lifecycle.ts +324 -0
  52. package/src/commands/migrate.ts +253 -0
  53. package/src/commands/scribe-provider-interactive.ts +269 -0
  54. package/src/commands/status.ts +238 -0
  55. package/src/commands/vault-tokens-create-interactive.ts +137 -0
  56. package/src/commands/vault.ts +17 -0
  57. package/src/config.ts +16 -0
  58. package/src/env-file.ts +76 -0
  59. package/src/expose-last-provider.ts +71 -0
  60. package/src/expose-state.ts +125 -0
  61. package/src/help.ts +279 -0
  62. package/src/hub-control.ts +254 -0
  63. package/src/hub-origin.ts +44 -0
  64. package/src/hub-server.ts +113 -0
  65. package/src/hub.ts +674 -0
  66. package/src/notes-serve.ts +135 -0
  67. package/src/port-assign.ts +125 -0
  68. package/src/process-state.ts +111 -0
  69. package/src/scribe-config.ts +149 -0
  70. package/src/service-spec.ts +296 -0
  71. package/src/services-manifest.ts +171 -0
  72. package/src/tailscale/commands.ts +41 -0
  73. package/src/tailscale/detect.ts +107 -0
  74. package/src/tailscale/run.ts +28 -0
  75. package/src/vault/auth-status.ts +179 -0
  76. package/src/well-known.ts +127 -0
@@ -0,0 +1,324 @@
1
+ import { existsSync, openSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
4
+ import { readEnvFileValues } from "../env-file.ts";
5
+ import { readExposeState } from "../expose-state.ts";
6
+ import { readHubPort } from "../hub-control.ts";
7
+ import { HUB_ORIGIN_ENV, deriveHubOrigin } from "../hub-origin.ts";
8
+ import {
9
+ type AliveFn,
10
+ clearPid,
11
+ defaultAlive,
12
+ ensureLogPath,
13
+ logPath as logPathFor,
14
+ processState,
15
+ readPid,
16
+ writePid,
17
+ } from "../process-state.ts";
18
+ import { getSpec, knownServices, shortNameForManifest } from "../service-spec.ts";
19
+ import { type ServiceEntry, readManifest } from "../services-manifest.ts";
20
+
21
+ /**
22
+ * Tiny seam over `Bun.spawn` for lifecycle tests. The real spawner opens the
23
+ * log file, appends stdout+stderr to it, and `unref()`s the child so parent
24
+ * exit doesn't bring it down.
25
+ *
26
+ * `env`, when provided, is merged into the child's environment on top of the
27
+ * parent's — today's only caller is `start`, which injects
28
+ * PARACHUTE_HUB_ORIGIN so vault's OAuth issuer matches the hub URL.
29
+ */
30
+ export interface Spawner {
31
+ spawn(cmd: readonly string[], logFile: string, env?: Record<string, string>): number;
32
+ }
33
+
34
+ export const defaultSpawner: Spawner = {
35
+ spawn(cmd, logFile, env) {
36
+ const fd = openSync(logFile, "a");
37
+ const proc = Bun.spawn([...cmd], {
38
+ stdio: ["ignore", fd, fd],
39
+ env: env ? { ...process.env, ...env } : undefined,
40
+ });
41
+ proc.unref();
42
+ return proc.pid;
43
+ },
44
+ };
45
+
46
+ export type KillFn = (pid: number, signal: NodeJS.Signals | number) => void;
47
+ export type SleepFn = (ms: number) => Promise<void>;
48
+
49
+ export const defaultKill: KillFn = (pid, signal) => {
50
+ process.kill(pid, signal);
51
+ };
52
+
53
+ export const defaultSleep: SleepFn = (ms) => new Promise((r) => setTimeout(r, ms));
54
+
55
+ export interface LifecycleOpts {
56
+ spawner?: Spawner;
57
+ kill?: KillFn;
58
+ alive?: AliveFn;
59
+ sleep?: SleepFn;
60
+ now?: () => number;
61
+ manifestPath?: string;
62
+ configDir?: string;
63
+ log?: (line: string) => void;
64
+ /** How long stop waits for SIGTERM before escalating to SIGKILL. */
65
+ killWaitMs?: number;
66
+ /** Poll interval while waiting for SIGTERM to land. */
67
+ pollIntervalMs?: number;
68
+ /**
69
+ * Override the hub origin passed to services as PARACHUTE_HUB_ORIGIN. If
70
+ * unset, `start` derives it from `expose-state.json` (when exposed) or
71
+ * the hub.port file (local dev). Undefined → no env var is set at all,
72
+ * and the service advertises its own default issuer.
73
+ */
74
+ hubOrigin?: string;
75
+ }
76
+
77
+ interface Resolved {
78
+ spawner: Spawner;
79
+ kill: KillFn;
80
+ alive: AliveFn;
81
+ sleep: SleepFn;
82
+ now: () => number;
83
+ manifestPath: string;
84
+ configDir: string;
85
+ log: (line: string) => void;
86
+ killWaitMs: number;
87
+ pollIntervalMs: number;
88
+ hubOrigin: string | undefined;
89
+ }
90
+
91
+ function resolve(opts: LifecycleOpts): Resolved {
92
+ const configDir = opts.configDir ?? CONFIG_DIR;
93
+ return {
94
+ spawner: opts.spawner ?? defaultSpawner,
95
+ kill: opts.kill ?? defaultKill,
96
+ alive: opts.alive ?? defaultAlive,
97
+ sleep: opts.sleep ?? defaultSleep,
98
+ now: opts.now ?? Date.now,
99
+ manifestPath: opts.manifestPath ?? SERVICES_MANIFEST_PATH,
100
+ configDir,
101
+ log: opts.log ?? ((line) => console.log(line)),
102
+ killWaitMs: opts.killWaitMs ?? 10_000,
103
+ pollIntervalMs: opts.pollIntervalMs ?? 200,
104
+ hubOrigin: resolveHubOrigin(opts.hubOrigin, configDir),
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Source of truth order for `PARACHUTE_HUB_ORIGIN`:
110
+ * 1. explicit override (flag / opt)
111
+ * 2. live exposure's hubOrigin / canonicalFqdn (what clients actually see)
112
+ * 3. hub.port when the hub is running locally (local-dev loopback)
113
+ * 4. undefined — don't set the env, let the service self-advertise
114
+ */
115
+ function resolveHubOrigin(override: string | undefined, configDir: string): string | undefined {
116
+ if (override) return deriveHubOrigin({ override });
117
+ const state = readExposeState(join(configDir, "expose-state.json"));
118
+ if (state?.hubOrigin) return state.hubOrigin;
119
+ const exposeFqdn = state?.canonicalFqdn;
120
+ return deriveHubOrigin({ exposeFqdn, hubPort: readHubPort(configDir) });
121
+ }
122
+
123
+ /**
124
+ * Services selected by the `[svc]` positional. `undefined` targets every
125
+ * installed service (looked up via the manifest). Unknown names get a
126
+ * friendly error up front rather than a confusing spawn failure downstream.
127
+ */
128
+ function resolveTargets(
129
+ svc: string | undefined,
130
+ manifestPath: string,
131
+ ): { targets: Array<{ short: string; entry: ServiceEntry }> } | { error: string } {
132
+ const manifest = readManifest(manifestPath);
133
+ if (manifest.services.length === 0) {
134
+ return { error: "No services installed yet. Try: parachute install vault" };
135
+ }
136
+
137
+ if (svc !== undefined) {
138
+ const spec = getSpec(svc);
139
+ if (!spec) {
140
+ return {
141
+ error: `unknown service "${svc}". known: ${knownServices().join(", ")}`,
142
+ };
143
+ }
144
+ const entry = manifest.services.find((s) => s.name === spec.manifestName);
145
+ if (!entry) {
146
+ return {
147
+ error: `${svc} isn't installed. Run \`parachute install ${svc}\` first.`,
148
+ };
149
+ }
150
+ return { targets: [{ short: svc, entry }] };
151
+ }
152
+
153
+ const targets: Array<{ short: string; entry: ServiceEntry }> = [];
154
+ for (const entry of manifest.services) {
155
+ const short = shortNameForManifest(entry.name);
156
+ if (!short) continue;
157
+ targets.push({ short, entry });
158
+ }
159
+ if (targets.length === 0) {
160
+ return { error: "No manageable services in services.json." };
161
+ }
162
+ return { targets };
163
+ }
164
+
165
+ export async function start(svc: string | undefined, opts: LifecycleOpts = {}): Promise<number> {
166
+ const r = resolve(opts);
167
+ const picked = resolveTargets(svc, r.manifestPath);
168
+ if ("error" in picked) {
169
+ r.log(picked.error);
170
+ return 1;
171
+ }
172
+
173
+ let failures = 0;
174
+ for (const { short, entry } of picked.targets) {
175
+ const state = processState(short, r.configDir, r.alive);
176
+ if (state.status === "running") {
177
+ r.log(`${short} already running (pid ${state.pid}).`);
178
+ continue;
179
+ }
180
+ if (state.pid !== undefined) {
181
+ // Stale PID file for a dead process — clear it before we spawn fresh.
182
+ clearPid(short, r.configDir);
183
+ }
184
+
185
+ const spec = getSpec(short);
186
+ const cmd = spec?.startCmd?.(entry);
187
+ if (!cmd || cmd.length === 0) {
188
+ r.log(`${short}: lifecycle not yet supported for this service.`);
189
+ failures++;
190
+ continue;
191
+ }
192
+
193
+ const logFile = ensureLogPath(short, r.configDir);
194
+ // Merge `<configDir>/<short>/.env` into the spawn env so service-specific
195
+ // values (auto-wired SCRIBE_AUTH_TOKEN/SCRIBE_URL on vault, GROQ/OPENAI
196
+ // API keys on scribe written by the install prompt) reach the daemon.
197
+ // Vault still loads its own .env at runtime (it has its own start.sh
198
+ // wrapper for launchd / systemd) — this is idempotent there. Hub-origin
199
+ // override wins on collision; that's the live-exposure source of truth.
200
+ const fileEnv = readEnvFileValues(join(r.configDir, short, ".env"));
201
+ const env: Record<string, string> = { ...fileEnv };
202
+ if (r.hubOrigin) env[HUB_ORIGIN_ENV] = r.hubOrigin;
203
+ const envForSpawn = Object.keys(env).length > 0 ? env : undefined;
204
+ try {
205
+ const pid = r.spawner.spawn(cmd, logFile, envForSpawn);
206
+ writePid(short, pid, r.configDir);
207
+ r.log(`✓ ${short} started (pid ${pid}); logs: ${logFile}`);
208
+ if (r.hubOrigin) r.log(` ${HUB_ORIGIN_ENV}=${r.hubOrigin}`);
209
+ } catch (err) {
210
+ failures++;
211
+ const msg = err instanceof Error ? err.message : String(err);
212
+ r.log(`✗ ${short} failed to start: ${msg}`);
213
+ }
214
+ }
215
+ return failures === 0 ? 0 : 1;
216
+ }
217
+
218
+ export async function stop(svc: string | undefined, opts: LifecycleOpts = {}): Promise<number> {
219
+ const r = resolve(opts);
220
+ const picked = resolveTargets(svc, r.manifestPath);
221
+ if ("error" in picked) {
222
+ r.log(picked.error);
223
+ return 1;
224
+ }
225
+
226
+ let failures = 0;
227
+ for (const { short } of picked.targets) {
228
+ const pid = readPid(short, r.configDir);
229
+ if (pid === undefined) {
230
+ r.log(`${short} wasn't running.`);
231
+ continue;
232
+ }
233
+ if (!r.alive(pid)) {
234
+ clearPid(short, r.configDir);
235
+ r.log(`${short} wasn't running (cleaned stale pid file).`);
236
+ continue;
237
+ }
238
+
239
+ try {
240
+ r.kill(pid, "SIGTERM");
241
+ } catch (err) {
242
+ failures++;
243
+ r.log(`✗ ${short}: SIGTERM failed: ${err instanceof Error ? err.message : String(err)}`);
244
+ continue;
245
+ }
246
+
247
+ const deadline = r.now() + r.killWaitMs;
248
+ while (r.now() < deadline && r.alive(pid)) {
249
+ await r.sleep(r.pollIntervalMs);
250
+ }
251
+
252
+ if (r.alive(pid)) {
253
+ r.log(`${short} didn't exit after ${r.killWaitMs}ms; sending SIGKILL.`);
254
+ try {
255
+ r.kill(pid, "SIGKILL");
256
+ } catch (err) {
257
+ failures++;
258
+ r.log(`✗ ${short}: SIGKILL failed: ${err instanceof Error ? err.message : String(err)}`);
259
+ continue;
260
+ }
261
+ }
262
+
263
+ clearPid(short, r.configDir);
264
+ r.log(`✓ ${short} stopped.`);
265
+ }
266
+ return failures === 0 ? 0 : 1;
267
+ }
268
+
269
+ export async function restart(svc: string | undefined, opts: LifecycleOpts = {}): Promise<number> {
270
+ const stopCode = await stop(svc, opts);
271
+ if (stopCode !== 0) return stopCode;
272
+ return await start(svc, opts);
273
+ }
274
+
275
+ export interface LogsOpts {
276
+ configDir?: string;
277
+ log?: (line: string) => void;
278
+ /** Tail stream — if omitted, uses `tail -n <lines> -f <file>` via spawn. */
279
+ tailSpawner?: Spawner;
280
+ /** Number of trailing lines to print (default 200). */
281
+ lines?: number;
282
+ follow?: boolean;
283
+ }
284
+
285
+ export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
286
+ const configDir = opts.configDir ?? CONFIG_DIR;
287
+ const log = opts.log ?? ((line) => console.log(line));
288
+ const lines = opts.lines ?? 200;
289
+ const follow = opts.follow ?? false;
290
+
291
+ const spec = getSpec(svc);
292
+ if (!spec) {
293
+ log(`unknown service "${svc}". known: ${knownServices().join(", ")}`);
294
+ return 1;
295
+ }
296
+
297
+ const path = logPathFor(svc, configDir);
298
+ if (!existsSync(path)) {
299
+ log(`no logs yet for ${svc}. \`parachute start ${svc}\` to begin.`);
300
+ return 0;
301
+ }
302
+
303
+ if (follow) {
304
+ const spawner = opts.tailSpawner ?? {
305
+ spawn(cmd) {
306
+ const proc = Bun.spawn([...cmd], { stdio: ["ignore", "inherit", "inherit"] });
307
+ return proc.pid;
308
+ },
309
+ };
310
+ spawner.spawn(["tail", "-n", String(lines), "-f", path], path);
311
+ // tail runs until user Ctrl-C; block this process until it exits.
312
+ // When called from the real CLI, process.exit wraps us; in tests a
313
+ // stub spawner returns immediately and we fall through.
314
+ return 0;
315
+ }
316
+
317
+ // Non-follow path: read last N lines synchronously for a clean one-shot.
318
+ const content = await Bun.file(path).text();
319
+ const trimmed = content.replace(/\n$/, "");
320
+ const allLines = trimmed === "" ? [] : trimmed.split("\n");
321
+ const tail = allLines.slice(-lines);
322
+ for (const line of tail) log(line);
323
+ return 0;
324
+ }
@@ -0,0 +1,253 @@
1
+ import { existsSync, mkdirSync, readdirSync, renameSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { createInterface } from "node:readline/promises";
4
+ import { CONFIG_DIR } from "../config.ts";
5
+ import { knownServices } from "../service-spec.ts";
6
+
7
+ /**
8
+ * `parachute migrate` — sweep unrecognized entries at the ecosystem root
9
+ * (`~/.parachute/`) into a dated archive directory so pre-restructure cruft
10
+ * doesn't confuse beta installs.
11
+ *
12
+ * Archive, never delete: moved under `.archive-<YYYY-MM-DD>/` so anything
13
+ * swept is recoverable. Dotfiles and the recognized top-level entries
14
+ * (service dirs, services.json, expose-state.json, well-known/) are left
15
+ * alone. Content *inside* service dirs is owned by that service's own
16
+ * migration.
17
+ */
18
+
19
+ export const ARCHIVE_PREFIX = ".archive-";
20
+
21
+ /**
22
+ * Top-level names we keep in place. Service dirs derive from
23
+ * `knownServices()` so adding a service doesn't require touching migrate;
24
+ * `hub` is added explicitly since it's an internal-only lifecycle dir not
25
+ * in SERVICE_SPECS.
26
+ *
27
+ * `lens` is kept across the Notes→Lens→Notes rename round-trip
28
+ * (Apr 19 → Apr 22): users who installed during the brief Lens window
29
+ * have `~/.parachute/lens/` dirs that shouldn't get swept into
30
+ * `.archive-*` on upgrade. Safe to remove once launch users have all
31
+ * had a chance to re-install under the restored name.
32
+ */
33
+ export function safelistEntries(): Set<string> {
34
+ return new Set<string>([
35
+ ...knownServices(),
36
+ "lens",
37
+ "hub",
38
+ "services.json",
39
+ "expose-state.json",
40
+ "well-known",
41
+ ]);
42
+ }
43
+
44
+ /**
45
+ * Friendly labels for entries we've seen in the wild. Matched by exact name
46
+ * or (for sqlite companion files) by prefix. Purely cosmetic — drives the
47
+ * annotation column in the plan printout.
48
+ */
49
+ const KNOWN_CRUFT: Array<{ match: (name: string) => boolean; label: string }> = [
50
+ {
51
+ match: (n) => n === "daily.db" || n.startsWith("daily.db-"),
52
+ label: "legacy parachute-daily state",
53
+ },
54
+ { match: (n) => n === "server.yaml", label: "legacy server config" },
55
+ { match: (n) => n === "channel.log" || n === "channel.err", label: "legacy channel logs" },
56
+ { match: (n) => n === "channel.start.sh", label: "legacy channel launcher" },
57
+ { match: (n) => n === "logs", label: "vestigial top-level logs dir" },
58
+ {
59
+ match: (n) => n === "tokens.db" || n.startsWith("tokens.db-"),
60
+ label: "legacy top-level tokens db",
61
+ },
62
+ ];
63
+
64
+ function annotationFor(name: string): string | undefined {
65
+ for (const rule of KNOWN_CRUFT) if (rule.match(name)) return rule.label;
66
+ return undefined;
67
+ }
68
+
69
+ export interface ArchiveItem {
70
+ name: string;
71
+ absPath: string;
72
+ kind: "file" | "dir";
73
+ bytes: number;
74
+ annotation?: string;
75
+ }
76
+
77
+ export interface ArchivePlan {
78
+ archiveDirName: string;
79
+ archiveDir: string;
80
+ items: ArchiveItem[];
81
+ totalBytes: number;
82
+ }
83
+
84
+ function sizeOf(path: string): number {
85
+ const st = statSync(path);
86
+ if (!st.isDirectory()) return st.size;
87
+ let total = 0;
88
+ for (const entry of readdirSync(path, { withFileTypes: true })) {
89
+ total += sizeOf(join(path, entry.name));
90
+ }
91
+ return total;
92
+ }
93
+
94
+ function archiveDirName(now: Date): string {
95
+ return `${ARCHIVE_PREFIX}${now.toISOString().slice(0, 10)}`;
96
+ }
97
+
98
+ /**
99
+ * Inspect the ecosystem root and build a plan. Pure-ish: reads filesystem
100
+ * but never mutates. Returns zero-length items when nothing is archivable.
101
+ *
102
+ * Rule: skip anything starting with "." (dotfiles are the user's — `.env`,
103
+ * `.DS_Store`, prior `.archive-*` dirs, etc.) and anything in the safelist.
104
+ */
105
+ export function planArchive(configDir: string, now: Date): ArchivePlan {
106
+ const dirName = archiveDirName(now);
107
+ const archiveDir = join(configDir, dirName);
108
+ const plan: ArchivePlan = {
109
+ archiveDirName: dirName,
110
+ archiveDir,
111
+ items: [],
112
+ totalBytes: 0,
113
+ };
114
+ if (!existsSync(configDir)) return plan;
115
+
116
+ const safelist = safelistEntries();
117
+ const entries = readdirSync(configDir, { withFileTypes: true }).sort((a, b) =>
118
+ a.name.localeCompare(b.name),
119
+ );
120
+ for (const entry of entries) {
121
+ if (entry.name.startsWith(".")) continue;
122
+ if (safelist.has(entry.name)) continue;
123
+ const abs = join(configDir, entry.name);
124
+ // Dirent.isDirectory() follows symlinks on macOS/Linux — so a link
125
+ // pointing at an external tree would get sized via sizeOf() (bogus
126
+ // byte count, and potentially a slow walk through /mnt/... or similar).
127
+ // Classify the link itself as a zero-byte "file"; renameSync moves the
128
+ // link, not the target, which is the behavior we want.
129
+ if (entry.isSymbolicLink()) {
130
+ plan.items.push({
131
+ name: entry.name,
132
+ absPath: abs,
133
+ kind: "file",
134
+ bytes: 0,
135
+ annotation: annotationFor(entry.name),
136
+ });
137
+ continue;
138
+ }
139
+ const bytes = sizeOf(abs);
140
+ plan.items.push({
141
+ name: entry.name,
142
+ absPath: abs,
143
+ kind: entry.isDirectory() ? "dir" : "file",
144
+ bytes,
145
+ annotation: annotationFor(entry.name),
146
+ });
147
+ plan.totalBytes += bytes;
148
+ }
149
+ return plan;
150
+ }
151
+
152
+ function formatBytes(bytes: number): string {
153
+ if (bytes < 1024) return `${bytes} B`;
154
+ const units = ["KB", "MB", "GB"];
155
+ let v = bytes / 1024;
156
+ let i = 0;
157
+ while (v >= 1024 && i < units.length - 1) {
158
+ v /= 1024;
159
+ i += 1;
160
+ }
161
+ return `${v.toFixed(1)} ${units[i]}`;
162
+ }
163
+
164
+ function formatPlan(plan: ArchivePlan): string[] {
165
+ const lines: string[] = [];
166
+ lines.push(
167
+ `Will archive: ${plan.items.length} item${plan.items.length === 1 ? "" : "s"} (${formatBytes(plan.totalBytes)}) → ${plan.archiveDirName}/`,
168
+ );
169
+ for (const item of plan.items) {
170
+ const kindMark = item.kind === "dir" ? "/" : "";
171
+ const note = item.annotation ? ` — ${item.annotation}` : "";
172
+ lines.push(` ${item.name}${kindMark} (${formatBytes(item.bytes)})${note}`);
173
+ }
174
+ return lines;
175
+ }
176
+
177
+ /**
178
+ * Pick a destination name inside the archive dir. If a prior sweep the same
179
+ * day already archived the same name, suffix with `.dup-<epoch-ms>` so we
180
+ * never clobber. Rare in practice.
181
+ */
182
+ function resolveDest(archiveDir: string, name: string, now: Date): string {
183
+ const target = join(archiveDir, name);
184
+ if (!existsSync(target)) return target;
185
+ return join(archiveDir, `${name}.dup-${now.getTime()}`);
186
+ }
187
+
188
+ export async function defaultPrompt(question: string): Promise<string> {
189
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
190
+ const answer = await rl.question(question);
191
+ rl.close();
192
+ return answer;
193
+ }
194
+
195
+ export interface MigrateOpts {
196
+ configDir?: string;
197
+ now?: () => Date;
198
+ log?: (line: string) => void;
199
+ prompt?: (question: string) => Promise<string>;
200
+ dryRun?: boolean;
201
+ yes?: boolean;
202
+ }
203
+
204
+ export async function migrate(opts: MigrateOpts = {}): Promise<number> {
205
+ const configDir = opts.configDir ?? CONFIG_DIR;
206
+ const now = (opts.now ?? (() => new Date()))();
207
+ const log = opts.log ?? ((line) => console.log(line));
208
+ const prompt = opts.prompt ?? defaultPrompt;
209
+ const dryRun = opts.dryRun ?? false;
210
+ const yes = opts.yes ?? false;
211
+
212
+ const plan = planArchive(configDir, now);
213
+ if (plan.items.length === 0) {
214
+ log(`Nothing to archive. ${configDir} is already clean.`);
215
+ return 0;
216
+ }
217
+
218
+ for (const line of formatPlan(plan)) log(line);
219
+
220
+ if (dryRun) {
221
+ log("(dry-run — no changes made)");
222
+ return 0;
223
+ }
224
+
225
+ if (!yes) {
226
+ const answer = (await prompt("Proceed? [y/N] ")).trim().toLowerCase();
227
+ if (answer !== "y" && answer !== "yes") {
228
+ log("Aborted.");
229
+ return 1;
230
+ }
231
+ }
232
+
233
+ mkdirSync(plan.archiveDir, { recursive: true });
234
+ for (const item of plan.items) {
235
+ const dest = resolveDest(plan.archiveDir, item.name, now);
236
+ renameSync(item.absPath, dest);
237
+ }
238
+ log(
239
+ `✓ Archived ${plan.items.length} item${plan.items.length === 1 ? "" : "s"} to ${plan.archiveDirName}/`,
240
+ );
241
+ return 0;
242
+ }
243
+
244
+ /**
245
+ * One-line notice for contexts where migrate is *not* what the user
246
+ * asked for (e.g., after `parachute install`). Returns undefined when
247
+ * there's nothing archivable so callers can branch on truthy/falsy.
248
+ */
249
+ export function migrateNotice(configDir: string, now: Date): string | undefined {
250
+ const plan = planArchive(configDir, now);
251
+ if (plan.items.length === 0) return undefined;
252
+ return `parachute migrate: ${plan.items.length} unrecognized entr${plan.items.length === 1 ? "y" : "ies"} at ecosystem root — run 'parachute migrate' to archive.`;
253
+ }