@openparachute/hub 0.7.3 → 0.7.4-rc.10
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/package.json +4 -11
- package/src/__tests__/admin-vaults.test.ts +164 -1
- package/src/__tests__/api-account-2fa.test.ts +381 -0
- package/src/__tests__/api-hub-upgrade.test.ts +59 -3
- package/src/__tests__/cli.test.ts +22 -0
- package/src/__tests__/clients.test.ts +28 -8
- package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
- package/src/__tests__/hub-server.test.ts +127 -5
- package/src/__tests__/init.test.ts +507 -1
- package/src/__tests__/managed-unit.test.ts +62 -0
- package/src/__tests__/oauth-handlers.test.ts +488 -0
- package/src/__tests__/oauth-ui.test.ts +55 -1
- package/src/__tests__/scope-explanations.test.ts +19 -0
- package/src/__tests__/setup-wizard.test.ts +124 -7
- package/src/__tests__/status-supervisor.test.ts +152 -3
- package/src/__tests__/supervisor.test.ts +25 -0
- package/src/__tests__/vault-names.test.ts +32 -3
- package/src/__tests__/well-known.test.ts +37 -2
- package/src/admin-vaults.ts +7 -12
- package/src/api-account-2fa.ts +373 -0
- package/src/api-hub-upgrade.ts +38 -3
- package/src/api-me.ts +11 -2
- package/src/cli.ts +49 -5
- package/src/clients.ts +14 -0
- package/src/commands/init.ts +224 -37
- package/src/commands/status.ts +108 -5
- package/src/help.ts +17 -0
- package/src/hub-server.ts +72 -7
- package/src/managed-unit.ts +30 -1
- package/src/oauth-handlers.ts +98 -6
- package/src/oauth-ui.ts +123 -0
- package/src/scope-explanations.ts +2 -1
- package/src/setup-wizard.ts +40 -21
- package/src/supervisor.ts +46 -2
- package/src/vault-names.ts +15 -4
- package/src/well-known.ts +10 -1
- package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
- package/web/ui/dist/assets/{index-DZzX_Enf.js → index-DygKux-C.js} +13 -13
- package/web/ui/dist/index.html +2 -2
package/src/commands/init.ts
CHANGED
|
@@ -52,6 +52,7 @@ import { issueOperatorToken, readOperatorTokenFile } from "../operator-token.ts"
|
|
|
52
52
|
import { type AliveFn, defaultAlive, processState } from "../process-state.ts";
|
|
53
53
|
import { findService, readManifestLenient } from "../services-manifest.ts";
|
|
54
54
|
import { listUsers } from "../users.ts";
|
|
55
|
+
import { validateVaultName } from "../vault-name.ts";
|
|
55
56
|
import { type InstallOpts, install as defaultInstall } from "./install.ts";
|
|
56
57
|
|
|
57
58
|
/** The three options the exposure prompt offers — also the `--expose` flag's domain. */
|
|
@@ -154,14 +155,31 @@ export interface InitOpts {
|
|
|
154
155
|
* stub to record the call without shelling out to `cloudflared`.
|
|
155
156
|
*/
|
|
156
157
|
exposeCloudflareImpl?: () => Promise<number>;
|
|
158
|
+
/**
|
|
159
|
+
* Install channel for the vault module (hub#694 bug 2). `"rc"` makes init's
|
|
160
|
+
* vault install resolve `@openparachute/vault@rc` instead of `@latest`, so an
|
|
161
|
+
* rc-channel box (hub installed from `@rc`) doesn't DOWNGRADE vault below the
|
|
162
|
+
* hub on `parachute init`. Default `"latest"` (npm default; back-compat for
|
|
163
|
+
* every existing operator). Precedence in the CLI: `--channel <v>` flag >
|
|
164
|
+
* `PARACHUTE_CHANNEL` / `PARACHUTE_INSTALL_CHANNEL` env > `"latest"`. Threaded
|
|
165
|
+
* verbatim into `install()`'s existing `channel` plumbing
|
|
166
|
+
* (`resolveInstallChannel`).
|
|
167
|
+
*/
|
|
168
|
+
channel?: "latest" | "rc";
|
|
157
169
|
/**
|
|
158
170
|
* Test seam: shim for the vault-module install step (hub#168 Cut 1).
|
|
159
171
|
* Production calls `install("vault", { noCreate: true, noStart: true, …})`
|
|
160
172
|
* to put `@openparachute/vault` on PATH without creating a first-vault
|
|
161
173
|
* instance — the wizard's vault step decides Create/Import/Skip. Tests
|
|
162
|
-
* pass a stub to record the call without shelling out.
|
|
174
|
+
* pass a stub to record the call without shelling out. The `channel` arg
|
|
175
|
+
* (hub#694) forwards into `install()`'s `channel` option so an rc box installs
|
|
176
|
+
* vault from `@rc`.
|
|
163
177
|
*/
|
|
164
|
-
installVaultModuleImpl?: (
|
|
178
|
+
installVaultModuleImpl?: (
|
|
179
|
+
configDir: string,
|
|
180
|
+
manifestPath: string,
|
|
181
|
+
channel?: "latest" | "rc",
|
|
182
|
+
) => Promise<number>;
|
|
165
183
|
/**
|
|
166
184
|
* Override the wizard-choice prompt (hub#168 Cut 4). When set, the
|
|
167
185
|
* "Continue setup in the browser or CLI?" question is answered without
|
|
@@ -185,6 +203,44 @@ export interface InitOpts {
|
|
|
185
203
|
* already known so there's no question to ask).
|
|
186
204
|
*/
|
|
187
205
|
noWizardPrompt?: boolean;
|
|
206
|
+
/**
|
|
207
|
+
* Vault name to create as part of `parachute init --vault-name <name>`
|
|
208
|
+
* (#478 Part 2). When set, init creates the first vault via
|
|
209
|
+
* `createFirstVaultImpl` immediately after Step 1.5 (operator-token
|
|
210
|
+
* guarantee), before the admin-URL resolution. Validated with
|
|
211
|
+
* `validateVaultName` in the CLI before reaching here.
|
|
212
|
+
*
|
|
213
|
+
* Idempotency lives in `parachute-vault create`, NOT in a services.json
|
|
214
|
+
* precheck: `create <name>` exits 0 + creates when `<name>` is new, and
|
|
215
|
+
* exits non-zero ("Vault \"<name>\" already exists.") on a re-run. We
|
|
216
|
+
* therefore ALWAYS attempt the create when this field is set and treat a
|
|
217
|
+
* non-zero exit as non-fatal (warn + continue). A services.json
|
|
218
|
+
* `parachute-vault` row is the MODULE-installed marker (Step 0.5 seeds it
|
|
219
|
+
* via `spec.seedEntry` on EVERY fresh install), not an instance marker —
|
|
220
|
+
* keying idempotency off it would silently no-op the create on the exact
|
|
221
|
+
* fresh-box path this feature targets.
|
|
222
|
+
*
|
|
223
|
+
* Without this field (the default), init makes NO vault — the wizard
|
|
224
|
+
* owns Create/Import/Skip as before. The --no-browser / scripted path
|
|
225
|
+
* remains vault-free unless --vault-name is explicitly passed.
|
|
226
|
+
*/
|
|
227
|
+
vaultName?: string;
|
|
228
|
+
/**
|
|
229
|
+
* Test seam: injectable impl for the `--vault-name` create step (#478
|
|
230
|
+
* Part 2). This IS the whole create implementation — tests swap the
|
|
231
|
+
* entire function for a stub that records the call without touching a
|
|
232
|
+
* live vault binary. It takes the vault name plus a ctx carrying a
|
|
233
|
+
* runner shim, and returns an exit code (0 = success).
|
|
234
|
+
*
|
|
235
|
+
* The production default (`defaultCreateFirstVault`) uses that runner to
|
|
236
|
+
* shell out `["parachute-vault", "create", name]`, following the `Runner`
|
|
237
|
+
* type pattern established across every command that shells out:
|
|
238
|
+
* `readonly string[] => Promise<number>`.
|
|
239
|
+
*/
|
|
240
|
+
createFirstVaultImpl?: (
|
|
241
|
+
name: string,
|
|
242
|
+
ctx: { runner: (cmd: readonly string[]) => Promise<number> },
|
|
243
|
+
) => Promise<number>;
|
|
188
244
|
/**
|
|
189
245
|
* Canonical public hub origin (the OAuth issuer / `iss` claim). Persisted to
|
|
190
246
|
* `hub_settings.hub_origin` BEFORE the hub unit starts + modules spawn, so
|
|
@@ -509,8 +565,18 @@ async function defaultGuaranteeOperatorToken(ctx: {
|
|
|
509
565
|
* shim that re-emits each line under an `[install vault] ` prefix so the
|
|
510
566
|
* init log stays grep-able. Idempotent — `install` short-circuits the
|
|
511
567
|
* bun-add when vault is already linked / installed.
|
|
568
|
+
*
|
|
569
|
+
* The `channel` arg (hub#694) forwards into `install()`'s `channel` option so
|
|
570
|
+
* an rc-channel box installs `@openparachute/vault@rc` instead of `@latest` —
|
|
571
|
+
* otherwise init silently DOWNGRADES vault below the rc-tracking hub. Undefined
|
|
572
|
+
* → install's own resolution (`--tag` > `channel` > `PARACHUTE_INSTALL_CHANNEL`
|
|
573
|
+
* env > `"latest"`) applies, preserving today's behavior.
|
|
512
574
|
*/
|
|
513
|
-
async function defaultInstallVaultModule(
|
|
575
|
+
async function defaultInstallVaultModule(
|
|
576
|
+
configDir: string,
|
|
577
|
+
manifestPath: string,
|
|
578
|
+
channel?: "latest" | "rc",
|
|
579
|
+
): Promise<number> {
|
|
514
580
|
const installOpts: InstallOpts = {
|
|
515
581
|
configDir,
|
|
516
582
|
manifestPath,
|
|
@@ -518,6 +584,7 @@ async function defaultInstallVaultModule(configDir: string, manifestPath: string
|
|
|
518
584
|
noStart: true,
|
|
519
585
|
log: (line) => console.log(`[install vault] ${line}`),
|
|
520
586
|
};
|
|
587
|
+
if (channel) installOpts.channel = channel;
|
|
521
588
|
return await defaultInstall("vault", installOpts);
|
|
522
589
|
}
|
|
523
590
|
|
|
@@ -571,6 +638,38 @@ async function defaultFetchBootstrapToken(loopbackUrl: string): Promise<string |
|
|
|
571
638
|
}
|
|
572
639
|
}
|
|
573
640
|
|
|
641
|
+
/**
|
|
642
|
+
* Default runner shim for the `--vault-name` create step (#478 Part 2).
|
|
643
|
+
* Shells out `parachute-vault create <name>` via Bun.spawn, inheriting
|
|
644
|
+
* the operator's full env (so PARACHUTE_HOME, PATH, etc. pass through).
|
|
645
|
+
*
|
|
646
|
+
* This is the same pattern every other command that shells out uses — a
|
|
647
|
+
* `Runner` (`readonly string[] => Promise<number>`) so tests can inject a
|
|
648
|
+
* stub without touching the real vault binary.
|
|
649
|
+
*/
|
|
650
|
+
async function defaultRunner(cmd: readonly string[]): Promise<number> {
|
|
651
|
+
const proc = Bun.spawn([...cmd], {
|
|
652
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
653
|
+
env: process.env,
|
|
654
|
+
});
|
|
655
|
+
return await proc.exited;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Default impl for the `--vault-name` first-vault create step (#478 Part 2).
|
|
660
|
+
* Invokes `parachute-vault create <name>` via the injectable runner. The
|
|
661
|
+
* `parachute-vault` binary must already be on PATH (guaranteed by Step 0.5's
|
|
662
|
+
* vault-module install). Exit code is forwarded directly — callers log the
|
|
663
|
+
* outcome and continue (a non-zero create doesn't abort init; the operator
|
|
664
|
+
* can re-run `parachute vault create <name>` or use the wizard to retry).
|
|
665
|
+
*/
|
|
666
|
+
async function defaultCreateFirstVault(
|
|
667
|
+
name: string,
|
|
668
|
+
ctx: { runner: (cmd: readonly string[]) => Promise<number> },
|
|
669
|
+
): Promise<number> {
|
|
670
|
+
return await ctx.runner(["parachute-vault", "create", name]);
|
|
671
|
+
}
|
|
672
|
+
|
|
574
673
|
/**
|
|
575
674
|
* Prompt for the wizard-choice question (hub#168 Cut 4). Returns the
|
|
576
675
|
* picked option, or `undefined` if the operator quit. Default is
|
|
@@ -637,6 +736,32 @@ async function promptExposeChoice(
|
|
|
637
736
|
return defaultChoice;
|
|
638
737
|
}
|
|
639
738
|
|
|
739
|
+
/**
|
|
740
|
+
* Resolve the install channel for init's vault module step (hub#694 bug 2).
|
|
741
|
+
*
|
|
742
|
+
* Precedence (highest → lowest):
|
|
743
|
+
* 1. explicit `--channel rc|latest` (parsed in cli.ts → `opts.channel`)
|
|
744
|
+
* 2. `PARACHUTE_CHANNEL` env (the DigitalOcean cloud-init script's var)
|
|
745
|
+
* 3. `PARACHUTE_INSTALL_CHANNEL` env (the install layer's own platform cascade)
|
|
746
|
+
* 4. `undefined` → `install()` falls back to its own "latest" default
|
|
747
|
+
*
|
|
748
|
+
* Returns `"latest"` / `"rc"` when one is resolved, or `undefined` to defer to
|
|
749
|
+
* `install()`'s resolution. A non-`rc`/`latest` env value is ignored (returns
|
|
750
|
+
* undefined) so a typo degrades to "latest" rather than crashing init — the
|
|
751
|
+
* same forgiving posture `resolveInstallChannel` takes for the install command.
|
|
752
|
+
*/
|
|
753
|
+
export function resolveInitChannel(
|
|
754
|
+
explicit: "latest" | "rc" | undefined,
|
|
755
|
+
env: NodeJS.ProcessEnv,
|
|
756
|
+
): "latest" | "rc" | undefined {
|
|
757
|
+
if (explicit === "rc" || explicit === "latest") return explicit;
|
|
758
|
+
for (const key of ["PARACHUTE_CHANNEL", "PARACHUTE_INSTALL_CHANNEL"]) {
|
|
759
|
+
const v = env[key];
|
|
760
|
+
if (v === "rc" || v === "latest") return v;
|
|
761
|
+
}
|
|
762
|
+
return undefined;
|
|
763
|
+
}
|
|
764
|
+
|
|
640
765
|
export async function init(opts: InitOpts = {}): Promise<number> {
|
|
641
766
|
const configDir = opts.configDir ?? CONFIG_DIR;
|
|
642
767
|
const manifestPath = opts.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
@@ -668,9 +793,20 @@ export async function init(opts: InitOpts = {}): Promise<number> {
|
|
|
668
793
|
const exposeTailnetImpl = opts.exposeTailnetImpl ?? defaultExposeTailnet;
|
|
669
794
|
const exposeCloudflareImpl = opts.exposeCloudflareImpl ?? defaultExposeCloudflare;
|
|
670
795
|
const installVaultModuleImpl = opts.installVaultModuleImpl ?? defaultInstallVaultModule;
|
|
796
|
+
// hub#694 bug 2: resolve the channel for the vault module install. Precedence:
|
|
797
|
+
// explicit `--channel <v>` (opts.channel) > `PARACHUTE_CHANNEL` /
|
|
798
|
+
// `PARACHUTE_INSTALL_CHANNEL` env > undefined (install's own "latest"
|
|
799
|
+
// fallback). The env fallback is what makes the DigitalOcean cloud-init
|
|
800
|
+
// script's `PARACHUTE_CHANNEL=rc` cascade into init's vault install with zero
|
|
801
|
+
// extra flags — init never received a `--channel` from that script, but it
|
|
802
|
+
// reads the env the script already exports. A garbage env value falls through
|
|
803
|
+
// to undefined → install resolves "latest" (matching resolveInstallChannel's
|
|
804
|
+
// own garbage-handling), so an operator typo can't break init.
|
|
805
|
+
const installChannel: "latest" | "rc" | undefined = resolveInitChannel(opts.channel, env);
|
|
671
806
|
const runCliWizardImpl = opts.runCliWizardImpl ?? defaultRunCliWizard;
|
|
672
807
|
const fetchBootstrapTokenImpl = opts.fetchBootstrapTokenImpl ?? defaultFetchBootstrapToken;
|
|
673
808
|
const setHubOriginImpl = opts.setHubOriginImpl ?? defaultSetHubOrigin;
|
|
809
|
+
const createFirstVaultImpl = opts.createFirstVaultImpl ?? defaultCreateFirstVault;
|
|
674
810
|
|
|
675
811
|
log("Parachute init — getting your hub set up.");
|
|
676
812
|
log("");
|
|
@@ -696,6 +832,48 @@ export async function init(opts: InitOpts = {}): Promise<number> {
|
|
|
696
832
|
}
|
|
697
833
|
}
|
|
698
834
|
|
|
835
|
+
// Step 0.5 (hub#694 bug 1 — the spawn race): install + seed the vault module
|
|
836
|
+
// into services.json BEFORE the hub unit starts (Step 1 below). The hub unit's
|
|
837
|
+
// `serve` runs the in-process Supervisor, which scans services.json EXACTLY
|
|
838
|
+
// ONCE at boot (`bootSupervisedModules`) and never rescans. If we seed vault
|
|
839
|
+
// AFTER the unit boots (the old Step 2.5 ordering), that single scan reads a
|
|
840
|
+
// services.json with no vault row → vault is registered-but-never-spawned, so
|
|
841
|
+
// `/vault/*` 502s until a manual `parachute restart` re-triggers a per-module
|
|
842
|
+
// start. On a slow box (1GB droplet) the scan reliably wins that race. Seeding
|
|
843
|
+
// first means the boot scan finds vault and spawns it on the first pass — no
|
|
844
|
+
// restart needed.
|
|
845
|
+
//
|
|
846
|
+
// `install("vault", { noCreate: true, noStart: true, … })` only does the
|
|
847
|
+
// on-disk work — `bun add -g` (idempotent; short-circuits when vault is
|
|
848
|
+
// bun-linked / already installed) + an `upsertService` seed write. It does NOT
|
|
849
|
+
// need a running hub: the start path, the stale-unit sweep, and the
|
|
850
|
+
// supervised-hub guidance probe are all gated off under `noCreate`/`noStart`,
|
|
851
|
+
// so running it before the unit exists is safe. The wizard's vault step still
|
|
852
|
+
// owns Create / Import / Skip (noCreate defers first-vault creation); the
|
|
853
|
+
// supervisor (not install.ts) owns spawning (noStart).
|
|
854
|
+
//
|
|
855
|
+
// Idempotent: if a vault row already exists (re-run, or a prior install), this
|
|
856
|
+
// short-circuits past the bun-add and the row is left intact. We don't block
|
|
857
|
+
// init on a non-zero exit — the wizard can retry from /admin/setup.
|
|
858
|
+
const findVaultEntry = (): boolean => {
|
|
859
|
+
try {
|
|
860
|
+
return findService("parachute-vault", manifestPath) !== undefined;
|
|
861
|
+
} catch {
|
|
862
|
+
return false;
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
const vaultAlreadyInstalled = findVaultEntry();
|
|
866
|
+
if (!vaultAlreadyInstalled) {
|
|
867
|
+
log("Installing the vault module so the wizard can offer create / import / skip…");
|
|
868
|
+
const installCode = await installVaultModuleImpl(configDir, manifestPath, installChannel);
|
|
869
|
+
if (installCode !== 0) {
|
|
870
|
+
log(
|
|
871
|
+
`⚠ vault module install returned ${installCode}; the wizard can retry from /admin/setup.`,
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
log("");
|
|
875
|
+
}
|
|
876
|
+
|
|
699
877
|
// Step 1: hub running?
|
|
700
878
|
// NB: under the Phase 3a unit-managed hub there is no pidfile, so
|
|
701
879
|
// `processState(HUB_SVC)` reports not-running on EVERY init re-run even when
|
|
@@ -779,6 +957,42 @@ export async function init(opts: InitOpts = {}): Promise<number> {
|
|
|
779
957
|
// to the wizard regardless.
|
|
780
958
|
await guaranteeOperatorToken({ configDir, hubPort, log });
|
|
781
959
|
|
|
960
|
+
// Step 1.6 (#478 Part 2): if `--vault-name <name>` was given, create the
|
|
961
|
+
// first vault now — after the hub is up (Step 1) and the operator token is
|
|
962
|
+
// guaranteed (Step 1.5). The vault module was installed at Step 0.5, so
|
|
963
|
+
// `parachute-vault` is on PATH.
|
|
964
|
+
//
|
|
965
|
+
// We ALWAYS attempt the create when `--vault-name` is set. Idempotency lives
|
|
966
|
+
// in `parachute-vault create` itself, NOT in a services.json precheck:
|
|
967
|
+
// - `create <name>` exits 0 + creates the vault when `<name>` is new.
|
|
968
|
+
// - `create <name>` exits non-zero ("Vault \"<name>\" already exists.") when
|
|
969
|
+
// that exact name already exists (a benign re-run).
|
|
970
|
+
//
|
|
971
|
+
// We DON'T precheck the services.json `parachute-vault` row: Step 0.5's
|
|
972
|
+
// `install("vault", { noCreate: true })` seeds that row via `spec.seedEntry`
|
|
973
|
+
// on EVERY fresh install (the module-installed marker — see install.ts's
|
|
974
|
+
// InstallOpts doc), so on the exact fresh-box path this feature targets the
|
|
975
|
+
// row is ALWAYS present and a row-keyed precheck would silently no-op the
|
|
976
|
+
// create. The row marks "module installed", not "instance exists" — only the
|
|
977
|
+
// create command's own exit reliably distinguishes the two.
|
|
978
|
+
//
|
|
979
|
+
// A non-zero exit is non-fatal: warn + continue. It could mean the vault
|
|
980
|
+
// already exists (a fine re-run) OR a genuine creation failure — init's
|
|
981
|
+
// contract is hub up → wizard regardless, so we never abort here. The
|
|
982
|
+
// operator can check `parachute status` / re-run `parachute vault create`.
|
|
983
|
+
if (opts.vaultName !== undefined) {
|
|
984
|
+
log(`Creating vault "${opts.vaultName}"…`);
|
|
985
|
+
const createCode = await createFirstVaultImpl(opts.vaultName, { runner: defaultRunner });
|
|
986
|
+
if (createCode === 0) {
|
|
987
|
+
log(`✓ Vault "${opts.vaultName}" created.`);
|
|
988
|
+
} else {
|
|
989
|
+
log(
|
|
990
|
+
`⚠ \`parachute-vault create ${opts.vaultName}\` exited ${createCode} — the vault may already exist, or creation failed. Check \`parachute status\` / re-run \`parachute vault create ${opts.vaultName}\`.`,
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
log("");
|
|
994
|
+
}
|
|
995
|
+
|
|
782
996
|
// Step 2: exposure chain. Skipped when already exposed, in non-TTY,
|
|
783
997
|
// or when --no-expose-prompt was passed. `--expose <choice>` jumps
|
|
784
998
|
// straight to the corresponding chain without asking.
|
|
@@ -831,41 +1045,14 @@ export async function init(opts: InitOpts = {}): Promise<number> {
|
|
|
831
1045
|
}
|
|
832
1046
|
}
|
|
833
1047
|
|
|
834
|
-
//
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
//
|
|
840
|
-
// would auto-create a `default` vault) is skipped. The wizard's
|
|
841
|
-
// vault step then either Creates / Imports / Skips.
|
|
842
|
-
//
|
|
843
|
-
// Idempotent: install short-circuits the bun-add when vault is
|
|
844
|
-
// already linked (`bun link`) or already globally installed. If the
|
|
845
|
-
// operator already has a vault row, this is a no-op past the
|
|
846
|
-
// already-installed log line. We don't block init on this step;
|
|
847
|
-
// a non-zero exit code is logged but treated as a warning, since the
|
|
848
|
-
// wizard can re-attempt the install itself from /admin/setup.
|
|
849
|
-
const findVaultEntry = (): boolean => {
|
|
850
|
-
try {
|
|
851
|
-
return findService("parachute-vault", manifestPath) !== undefined;
|
|
852
|
-
} catch {
|
|
853
|
-
return false;
|
|
854
|
-
}
|
|
855
|
-
};
|
|
856
|
-
const vaultAlreadyInstalled = findVaultEntry();
|
|
857
|
-
if (!vaultAlreadyInstalled) {
|
|
858
|
-
log("");
|
|
859
|
-
log("Installing the vault module so the wizard can offer create / import / skip…");
|
|
860
|
-
const installCode = await installVaultModuleImpl(configDir, manifestPath);
|
|
861
|
-
if (installCode !== 0) {
|
|
862
|
-
log(
|
|
863
|
-
`⚠ vault module install returned ${installCode}; the wizard can retry from /admin/setup.`,
|
|
864
|
-
);
|
|
865
|
-
}
|
|
866
|
-
}
|
|
1048
|
+
// (The vault module install + seed now runs at Step 0.5, BEFORE the hub unit
|
|
1049
|
+
// starts — see hub#694 bug 1. It used to live here, after exposure; moving it
|
|
1050
|
+
// ahead of Step 1's hub bringup is what lets the supervisor's one-time boot
|
|
1051
|
+
// scan find + spawn vault instead of registering it after the scan already
|
|
1052
|
+
// ran. The "always install the vault module" directive — Aaron 2026-05-28,
|
|
1053
|
+
// hub#168 Cut 1 — and the noCreate/noStart split are unchanged.)
|
|
867
1054
|
|
|
868
|
-
// Step 3: vault configured? (After the module install above, this may
|
|
1055
|
+
// Step 3: vault configured? (After the Step 0.5 module install above, this may
|
|
869
1056
|
// have flipped from false to true on a fresh box. The wizard reads
|
|
870
1057
|
// services.json on every request, so the "configured" answer here is
|
|
871
1058
|
// best-effort — it only shapes the next-step log message below.)
|
package/src/commands/status.ts
CHANGED
|
@@ -19,8 +19,8 @@ import {
|
|
|
19
19
|
} from "../install-source.ts";
|
|
20
20
|
import {
|
|
21
21
|
type DriveModuleOpDeps,
|
|
22
|
-
type ModuleStatesResult,
|
|
23
22
|
type ModuleStateSnapshot,
|
|
23
|
+
type ModuleStatesResult,
|
|
24
24
|
NoOperatorTokenError,
|
|
25
25
|
OperatorTokenExpiredError,
|
|
26
26
|
fetchModuleStates as fetchModuleStatesImpl,
|
|
@@ -71,6 +71,17 @@ export interface StatusOpts {
|
|
|
71
71
|
probeHubHealth?: (port: number) => Promise<boolean>;
|
|
72
72
|
/** Read the running supervisor's module states (§6.4 module rows). */
|
|
73
73
|
fetchModuleStates?: (deps: DriveModuleOpDeps) => Promise<ModuleStatesResult>;
|
|
74
|
+
/**
|
|
75
|
+
* Unauthenticated module-liveness probe (#700). Used ONLY on the degraded
|
|
76
|
+
* path where the supervisor run-state read couldn't run (no/expired/invalid
|
|
77
|
+
* operator token, or any API error) but the hub itself is up: probes a
|
|
78
|
+
* module's own `/health` directly on its loopback port. Treats 2xx AND 401
|
|
79
|
+
* as live (mirrors the "auth-gated health = healthy" rule, #423: a module
|
|
80
|
+
* that answers 401 is authenticated-but-alive, not down). Bounded; never
|
|
81
|
+
* throws. Production reuses the same bounded fetch shape as the hub probe;
|
|
82
|
+
* tests inject so they don't hit the network.
|
|
83
|
+
*/
|
|
84
|
+
probeModuleHealth?: (port: number, health: string) => Promise<boolean>;
|
|
74
85
|
/**
|
|
75
86
|
* Open the hub DB used to validate/auto-rotate the operator token in
|
|
76
87
|
* `fetchModuleStates`. Production opens `<configDir>/hub.db`; tests inject a
|
|
@@ -162,6 +173,15 @@ interface StatusRow {
|
|
|
162
173
|
* Printed on a continuation line like the other notes.
|
|
163
174
|
*/
|
|
164
175
|
managerNote?: string;
|
|
176
|
+
/**
|
|
177
|
+
* Set on a module row whose STATE was derived from an unauthenticated
|
|
178
|
+
* `/health` probe rather than the supervisor's run-state (#700) — the
|
|
179
|
+
* degraded-read fallback (no/expired operator token, or an API error) where
|
|
180
|
+
* the module is genuinely serving. Tells the operator the row is live-but-
|
|
181
|
+
* thin: no PID/uptime/structured run-state until they sign in. Printed on a
|
|
182
|
+
* continuation line like the other notes.
|
|
183
|
+
*/
|
|
184
|
+
probeNote?: string;
|
|
165
185
|
}
|
|
166
186
|
|
|
167
187
|
/**
|
|
@@ -319,6 +339,7 @@ function renderRows(rows: StatusRow[], print: (line: string) => void): void {
|
|
|
319
339
|
print(` ! probe: ${row.healthDetail}`);
|
|
320
340
|
}
|
|
321
341
|
if (row.managerNote) print(` ! ${row.managerNote}`);
|
|
342
|
+
if (row.probeNote) print(` → ${row.probeNote}`);
|
|
322
343
|
if (row.driftWarning) print(` ! ${row.driftWarning}`);
|
|
323
344
|
if (row.staleNote) print(` ! ${row.staleNote}`);
|
|
324
345
|
if (row.startErrorNote) print(` ! ${row.startErrorNote}`);
|
|
@@ -336,12 +357,33 @@ function renderRows(rows: StatusRow[], print: (line: string) => void): void {
|
|
|
336
357
|
// in Phase 5b.
|
|
337
358
|
// ---------------------------------------------------------------------------
|
|
338
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Default unauthenticated module-liveness probe (#700). A bounded `fetch` to the
|
|
362
|
+
* module's own `http://127.0.0.1:<port><health>`. Treats 2xx AND 401 as live —
|
|
363
|
+
* an auth-gated `/health` that answers 401 is authenticated-but-alive, not down
|
|
364
|
+
* (the "auth-gated health = healthy" rule, #423). Any other status / network
|
|
365
|
+
* error / timeout → false. 1.5s timeout, mirroring hub-unit's `defaultProbeHealth`.
|
|
366
|
+
*/
|
|
367
|
+
async function defaultProbeModuleHealth(port: number, health: string): Promise<boolean> {
|
|
368
|
+
try {
|
|
369
|
+
const res = await fetch(`http://127.0.0.1:${port}${health}`, {
|
|
370
|
+
signal: AbortSignal.timeout(1500),
|
|
371
|
+
// Loopback-only target, but never chase a redirect off-box (defensive).
|
|
372
|
+
redirect: "manual",
|
|
373
|
+
});
|
|
374
|
+
return res.ok || res.status === 401;
|
|
375
|
+
} catch {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
339
380
|
/** Resolved supervisor-path seams (see `StatusOpts.supervisor`). */
|
|
340
381
|
interface ResolvedStatusSupervisor {
|
|
341
382
|
hubUnitDeps: HubUnitDeps;
|
|
342
383
|
queryHubUnitState: (deps: HubUnitDeps) => HubUnitStateResult;
|
|
343
384
|
probeHubHealth: (port: number) => Promise<boolean>;
|
|
344
385
|
fetchModuleStates: (deps: DriveModuleOpDeps) => Promise<ModuleStatesResult>;
|
|
386
|
+
probeModuleHealth: (port: number, health: string) => Promise<boolean>;
|
|
345
387
|
openDb: (configDir: string) => Database;
|
|
346
388
|
baseUrl: string | undefined;
|
|
347
389
|
}
|
|
@@ -357,6 +399,7 @@ function resolveStatusSupervisor(opts: StatusOpts["supervisor"]): ResolvedStatus
|
|
|
357
399
|
queryHubUnitState: opts?.queryHubUnitState ?? queryHubUnitStateImpl,
|
|
358
400
|
probeHubHealth: opts?.probeHubHealth ?? hubUnitDeps.probeHealth,
|
|
359
401
|
fetchModuleStates: opts?.fetchModuleStates ?? fetchModuleStatesImpl,
|
|
402
|
+
probeModuleHealth: opts?.probeModuleHealth ?? defaultProbeModuleHealth,
|
|
360
403
|
openDb: opts?.openDb ?? ((configDir) => openHubDb(hubDbPath(configDir))),
|
|
361
404
|
baseUrl: opts?.baseUrl,
|
|
362
405
|
};
|
|
@@ -471,10 +514,17 @@ async function buildSupervisorRows(args: BuildSupervisorRowsArgs): Promise<Statu
|
|
|
471
514
|
...(sup.baseUrl !== undefined ? { baseUrl: sup.baseUrl } : {}),
|
|
472
515
|
});
|
|
473
516
|
} catch (err) {
|
|
474
|
-
if (err instanceof NoOperatorTokenError
|
|
475
|
-
// No
|
|
476
|
-
//
|
|
477
|
-
//
|
|
517
|
+
if (err instanceof NoOperatorTokenError) {
|
|
518
|
+
// No operator token AND none can be minted yet — on a fresh box the
|
|
519
|
+
// first admin doesn't exist, so `rotate-operator` would itself hard-error
|
|
520
|
+
// ("no hub users yet"). Point at `set-password` (create the first admin),
|
|
521
|
+
// the actual unblocking step. We still can't read run-state, but the hub
|
|
522
|
+
// is up — degrade gracefully (§6.4), do NOT 401-crash status (#700).
|
|
523
|
+
moduleReadNote =
|
|
524
|
+
"couldn't read live module state — run `parachute auth set-password` to create the first admin (then `parachute auth rotate-operator`)";
|
|
525
|
+
} else if (err instanceof OperatorTokenExpiredError) {
|
|
526
|
+
// Token exists but is stale: an admin already exists, so re-minting works.
|
|
527
|
+
// Keep the rotate-operator guidance.
|
|
478
528
|
moduleReadNote =
|
|
479
529
|
"couldn't read live module state — run `parachute auth rotate-operator` to mint an operator token";
|
|
480
530
|
} else {
|
|
@@ -500,6 +550,26 @@ async function buildSupervisorRows(args: BuildSupervisorRowsArgs): Promise<Statu
|
|
|
500
550
|
if (m.short && !stateByShort.has(m.short)) stateByShort.set(m.short, m);
|
|
501
551
|
}
|
|
502
552
|
|
|
553
|
+
// Unauthenticated-liveness fallback (#700). On the degraded path — the hub is
|
|
554
|
+
// up but we couldn't read supervisor run-state (no/expired operator token, or
|
|
555
|
+
// an API error) — probe each module's own `/health` directly so a module that
|
|
556
|
+
// is genuinely serving reads LIVE instead of being mapped null→`inactive`
|
|
557
|
+
// (which falsely told fresh-box operators a working install was broken). Keyed
|
|
558
|
+
// by the unique `entry.name`; probed concurrently, bounded, never throws.
|
|
559
|
+
const probeAlive = new Map<string, boolean>();
|
|
560
|
+
if (hubHealthy && !states) {
|
|
561
|
+
await Promise.all(
|
|
562
|
+
manifest.services.map(async (entry) => {
|
|
563
|
+
try {
|
|
564
|
+
const alive = await sup.probeModuleHealth(entry.port, entry.health);
|
|
565
|
+
if (alive) probeAlive.set(entry.name, true);
|
|
566
|
+
} catch {
|
|
567
|
+
// Probe must never crash status — absent from the map = treated as down.
|
|
568
|
+
}
|
|
569
|
+
}),
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
503
573
|
const rows: StatusRow[] = manifest.services.map((entry) => {
|
|
504
574
|
const base = manifestRowBase(entry, installSourceDeps);
|
|
505
575
|
const snap = base.short ? stateByShort.get(base.short) : undefined;
|
|
@@ -526,6 +596,39 @@ async function buildSupervisorRows(args: BuildSupervisorRowsArgs): Promise<Statu
|
|
|
526
596
|
};
|
|
527
597
|
}
|
|
528
598
|
|
|
599
|
+
// Degraded read, but the module answered an unauthenticated `/health` probe
|
|
600
|
+
// (#700): show it LIVE instead of null→`inactive`. We can't surface PID/
|
|
601
|
+
// uptime/structured run-state (those need the operator token), so keep the
|
|
602
|
+
// degraded `moduleReadNote` AND add a probe-derived continuation note so the
|
|
603
|
+
// operator understands the row is from a liveness probe, not full supervisor
|
|
604
|
+
// state. `skipped: true` keeps a working install at exit 0.
|
|
605
|
+
if (!snap && probeAlive.get(entry.name)) {
|
|
606
|
+
const row: StatusRow = {
|
|
607
|
+
service: entry.name,
|
|
608
|
+
port: String(entry.port),
|
|
609
|
+
version: entry.version,
|
|
610
|
+
stateLabel: "active",
|
|
611
|
+
pidLabel: "-",
|
|
612
|
+
uptimeLabel: "-",
|
|
613
|
+
healthDetail: "-",
|
|
614
|
+
latencyLabel: "-",
|
|
615
|
+
sourceLabel: base.sourceLabel,
|
|
616
|
+
url: base.url,
|
|
617
|
+
healthy: true,
|
|
618
|
+
skipped: true,
|
|
619
|
+
};
|
|
620
|
+
row.probeNote = "live via unauthenticated health probe — sign in for full supervisor state";
|
|
621
|
+
if (base.driftWarning) row.driftWarning = base.driftWarning;
|
|
622
|
+
if (base.staleNote) row.staleNote = base.staleNote;
|
|
623
|
+
if (base.manifestStartErrorNote) row.startErrorNote = base.manifestStartErrorNote;
|
|
624
|
+
// Surface the degraded-read note ONCE (first module row), same as below.
|
|
625
|
+
if (moduleReadNote) {
|
|
626
|
+
row.managerNote = moduleReadNote;
|
|
627
|
+
moduleReadNote = undefined;
|
|
628
|
+
}
|
|
629
|
+
return row;
|
|
630
|
+
}
|
|
631
|
+
|
|
529
632
|
const { stateLabel, healthy, skipped } = mapSupervisorStatus(snap?.supervisor_status ?? null);
|
|
530
633
|
// Prefer the supervisor's structured start-error (live), else the persisted
|
|
531
634
|
// services.json note — same friendly surface either way (#188).
|
package/src/help.ts
CHANGED
|
@@ -132,7 +132,9 @@ export function initHelp(): string {
|
|
|
132
132
|
Usage:
|
|
133
133
|
parachute init [--no-browser] [--no-expose-prompt]
|
|
134
134
|
[--expose none|tailnet|cloudflare]
|
|
135
|
+
[--channel rc|latest]
|
|
135
136
|
[--hub-origin <url>]
|
|
137
|
+
[--vault-name <name>]
|
|
136
138
|
[--cli-wizard | --browser-wizard]
|
|
137
139
|
|
|
138
140
|
What it does:
|
|
@@ -168,11 +170,23 @@ Flags:
|
|
|
168
170
|
none — stay loopback-only
|
|
169
171
|
tailnet — set up Tailscale serve (private to your tailnet)
|
|
170
172
|
cloudflare — set up Cloudflare Tunnel (your own domain)
|
|
173
|
+
--channel <rc|latest> npm dist-tag for the vault module install (default: latest).
|
|
174
|
+
Use \`rc\` on an rc-channel box so init doesn't downgrade
|
|
175
|
+
vault below the hub. Also honors PARACHUTE_CHANNEL /
|
|
176
|
+
PARACHUTE_INSTALL_CHANNEL env when the flag is absent.
|
|
171
177
|
--hub-origin <url> set the canonical public origin (OAuth issuer) BEFORE
|
|
172
178
|
the hub + modules start, so vault/scribe come up
|
|
173
179
|
accepting it in one pass. For reverse-proxy /
|
|
174
180
|
Caddy-direct boxes that bind loopback but are reached
|
|
175
181
|
over a public HTTPS URL (e.g. https://<ip>.sslip.io).
|
|
182
|
+
--vault-name <name> create the first vault in one shot (#478 Part 2).
|
|
183
|
+
Runs \`parachute-vault create <name>\` after the hub
|
|
184
|
+
is up. Non-fatal on re-run — \`create\` exits
|
|
185
|
+
non-zero if the vault already exists, and that's
|
|
186
|
+
tolerated. Must be a valid vault name: lowercase
|
|
187
|
+
alphanumeric + hyphens/underscores, 2–32 chars.
|
|
188
|
+
Without this flag, the wizard owns vault creation
|
|
189
|
+
(the default experience is unchanged).
|
|
176
190
|
--cli-wizard skip the "browser or CLI?" prompt and walk the wizard
|
|
177
191
|
in this terminal (hub#168 Cut 4)
|
|
178
192
|
--browser-wizard skip the prompt and open the browser wizard directly
|
|
@@ -185,6 +199,9 @@ Examples:
|
|
|
185
199
|
parachute init --expose tailnet # CI/scripted: chain straight into Tailscale
|
|
186
200
|
parachute init --no-browser # don't shell out to open / xdg-open
|
|
187
201
|
parachute init --cli-wizard # walk the wizard in this terminal (hub#168)
|
|
202
|
+
parachute init --channel rc # rc box: install the vault module from @rc
|
|
203
|
+
parachute init --vault-name default --no-browser
|
|
204
|
+
# CI/scripted: hub + first vault in one pass
|
|
188
205
|
`;
|
|
189
206
|
}
|
|
190
207
|
|