@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.
- package/LICENSE +661 -0
- package/README.md +284 -0
- package/package.json +31 -0
- package/src/__tests__/auth.test.ts +101 -0
- package/src/__tests__/auto-wire.test.ts +283 -0
- package/src/__tests__/cli.test.ts +192 -0
- package/src/__tests__/cloudflare-config.test.ts +54 -0
- package/src/__tests__/cloudflare-detect.test.ts +68 -0
- package/src/__tests__/cloudflare-state.test.ts +92 -0
- package/src/__tests__/cloudflare-tunnel.test.ts +207 -0
- package/src/__tests__/config.test.ts +18 -0
- package/src/__tests__/env-file.test.ts +125 -0
- package/src/__tests__/expose-auth-preflight.test.ts +201 -0
- package/src/__tests__/expose-cloudflare.test.ts +484 -0
- package/src/__tests__/expose-interactive.test.ts +703 -0
- package/src/__tests__/expose-last-provider.test.ts +113 -0
- package/src/__tests__/expose-off-auto.test.ts +269 -0
- package/src/__tests__/expose-state.test.ts +101 -0
- package/src/__tests__/expose.test.ts +1581 -0
- package/src/__tests__/hub-control.test.ts +346 -0
- package/src/__tests__/hub-server.test.ts +157 -0
- package/src/__tests__/hub.test.ts +116 -0
- package/src/__tests__/install.test.ts +1145 -0
- package/src/__tests__/lifecycle.test.ts +608 -0
- package/src/__tests__/migrate.test.ts +422 -0
- package/src/__tests__/notes-serve.test.ts +135 -0
- package/src/__tests__/port-assign.test.ts +178 -0
- package/src/__tests__/process-state.test.ts +140 -0
- package/src/__tests__/scribe-config.test.ts +193 -0
- package/src/__tests__/scribe-provider-interactive.test.ts +361 -0
- package/src/__tests__/services-manifest.test.ts +177 -0
- package/src/__tests__/status.test.ts +347 -0
- package/src/__tests__/tailscale-commands.test.ts +111 -0
- package/src/__tests__/tailscale-detect.test.ts +64 -0
- package/src/__tests__/vault-auth-status.test.ts +164 -0
- package/src/__tests__/vault-tokens-create-interactive.test.ts +183 -0
- package/src/__tests__/well-known.test.ts +214 -0
- package/src/auto-wire.ts +184 -0
- package/src/cli.ts +482 -0
- package/src/cloudflare/config.ts +58 -0
- package/src/cloudflare/detect.ts +58 -0
- package/src/cloudflare/state.ts +96 -0
- package/src/cloudflare/tunnel.ts +135 -0
- package/src/commands/auth.ts +69 -0
- package/src/commands/expose-auth-preflight.ts +217 -0
- package/src/commands/expose-cloudflare.ts +329 -0
- package/src/commands/expose-interactive.ts +428 -0
- package/src/commands/expose-off-auto.ts +199 -0
- package/src/commands/expose.ts +522 -0
- package/src/commands/install.ts +422 -0
- package/src/commands/lifecycle.ts +324 -0
- package/src/commands/migrate.ts +253 -0
- package/src/commands/scribe-provider-interactive.ts +269 -0
- package/src/commands/status.ts +238 -0
- package/src/commands/vault-tokens-create-interactive.ts +137 -0
- package/src/commands/vault.ts +17 -0
- package/src/config.ts +16 -0
- package/src/env-file.ts +76 -0
- package/src/expose-last-provider.ts +71 -0
- package/src/expose-state.ts +125 -0
- package/src/help.ts +279 -0
- package/src/hub-control.ts +254 -0
- package/src/hub-origin.ts +44 -0
- package/src/hub-server.ts +113 -0
- package/src/hub.ts +674 -0
- package/src/notes-serve.ts +135 -0
- package/src/port-assign.ts +125 -0
- package/src/process-state.ts +111 -0
- package/src/scribe-config.ts +149 -0
- package/src/service-spec.ts +296 -0
- package/src/services-manifest.ts +171 -0
- package/src/tailscale/commands.ts +41 -0
- package/src/tailscale/detect.ts +107 -0
- package/src/tailscale/run.ts +28 -0
- package/src/vault/auth-status.ts +179 -0
- 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
|
+
}
|