@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21
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/README.md +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
package/src/commands/migrate.ts
CHANGED
|
@@ -1,50 +1,108 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, renameSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
|
-
import { CONFIG_DIR } from "../config.ts";
|
|
5
|
-
import {
|
|
4
|
+
import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
5
|
+
import { HUB_SVC } from "../hub-control.ts";
|
|
6
|
+
import { type AliveFn, defaultAlive, processState } from "../process-state.ts";
|
|
7
|
+
import { knownServices, shortNameForManifest } from "../service-spec.ts";
|
|
8
|
+
import { readManifestLenient } from "../services-manifest.ts";
|
|
6
9
|
|
|
7
10
|
/**
|
|
8
|
-
* `parachute migrate` — sweep
|
|
11
|
+
* `parachute migrate` — sweep known-cruft entries at the ecosystem root
|
|
9
12
|
* (`~/.parachute/`) into a dated archive directory so pre-restructure cruft
|
|
10
13
|
* doesn't confuse beta installs.
|
|
11
14
|
*
|
|
15
|
+
* Allowlist-of-what-to-archive (the original 2026-05-27 redesign closed
|
|
16
|
+
* the prior blocklist-with-safelist shape, hub#440). The risk with the
|
|
17
|
+
* older shape: anything new appearing at the ecosystem root (a future
|
|
18
|
+
* module's dir, `hub.db`, a user's own dotfile, etc.) would get swept by
|
|
19
|
+
* default unless someone remembered to add it to the safelist. Aaron
|
|
20
|
+
* caught a near-miss where `hub.db` was missing from the safelist; that
|
|
21
|
+
* was the trigger for flipping the model.
|
|
22
|
+
*
|
|
23
|
+
* The new model:
|
|
24
|
+
*
|
|
25
|
+
* - `KNOWN_CRUFT` is the *actual archive criterion* — only entries that
|
|
26
|
+
* match a rule there get archived.
|
|
27
|
+
* - `KNOWN_ARCHIVABLE_DIRS` covers directories we explicitly sweep (the
|
|
28
|
+
* `lens` legacy dir from the Notes→Lens→Notes rename round-trip).
|
|
29
|
+
* - Everything else — including new modules' dirs, future state files,
|
|
30
|
+
* and the user's own stray files — gets a `[unknown — skipping]`
|
|
31
|
+
* annotation and is left in place. The user can remove unknowns
|
|
32
|
+
* manually if they want.
|
|
33
|
+
*
|
|
12
34
|
* Archive, never delete: moved under `.archive-<YYYY-MM-DD>/` so anything
|
|
13
|
-
* swept is recoverable. Dotfiles
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
35
|
+
* swept is recoverable. Dotfiles at root are still left alone (the user's
|
|
36
|
+
* own `.env`, `.DS_Store`, prior `.archive-*` dirs).
|
|
37
|
+
*
|
|
38
|
+
* Cut-2 safety procedures (also 2026-05-27):
|
|
39
|
+
*
|
|
40
|
+
* - Refuses to sweep while any service in `services.json` (plus the hub)
|
|
41
|
+
* is currently running — moving a path a daemon owns would corrupt
|
|
42
|
+
* state.
|
|
43
|
+
* - SQLite-shaped files (`*.db`, `*.db-wal`, `*.db-shm`) get a `[live-db]`
|
|
44
|
+
* risk label and trigger an extra confirmation noting wal/shm
|
|
45
|
+
* consistency.
|
|
46
|
+
* - The printed plan annotates each item `[safe]` / `[live-db]` /
|
|
47
|
+
* `[unknown — skipping]`, sorts skipped items last.
|
|
48
|
+
* - Non-TTY invocations refuse without `--yes` so a pipe from CI can't
|
|
49
|
+
* accidentally archive a real install.
|
|
50
|
+
* - `--list` shows what would happen without prompting (friendlier
|
|
51
|
+
* phrasing of `--dry-run`).
|
|
17
52
|
*/
|
|
18
53
|
|
|
19
54
|
export const ARCHIVE_PREFIX = ".archive-";
|
|
20
55
|
|
|
21
56
|
/**
|
|
22
|
-
* Top-level names we
|
|
23
|
-
* `knownServices()`
|
|
24
|
-
*
|
|
25
|
-
*
|
|
57
|
+
* Top-level names we leave in place. Service dirs derive from
|
|
58
|
+
* `knownServices()`; `hub` is added explicitly since it's an internal-only
|
|
59
|
+
* lifecycle dir not in SERVICE_SPECS. Stays in step with the rest of the
|
|
60
|
+
* hub's view of what belongs at the root, so `migrateNotice` and
|
|
61
|
+
* `parachute install`'s post-install hint don't false-positive on the
|
|
62
|
+
* latest legitimate root entries.
|
|
26
63
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
64
|
+
* Note: under the allowlist model the safelist is now informational —
|
|
65
|
+
* archiving only ever happens for entries that match an explicit rule
|
|
66
|
+
* (`KNOWN_CRUFT` / `KNOWN_ARCHIVABLE_DIRS`). The safelist is still
|
|
67
|
+
* retained so `migrateNotice`'s count of "things at the root that aren't
|
|
68
|
+
* recognized" stays meaningful, and so safelisted entries don't show up
|
|
69
|
+
* as `[unknown — skipping]` noise in the plan.
|
|
32
70
|
*/
|
|
33
71
|
export function safelistEntries(): Set<string> {
|
|
34
72
|
return new Set<string>([
|
|
35
73
|
...knownServices(),
|
|
36
|
-
"lens",
|
|
37
74
|
"hub",
|
|
38
75
|
"services.json",
|
|
39
76
|
"expose-state.json",
|
|
77
|
+
"cloudflared-state.json",
|
|
78
|
+
"hub.db",
|
|
79
|
+
"hub.db-wal",
|
|
80
|
+
"hub.db-shm",
|
|
40
81
|
"well-known",
|
|
82
|
+
"cloudflared",
|
|
41
83
|
]);
|
|
42
84
|
}
|
|
43
85
|
|
|
44
86
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
87
|
+
* Allowlist of known-archivable directories. Distinct from `KNOWN_CRUFT`
|
|
88
|
+
* (which matches by name/prefix predicate) — directories listed here are
|
|
89
|
+
* always treated as `safe` to archive.
|
|
90
|
+
*
|
|
91
|
+
* `lens` is the only entry: a legacy directory left over from the brief
|
|
92
|
+
* Notes→Lens→Notes rename round-trip (2026-04-19 → 2026-04-22). Users who
|
|
93
|
+
* installed during that window have `~/.parachute/lens/` dirs that should
|
|
94
|
+
* be archived now that the rename is finished. Safe to drop from this
|
|
95
|
+
* list once enough operator cycles have passed to be confident no
|
|
96
|
+
* install is still on the Lens-era code path.
|
|
97
|
+
*/
|
|
98
|
+
export const KNOWN_ARCHIVABLE_DIRS: ReadonlySet<string> = new Set<string>(["lens"]);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Known-cruft rules. Each rule names a label (the friendly description in
|
|
102
|
+
* the plan) and a predicate that matches by name or prefix. Under the
|
|
103
|
+
* 2026-05-27 redesign this is the *primary* archive criterion: an entry
|
|
104
|
+
* at the ecosystem root that doesn't match here (or `KNOWN_ARCHIVABLE_DIRS`)
|
|
105
|
+
* is treated as unknown and left in place.
|
|
48
106
|
*/
|
|
49
107
|
const KNOWN_CRUFT: Array<{ match: (name: string) => boolean; label: string }> = [
|
|
50
108
|
{
|
|
@@ -61,24 +119,49 @@ const KNOWN_CRUFT: Array<{ match: (name: string) => boolean; label: string }> =
|
|
|
61
119
|
},
|
|
62
120
|
];
|
|
63
121
|
|
|
64
|
-
function
|
|
122
|
+
function cruftLabel(name: string): string | undefined {
|
|
65
123
|
for (const rule of KNOWN_CRUFT) if (rule.match(name)) return rule.label;
|
|
66
124
|
return undefined;
|
|
67
125
|
}
|
|
68
126
|
|
|
69
|
-
|
|
127
|
+
/**
|
|
128
|
+
* SQLite shape — `.db` plus its WAL/SHM companions. Files with this shape
|
|
129
|
+
* carry the `live-db` risk label and pull a second confirmation in the
|
|
130
|
+
* interactive path because the three files are only consistent as a set.
|
|
131
|
+
*/
|
|
132
|
+
function isSqliteShape(name: string): boolean {
|
|
133
|
+
return /\.db(?:-wal|-shm)?$/.test(name);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type RiskLabel = "safe" | "live-db" | "unknown";
|
|
137
|
+
|
|
138
|
+
export interface PlanItem {
|
|
70
139
|
name: string;
|
|
71
140
|
absPath: string;
|
|
72
141
|
kind: "file" | "dir";
|
|
73
142
|
bytes: number;
|
|
143
|
+
/** Friendly description (e.g. "legacy parachute-daily state"). */
|
|
74
144
|
annotation?: string;
|
|
145
|
+
risk: RiskLabel;
|
|
146
|
+
/** True iff this item will actually be moved into the archive. */
|
|
147
|
+
archive: boolean;
|
|
75
148
|
}
|
|
76
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Kept as an alias for back-compat with callers / older tests that import
|
|
152
|
+
* `ArchiveItem`. Same shape as `PlanItem`.
|
|
153
|
+
*/
|
|
154
|
+
export type ArchiveItem = PlanItem;
|
|
155
|
+
|
|
77
156
|
export interface ArchivePlan {
|
|
78
157
|
archiveDirName: string;
|
|
79
158
|
archiveDir: string;
|
|
80
|
-
|
|
159
|
+
/** Every entry encountered at the root (archivable + skipped). */
|
|
160
|
+
items: PlanItem[];
|
|
161
|
+
/** Total bytes across `archive: true` items only. */
|
|
81
162
|
totalBytes: number;
|
|
163
|
+
/** True iff at least one `archive: true` item is a SQLite-shape file. */
|
|
164
|
+
hasLiveDb: boolean;
|
|
82
165
|
}
|
|
83
166
|
|
|
84
167
|
function sizeOf(path: string): number {
|
|
@@ -95,12 +178,36 @@ function archiveDirName(now: Date): string {
|
|
|
95
178
|
return `${ARCHIVE_PREFIX}${now.toISOString().slice(0, 10)}`;
|
|
96
179
|
}
|
|
97
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Classify a single root entry against the allowlist + known-cruft rules.
|
|
183
|
+
* `safelist` is the recognized-and-leave-alone set; nothing on it ever
|
|
184
|
+
* reaches the plan at all (filtered out upstream).
|
|
185
|
+
*/
|
|
186
|
+
function classify(name: string): { risk: RiskLabel; annotation?: string; archive: boolean } {
|
|
187
|
+
const cruft = cruftLabel(name);
|
|
188
|
+
if (cruft) {
|
|
189
|
+
const risk: RiskLabel = isSqliteShape(name) ? "live-db" : "safe";
|
|
190
|
+
return { risk, annotation: cruft, archive: true };
|
|
191
|
+
}
|
|
192
|
+
if (KNOWN_ARCHIVABLE_DIRS.has(name)) {
|
|
193
|
+
return { risk: "safe", annotation: "legacy directory", archive: true };
|
|
194
|
+
}
|
|
195
|
+
return { risk: "unknown", archive: false };
|
|
196
|
+
}
|
|
197
|
+
|
|
98
198
|
/**
|
|
99
199
|
* Inspect the ecosystem root and build a plan. Pure-ish: reads filesystem
|
|
100
|
-
* but never mutates. Returns zero-length items when nothing is
|
|
200
|
+
* but never mutates. Returns zero-length items when nothing is at the root
|
|
201
|
+
* beyond the safelist.
|
|
101
202
|
*
|
|
102
203
|
* Rule: skip anything starting with "." (dotfiles are the user's — `.env`,
|
|
103
204
|
* `.DS_Store`, prior `.archive-*` dirs, etc.) and anything in the safelist.
|
|
205
|
+
* Everything else goes into the plan with a classification; only items
|
|
206
|
+
* with `archive: true` are actually swept.
|
|
207
|
+
*
|
|
208
|
+
* Sort order: archivable items first (alphabetical), then skipped items
|
|
209
|
+
* last — keeps the "what will happen" reading at the top of the plan
|
|
210
|
+
* printout.
|
|
104
211
|
*/
|
|
105
212
|
export function planArchive(configDir: string, now: Date): ArchivePlan {
|
|
106
213
|
const dirName = archiveDirName(now);
|
|
@@ -110,6 +217,7 @@ export function planArchive(configDir: string, now: Date): ArchivePlan {
|
|
|
110
217
|
archiveDir,
|
|
111
218
|
items: [],
|
|
112
219
|
totalBytes: 0,
|
|
220
|
+
hasLiveDb: false,
|
|
113
221
|
};
|
|
114
222
|
if (!existsSync(configDir)) return plan;
|
|
115
223
|
|
|
@@ -117,35 +225,50 @@ export function planArchive(configDir: string, now: Date): ArchivePlan {
|
|
|
117
225
|
const entries = readdirSync(configDir, { withFileTypes: true }).sort((a, b) =>
|
|
118
226
|
a.name.localeCompare(b.name),
|
|
119
227
|
);
|
|
228
|
+
const collected: PlanItem[] = [];
|
|
120
229
|
for (const entry of entries) {
|
|
121
230
|
if (entry.name.startsWith(".")) continue;
|
|
122
231
|
if (safelist.has(entry.name)) continue;
|
|
123
232
|
const abs = join(configDir, entry.name);
|
|
233
|
+
const cls = classify(entry.name);
|
|
124
234
|
// Dirent.isDirectory() follows symlinks on macOS/Linux — so a link
|
|
125
235
|
// pointing at an external tree would get sized via sizeOf() (bogus
|
|
126
236
|
// byte count, and potentially a slow walk through /mnt/... or similar).
|
|
127
237
|
// Classify the link itself as a zero-byte "file"; renameSync moves the
|
|
128
238
|
// link, not the target, which is the behavior we want.
|
|
129
239
|
if (entry.isSymbolicLink()) {
|
|
130
|
-
|
|
240
|
+
const item: PlanItem = {
|
|
131
241
|
name: entry.name,
|
|
132
242
|
absPath: abs,
|
|
133
243
|
kind: "file",
|
|
134
244
|
bytes: 0,
|
|
135
|
-
|
|
136
|
-
|
|
245
|
+
risk: cls.risk,
|
|
246
|
+
archive: cls.archive,
|
|
247
|
+
};
|
|
248
|
+
if (cls.annotation !== undefined) item.annotation = cls.annotation;
|
|
249
|
+
collected.push(item);
|
|
137
250
|
continue;
|
|
138
251
|
}
|
|
139
|
-
const bytes = sizeOf(abs);
|
|
140
|
-
|
|
252
|
+
const bytes = cls.archive ? sizeOf(abs) : 0;
|
|
253
|
+
const item: PlanItem = {
|
|
141
254
|
name: entry.name,
|
|
142
255
|
absPath: abs,
|
|
143
256
|
kind: entry.isDirectory() ? "dir" : "file",
|
|
144
257
|
bytes,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
258
|
+
risk: cls.risk,
|
|
259
|
+
archive: cls.archive,
|
|
260
|
+
};
|
|
261
|
+
if (cls.annotation !== undefined) item.annotation = cls.annotation;
|
|
262
|
+
collected.push(item);
|
|
263
|
+
if (cls.archive) plan.totalBytes += bytes;
|
|
148
264
|
}
|
|
265
|
+
// Sort: archivable first (alpha), then skipped (alpha). Stable across runs.
|
|
266
|
+
collected.sort((a, b) => {
|
|
267
|
+
if (a.archive !== b.archive) return a.archive ? -1 : 1;
|
|
268
|
+
return a.name.localeCompare(b.name);
|
|
269
|
+
});
|
|
270
|
+
plan.items = collected;
|
|
271
|
+
plan.hasLiveDb = collected.some((i) => i.archive && i.risk === "live-db");
|
|
149
272
|
return plan;
|
|
150
273
|
}
|
|
151
274
|
|
|
@@ -161,15 +284,35 @@ function formatBytes(bytes: number): string {
|
|
|
161
284
|
return `${v.toFixed(1)} ${units[i]}`;
|
|
162
285
|
}
|
|
163
286
|
|
|
287
|
+
function riskTag(item: PlanItem): string {
|
|
288
|
+
if (!item.archive) return "[unknown — skipping]";
|
|
289
|
+
if (item.risk === "live-db") return "[live-db]";
|
|
290
|
+
return "[safe]";
|
|
291
|
+
}
|
|
292
|
+
|
|
164
293
|
function formatPlan(plan: ArchivePlan): string[] {
|
|
165
294
|
const lines: string[] = [];
|
|
295
|
+
const archivable = plan.items.filter((i) => i.archive);
|
|
296
|
+
const skipped = plan.items.filter((i) => !i.archive);
|
|
166
297
|
lines.push(
|
|
167
|
-
`Will archive: ${
|
|
298
|
+
`Will archive: ${archivable.length} item${archivable.length === 1 ? "" : "s"} (${formatBytes(plan.totalBytes)}) → ${plan.archiveDirName}/`,
|
|
168
299
|
);
|
|
169
|
-
for (const item of
|
|
300
|
+
for (const item of archivable) {
|
|
170
301
|
const kindMark = item.kind === "dir" ? "/" : "";
|
|
171
302
|
const note = item.annotation ? ` — ${item.annotation}` : "";
|
|
172
|
-
lines.push(` ${item.name}${kindMark} (${formatBytes(item.bytes)})${note}`);
|
|
303
|
+
lines.push(` ${riskTag(item)} ${item.name}${kindMark} (${formatBytes(item.bytes)})${note}`);
|
|
304
|
+
}
|
|
305
|
+
if (skipped.length > 0) {
|
|
306
|
+
lines.push("");
|
|
307
|
+
lines.push(
|
|
308
|
+
`Leaving alone: ${skipped.length} unknown entr${skipped.length === 1 ? "y" : "ies"} at the root.`,
|
|
309
|
+
);
|
|
310
|
+
lines.push(" (I don't recognize these — they may be from a module hub doesn't know about,");
|
|
311
|
+
lines.push(" or from your own setup. If they're safe to remove, you can do it manually.)");
|
|
312
|
+
for (const item of skipped) {
|
|
313
|
+
const kindMark = item.kind === "dir" ? "/" : "";
|
|
314
|
+
lines.push(` ${riskTag(item)} ${item.name}${kindMark}`);
|
|
315
|
+
}
|
|
173
316
|
}
|
|
174
317
|
return lines;
|
|
175
318
|
}
|
|
@@ -192,24 +335,91 @@ export async function defaultPrompt(question: string): Promise<string> {
|
|
|
192
335
|
return answer;
|
|
193
336
|
}
|
|
194
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Probe whether any managed service (or the hub) is currently running.
|
|
340
|
+
* Returns the list of short names that are live; an empty list means the
|
|
341
|
+
* sweep is safe to proceed.
|
|
342
|
+
*
|
|
343
|
+
* Reads services.json leniently so a malformed entry doesn't block the
|
|
344
|
+
* pre-flight (better to surface "running: vault" + a corrupt-entry warning
|
|
345
|
+
* elsewhere than to refuse migration on an unrelated parsing problem).
|
|
346
|
+
*/
|
|
347
|
+
export function listRunningServices(
|
|
348
|
+
configDir: string,
|
|
349
|
+
manifestPath: string,
|
|
350
|
+
alive: AliveFn,
|
|
351
|
+
): string[] {
|
|
352
|
+
const running: string[] = [];
|
|
353
|
+
const hubState = processState(HUB_SVC, configDir, alive);
|
|
354
|
+
if (hubState.status === "running") running.push(HUB_SVC);
|
|
355
|
+
let manifest: ReturnType<typeof readManifestLenient>;
|
|
356
|
+
try {
|
|
357
|
+
manifest = readManifestLenient(manifestPath);
|
|
358
|
+
} catch {
|
|
359
|
+
return running;
|
|
360
|
+
}
|
|
361
|
+
for (const entry of manifest.services) {
|
|
362
|
+
const short = shortNameForManifest(entry.name) ?? entry.name;
|
|
363
|
+
if (running.includes(short)) continue;
|
|
364
|
+
const state = processState(short, configDir, alive);
|
|
365
|
+
if (state.status === "running") running.push(short);
|
|
366
|
+
}
|
|
367
|
+
return running;
|
|
368
|
+
}
|
|
369
|
+
|
|
195
370
|
export interface MigrateOpts {
|
|
196
371
|
configDir?: string;
|
|
372
|
+
manifestPath?: string;
|
|
197
373
|
now?: () => Date;
|
|
198
374
|
log?: (line: string) => void;
|
|
199
375
|
prompt?: (question: string) => Promise<string>;
|
|
200
376
|
dryRun?: boolean;
|
|
201
377
|
yes?: boolean;
|
|
378
|
+
/** `--list` — synonym for `--dry-run` with a more discoverable flag name. */
|
|
379
|
+
list?: boolean;
|
|
380
|
+
/** Test seam: process-liveness check used by `listRunningServices`. */
|
|
381
|
+
alive?: AliveFn;
|
|
382
|
+
/**
|
|
383
|
+
* Test seam: override the TTY check. Production reads
|
|
384
|
+
* `process.stdin.isTTY`; tests pass `true`/`false` to drive the
|
|
385
|
+
* non-interactive guard without manipulating real fds.
|
|
386
|
+
*/
|
|
387
|
+
isTty?: boolean;
|
|
202
388
|
}
|
|
203
389
|
|
|
204
390
|
export async function migrate(opts: MigrateOpts = {}): Promise<number> {
|
|
205
391
|
const configDir = opts.configDir ?? CONFIG_DIR;
|
|
392
|
+
const manifestPath = opts.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
206
393
|
const now = (opts.now ?? (() => new Date()))();
|
|
207
394
|
const log = opts.log ?? ((line) => console.log(line));
|
|
208
395
|
const prompt = opts.prompt ?? defaultPrompt;
|
|
209
|
-
const dryRun = opts.dryRun ?? false;
|
|
396
|
+
const dryRun = (opts.dryRun ?? false) || (opts.list ?? false);
|
|
210
397
|
const yes = opts.yes ?? false;
|
|
398
|
+
const alive = opts.alive ?? defaultAlive;
|
|
399
|
+
const isTty = opts.isTty ?? Boolean(process.stdin.isTTY);
|
|
400
|
+
|
|
401
|
+
// Refuse-while-running: archiving a path a live daemon owns can corrupt
|
|
402
|
+
// its state. Print the runners and bail before we read the directory.
|
|
403
|
+
// `--list` and `--dry-run` are read-only and skip this guard; the
|
|
404
|
+
// operator may explicitly want to see what would move while things are
|
|
405
|
+
// up.
|
|
406
|
+
if (!dryRun) {
|
|
407
|
+
const running = listRunningServices(configDir, manifestPath, alive);
|
|
408
|
+
if (running.length > 0) {
|
|
409
|
+
log("parachute migrate: services are currently running — refusing to sweep:");
|
|
410
|
+
for (const short of running) log(` - ${short}`);
|
|
411
|
+
log("");
|
|
412
|
+
log("Stop them first, then re-run:");
|
|
413
|
+
log(" parachute stop");
|
|
414
|
+
log("");
|
|
415
|
+
log("Or preview the plan without changing anything:");
|
|
416
|
+
log(" parachute migrate --list");
|
|
417
|
+
return 1;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
211
420
|
|
|
212
421
|
const plan = planArchive(configDir, now);
|
|
422
|
+
const archivable = plan.items.filter((i) => i.archive);
|
|
213
423
|
if (plan.items.length === 0) {
|
|
214
424
|
log(`Nothing to archive. ${configDir} is already clean.`);
|
|
215
425
|
return 0;
|
|
@@ -217,26 +427,63 @@ export async function migrate(opts: MigrateOpts = {}): Promise<number> {
|
|
|
217
427
|
|
|
218
428
|
for (const line of formatPlan(plan)) log(line);
|
|
219
429
|
|
|
430
|
+
if (archivable.length === 0) {
|
|
431
|
+
// Only unknowns at the root; nothing to do.
|
|
432
|
+
log("");
|
|
433
|
+
log("Nothing recognized to archive.");
|
|
434
|
+
return 0;
|
|
435
|
+
}
|
|
436
|
+
|
|
220
437
|
if (dryRun) {
|
|
221
|
-
log("
|
|
438
|
+
log("");
|
|
439
|
+
log(opts.list ? "(--list — no changes made)" : "(dry-run — no changes made)");
|
|
222
440
|
return 0;
|
|
223
441
|
}
|
|
224
442
|
|
|
443
|
+
// Non-TTY without `--yes` is a hard refuse. A pipe from CI or a launchd
|
|
444
|
+
// wrapper that lost its terminal shouldn't be able to silently archive
|
|
445
|
+
// user state — prefer an actionable error.
|
|
446
|
+
if (!isTty && !yes) {
|
|
447
|
+
log("");
|
|
448
|
+
log("parachute migrate: refusing to sweep without a TTY.");
|
|
449
|
+
log("Run interactively, or pass `--yes` if you're certain. To preview:");
|
|
450
|
+
log(" parachute migrate --list");
|
|
451
|
+
return 1;
|
|
452
|
+
}
|
|
453
|
+
|
|
225
454
|
if (!yes) {
|
|
226
455
|
const answer = (await prompt("Proceed? [y/N] ")).trim().toLowerCase();
|
|
227
456
|
if (answer !== "y" && answer !== "yes") {
|
|
228
457
|
log("Aborted.");
|
|
229
458
|
return 1;
|
|
230
459
|
}
|
|
460
|
+
// Extra confirmation when a SQLite-shape file is in the plan. The
|
|
461
|
+
// wal/shm companions are only consistent with their .db when the
|
|
462
|
+
// owning process is stopped (we already refused-while-running above,
|
|
463
|
+
// so they are), but operators sometimes have ad-hoc backup tooling
|
|
464
|
+
// that watches these — extra y/N is a cheap insurance against
|
|
465
|
+
// surprise.
|
|
466
|
+
if (plan.hasLiveDb) {
|
|
467
|
+
log("");
|
|
468
|
+
log("⚠ The plan includes SQLite-shape files (`*.db`, `*.db-wal`, `*.db-shm`).");
|
|
469
|
+
log(" These three are only consistent as a set — the archive moves them together,");
|
|
470
|
+
log(" but if anything outside Parachute is reading them (backup tooling, etc.),");
|
|
471
|
+
log(" that consumer will see a missing file after the sweep.");
|
|
472
|
+
const dbAnswer = (await prompt("Archive the live-db files too? [y/N] ")).trim().toLowerCase();
|
|
473
|
+
if (dbAnswer !== "y" && dbAnswer !== "yes") {
|
|
474
|
+
log("Aborted.");
|
|
475
|
+
return 1;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
231
478
|
}
|
|
232
479
|
|
|
233
480
|
mkdirSync(plan.archiveDir, { recursive: true });
|
|
234
|
-
for (const item of
|
|
481
|
+
for (const item of archivable) {
|
|
235
482
|
const dest = resolveDest(plan.archiveDir, item.name, now);
|
|
236
483
|
renameSync(item.absPath, dest);
|
|
237
484
|
}
|
|
238
485
|
log(
|
|
239
|
-
`✓ Archived ${
|
|
486
|
+
`✓ Archived ${archivable.length} item${archivable.length === 1 ? "" : "s"} to ${plan.archiveDirName}/`,
|
|
240
487
|
);
|
|
241
488
|
return 0;
|
|
242
489
|
}
|
|
@@ -245,9 +492,14 @@ export async function migrate(opts: MigrateOpts = {}): Promise<number> {
|
|
|
245
492
|
* One-line notice for contexts where migrate is *not* what the user
|
|
246
493
|
* asked for (e.g., after `parachute install`). Returns undefined when
|
|
247
494
|
* there's nothing archivable so callers can branch on truthy/falsy.
|
|
495
|
+
*
|
|
496
|
+
* Counts only `archive: true` items — unknowns at the root don't trip
|
|
497
|
+
* the notice (they're not actually candidates for sweeping; the user
|
|
498
|
+
* sees them only when they explicitly run `parachute migrate`).
|
|
248
499
|
*/
|
|
249
500
|
export function migrateNotice(configDir: string, now: Date): string | undefined {
|
|
250
501
|
const plan = planArchive(configDir, now);
|
|
251
|
-
|
|
252
|
-
|
|
502
|
+
const archivable = plan.items.filter((i) => i.archive);
|
|
503
|
+
if (archivable.length === 0) return undefined;
|
|
504
|
+
return `parachute migrate: ${archivable.length} archivable entr${archivable.length === 1 ? "y" : "ies"} at ecosystem root — run 'parachute migrate' to archive.`;
|
|
253
505
|
}
|
package/src/commands/status.ts
CHANGED
|
@@ -146,6 +146,14 @@ interface StatusRow {
|
|
|
146
146
|
* stale-after-rebuild row without comparing columns by eye.
|
|
147
147
|
*/
|
|
148
148
|
staleNote?: string;
|
|
149
|
+
/**
|
|
150
|
+
* Persisted last-start failure (`lastStartError`, written by the lifecycle
|
|
151
|
+
* start preflight when a startCmd binary is missing). Surfaced on a
|
|
152
|
+
* continuation line so a *later* `parachute status` explains why the row
|
|
153
|
+
* isn't active — "failed to start: <binary> not installed" — rather than
|
|
154
|
+
* just showing it inactive. Cleared on the next successful start.
|
|
155
|
+
*/
|
|
156
|
+
startErrorNote?: string;
|
|
149
157
|
}
|
|
150
158
|
|
|
151
159
|
/**
|
|
@@ -264,6 +272,17 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
|
|
|
264
272
|
? `STALE: services.json cached ${entry.version}; live package.json ${source.livePackageVersion}`
|
|
265
273
|
: undefined;
|
|
266
274
|
|
|
275
|
+
// Persisted last-start failure (lifecycle preflight wrote a missing-
|
|
276
|
+
// dependency wire). Surface a one-line summary; the full install recipe
|
|
277
|
+
// lives in services.json + the admin SPA card. Keeps `parachute status`
|
|
278
|
+
// scannable while still telling the operator "this is why it's down."
|
|
279
|
+
const startErrorNote =
|
|
280
|
+
entry.lastStartError !== undefined
|
|
281
|
+
? entry.lastStartError.binary !== undefined
|
|
282
|
+
? `failed to start: ${entry.lastStartError.binary} not installed — run \`parachute status\` detail or see /admin/modules for install steps`
|
|
283
|
+
: `failed to start: ${entry.lastStartError.error_description.split("\n")[0]}`
|
|
284
|
+
: undefined;
|
|
285
|
+
|
|
267
286
|
// Only skip probe when we know the process is dead (PID file was
|
|
268
287
|
// present but kill(pid, 0) failed). "unknown" status (no PID file)
|
|
269
288
|
// still probes — externally-managed services should report health.
|
|
@@ -287,6 +306,7 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
|
|
|
287
306
|
skipped: true,
|
|
288
307
|
driftWarning,
|
|
289
308
|
staleNote,
|
|
309
|
+
startErrorNote,
|
|
290
310
|
};
|
|
291
311
|
}
|
|
292
312
|
|
|
@@ -324,6 +344,7 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
|
|
|
324
344
|
skipped: false,
|
|
325
345
|
driftWarning,
|
|
326
346
|
staleNote,
|
|
347
|
+
startErrorNote,
|
|
327
348
|
};
|
|
328
349
|
}),
|
|
329
350
|
);
|
|
@@ -378,6 +399,7 @@ export async function status(opts: StatusOpts = {}): Promise<number> {
|
|
|
378
399
|
}
|
|
379
400
|
if (row.driftWarning) print(` ! ${row.driftWarning}`);
|
|
380
401
|
if (row.staleNote) print(` ! ${row.staleNote}`);
|
|
402
|
+
if (row.startErrorNote) print(` ! ${row.startErrorNote}`);
|
|
381
403
|
}
|
|
382
404
|
|
|
383
405
|
/**
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -72,25 +72,69 @@ export interface UpgradeRunner {
|
|
|
72
72
|
): Promise<{ code: number; stdout: string }>;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Exit code we synthesize when a binary can't be spawned at all. 127 is the
|
|
77
|
+
* POSIX shell convention for "command not found" — it lets every git call
|
|
78
|
+
* degrade to a normal non-zero result instead of crashing the whole command.
|
|
79
|
+
*/
|
|
80
|
+
const SPAWN_NOT_FOUND_CODE = 127;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* True when an error thrown by `Bun.spawn` means "the executable doesn't
|
|
84
|
+
* exist on this host" (ENOENT). On a minimal server with no `git` installed —
|
|
85
|
+
* a legitimate, common shape for a published-npm install on the canonical
|
|
86
|
+
* install path — `Bun.spawn(["git", ...])` throws *synchronously* with this
|
|
87
|
+
* shape. We catch it so `parachute upgrade` degrades to the npm path rather
|
|
88
|
+
* than dying with an uncaught `Executable not found in $PATH: "git"`.
|
|
89
|
+
*/
|
|
90
|
+
function isSpawnNotFound(err: unknown): boolean {
|
|
91
|
+
if (typeof err !== "object" || err === null) return false;
|
|
92
|
+
const code = (err as { code?: unknown }).code;
|
|
93
|
+
const message = (err as { message?: unknown }).message;
|
|
94
|
+
return (
|
|
95
|
+
code === "ENOENT" ||
|
|
96
|
+
(typeof message === "string" && message.includes("Executable not found in $PATH"))
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
75
100
|
export const defaultRunner: UpgradeRunner = {
|
|
76
101
|
async run(cmd, opts) {
|
|
77
102
|
// Inherit env so `bun add -g` etc. see TMPDIR, BUN_INSTALL, PATH, HOME.
|
|
78
103
|
// Bun.spawn defaults to empty env — see api-modules-ops.ts:defaultRun.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
104
|
+
let proc: Bun.Subprocess;
|
|
105
|
+
try {
|
|
106
|
+
proc = Bun.spawn([...cmd], {
|
|
107
|
+
cwd: opts?.cwd,
|
|
108
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
109
|
+
env: process.env,
|
|
110
|
+
});
|
|
111
|
+
} catch (err) {
|
|
112
|
+
// Binary not on this host (e.g. no `git` on a minimal server). Degrade
|
|
113
|
+
// to a non-zero exit rather than letting the throw crash the command.
|
|
114
|
+
if (isSpawnNotFound(err)) return SPAWN_NOT_FOUND_CODE;
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
84
117
|
return await proc.exited;
|
|
85
118
|
},
|
|
86
119
|
async capture(cmd, opts) {
|
|
87
120
|
// Inherit env — same rationale as `run` above.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
121
|
+
let proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
|
|
122
|
+
try {
|
|
123
|
+
proc = Bun.spawn([...cmd], {
|
|
124
|
+
cwd: opts?.cwd,
|
|
125
|
+
stdout: "pipe",
|
|
126
|
+
stderr: "pipe",
|
|
127
|
+
env: process.env,
|
|
128
|
+
});
|
|
129
|
+
} catch (err) {
|
|
130
|
+
// See `run` above: ENOENT (binary-not-found) becomes a captured
|
|
131
|
+
// non-zero result so every git call degrades to "command failed".
|
|
132
|
+
if (isSpawnNotFound(err)) {
|
|
133
|
+
const bin = cmd[0] ?? "command";
|
|
134
|
+
return { code: SPAWN_NOT_FOUND_CODE, stdout: `${bin}: not found on this host\n` };
|
|
135
|
+
}
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
94
138
|
const [stdout, stderr] = await Promise.all([
|
|
95
139
|
new Response(proc.stdout).text(),
|
|
96
140
|
new Response(proc.stderr).text(),
|