@openparachute/hub 0.7.0 → 0.7.2-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/README.md +13 -14
- package/package.json +1 -1
- package/src/__tests__/account-setup.test.ts +276 -6
- package/src/__tests__/admin-agent-grants.test.ts +1547 -0
- package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
- package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
- package/src/__tests__/admin-connections.test.ts +211 -57
- package/src/__tests__/admin-csrf-belt.test.ts +7 -7
- package/src/__tests__/admin-lock.test.ts +600 -0
- package/src/__tests__/admin-module-token.test.ts +36 -8
- package/src/__tests__/admin-vaults.test.ts +8 -8
- package/src/__tests__/api-invites.test.ts +166 -6
- package/src/__tests__/api-modules-ops.test.ts +17 -16
- package/src/__tests__/api-modules.test.ts +35 -36
- package/src/__tests__/api-ready.test.ts +2 -2
- package/src/__tests__/audience-gate.test.ts +752 -0
- package/src/__tests__/clients.test.ts +91 -0
- package/src/__tests__/grants-store.test.ts +219 -0
- package/src/__tests__/hub-db.test.ts +36 -0
- package/src/__tests__/hub-server.test.ts +275 -5
- package/src/__tests__/invites.test.ts +64 -1
- package/src/__tests__/lifecycle.test.ts +238 -3
- package/src/__tests__/migrate.test.ts +1 -1
- package/src/__tests__/module-manifest.test.ts +11 -11
- package/src/__tests__/oauth-client.test.ts +446 -0
- package/src/__tests__/oauth-flows-store.test.ts +141 -0
- package/src/__tests__/oauth-handlers.test.ts +124 -26
- package/src/__tests__/operator-token.test.ts +2 -2
- package/src/__tests__/scope-explanations.test.ts +3 -3
- package/src/__tests__/serve-boot.test.ts +14 -14
- package/src/__tests__/serve.test.ts +26 -0
- package/src/__tests__/service-spec-discovery.test.ts +26 -18
- package/src/__tests__/services-manifest.test.ts +60 -48
- package/src/__tests__/setup-gate.test.ts +52 -3
- package/src/__tests__/setup-wizard.test.ts +86 -280
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/upgrade.test.ts +276 -0
- package/src/__tests__/vault-remove.test.ts +393 -0
- package/src/__tests__/ws-bridge.test.ts +573 -0
- package/src/__tests__/ws-connection-caps.test.ts +456 -0
- package/src/account-setup.ts +94 -23
- package/src/admin-agent-grants.ts +1365 -0
- package/src/admin-agent-token.ts +147 -0
- package/src/admin-connections.ts +980 -61
- package/src/admin-host-admin-token.ts +14 -1
- package/src/admin-lock.ts +281 -0
- package/src/admin-login-ui.ts +64 -15
- package/src/admin-module-token.ts +15 -7
- package/src/admin-vault-admin-token.ts +8 -1
- package/src/admin-vaults.ts +21 -12
- package/src/api-admin-lock.ts +335 -0
- package/src/api-invites.ts +92 -12
- package/src/api-modules-ops.ts +3 -2
- package/src/api-modules.ts +9 -9
- package/src/audience-gate.ts +268 -0
- package/src/chrome-strip.ts +8 -1
- package/src/cli.ts +13 -1
- package/src/clients.ts +88 -0
- package/src/commands/install.ts +7 -0
- package/src/commands/lifecycle.ts +187 -47
- package/src/commands/serve-boot.ts +5 -4
- package/src/commands/serve.ts +45 -19
- package/src/commands/setup.ts +4 -3
- package/src/commands/upgrade.ts +118 -2
- package/src/commands/vault-remove.ts +361 -0
- package/src/commands/wizard.ts +4 -4
- package/src/connections-store.ts +35 -5
- package/src/grants-store.ts +272 -0
- package/src/help.ts +17 -7
- package/src/host-admin-token-validation.ts +6 -2
- package/src/hub-db.ts +26 -1
- package/src/hub-server.ts +710 -39
- package/src/hub-settings.ts +23 -8
- package/src/invites.ts +69 -2
- package/src/jwt-sign.ts +11 -1
- package/src/module-manifest.ts +109 -2
- package/src/oauth-client.ts +497 -0
- package/src/oauth-flows-store.ts +163 -0
- package/src/oauth-handlers.ts +40 -13
- package/src/operator-token.ts +1 -1
- package/src/origin-check.ts +13 -5
- package/src/resource-binding.ts +4 -4
- package/src/scope-explanations.ts +3 -3
- package/src/service-spec.ts +56 -43
- package/src/services-manifest.ts +97 -0
- package/src/setup-wizard.ts +56 -240
- package/src/ws-bridge.ts +256 -0
- package/src/ws-connection-caps.ts +170 -0
- package/web/ui/dist/assets/index-B5AUE359.js +61 -0
- package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/src/admin-channel-token.ts +0 -135
- package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { rethrowIfMissing } from "@openparachute/depcheck";
|
|
4
4
|
import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
@@ -609,6 +609,74 @@ export interface LogsOpts {
|
|
|
609
609
|
* Defaults to the group-aware `defaultAlive` (hub#88).
|
|
610
610
|
*/
|
|
611
611
|
alive?: AliveFn;
|
|
612
|
+
/**
|
|
613
|
+
* Filtered-follow source seam (hub#652) — the byte stream of lines appended
|
|
614
|
+
* to the hub log from attach onward. The production default spawns
|
|
615
|
+
* `tail -n 0 -f <hub.log>` with piped stdout so the `[<svc>] ` filter runs
|
|
616
|
+
* in-process; tests inject a deterministic stream.
|
|
617
|
+
*/
|
|
618
|
+
followStream?: (path: string) => ReadableStream<Uint8Array>;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Default `followStream`: tail the file from its end, stdout piped to us. */
|
|
622
|
+
function defaultFollowStream(path: string): ReadableStream<Uint8Array> {
|
|
623
|
+
try {
|
|
624
|
+
// Inherit env so `tail` sees PATH, etc. Bun.spawn defaults to empty
|
|
625
|
+
// env — see api-modules-ops.ts:defaultRun.
|
|
626
|
+
const proc = Bun.spawn(["tail", "-n", "0", "-f", path], {
|
|
627
|
+
stdin: "ignore",
|
|
628
|
+
stdout: "pipe",
|
|
629
|
+
stderr: "inherit",
|
|
630
|
+
env: process.env,
|
|
631
|
+
});
|
|
632
|
+
return proc.stdout;
|
|
633
|
+
} catch (err) {
|
|
634
|
+
// A missing `tail` (minimal container without coreutils) surfaces the
|
|
635
|
+
// friendly install UX instead of a raw spawn throw (cli.ts top-level
|
|
636
|
+
// catch renders the MissingDependencyError).
|
|
637
|
+
rethrowIfMissing(err, "tail");
|
|
638
|
+
throw err;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Pump a byte stream line-by-line, emitting only lines that carry `prefix`
|
|
644
|
+
* (stripped). Line-buffered the same way the supervisor's `pumpLines` is, so
|
|
645
|
+
* chunk boundaries inside a line don't drop or split matches.
|
|
646
|
+
*/
|
|
647
|
+
async function pumpFilteredLines(
|
|
648
|
+
stream: ReadableStream<Uint8Array>,
|
|
649
|
+
prefix: string,
|
|
650
|
+
output: (line: string) => void,
|
|
651
|
+
): Promise<void> {
|
|
652
|
+
const reader = stream.getReader();
|
|
653
|
+
const decoder = new TextDecoder();
|
|
654
|
+
let buf = "";
|
|
655
|
+
const emit = (line: string): void => {
|
|
656
|
+
if (line.startsWith(prefix)) output(line.slice(prefix.length));
|
|
657
|
+
};
|
|
658
|
+
try {
|
|
659
|
+
while (true) {
|
|
660
|
+
const { done, value } = await reader.read();
|
|
661
|
+
if (done) break;
|
|
662
|
+
buf += decoder.decode(value, { stream: true });
|
|
663
|
+
let nl = buf.indexOf("\n");
|
|
664
|
+
while (nl !== -1) {
|
|
665
|
+
emit(buf.slice(0, nl));
|
|
666
|
+
buf = buf.slice(nl + 1);
|
|
667
|
+
nl = buf.indexOf("\n");
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (buf.length > 0) emit(buf);
|
|
671
|
+
} finally {
|
|
672
|
+
reader.releaseLock();
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Split a log file's content into lines, dropping the trailing newline. */
|
|
677
|
+
function splitLogLines(content: string): string[] {
|
|
678
|
+
const trimmed = content.replace(/\n$/, "");
|
|
679
|
+
return trimmed === "" ? [] : trimmed.split("\n");
|
|
612
680
|
}
|
|
613
681
|
|
|
614
682
|
export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
|
|
@@ -620,11 +688,12 @@ export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
|
|
|
620
688
|
const alive = opts.alive ?? defaultAlive;
|
|
621
689
|
|
|
622
690
|
// logs only needs a valid short name to find the log file. First-party
|
|
623
|
-
// wins via the spec lookup; third-party rows match by `entry.name
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
// the
|
|
627
|
-
//
|
|
691
|
+
// wins via the spec lookup; third-party rows match by `entry.name` (the
|
|
692
|
+
// same token the supervisor uses as its log prefix); the internal hub is
|
|
693
|
+
// a known short outside of services.json. installDir is irrelevant here —
|
|
694
|
+
// the log file is keyed by short name and exists once the service has
|
|
695
|
+
// run, regardless of how it was registered. We just need to confirm the
|
|
696
|
+
// name maps to something the CLI manages.
|
|
628
697
|
const isFirstParty = getSpec(svc) !== undefined;
|
|
629
698
|
if (!isFirstParty && svc !== HUB_SVC) {
|
|
630
699
|
const entry = readManifest(manifestPath).services.find((s) => s.name === svc);
|
|
@@ -634,19 +703,111 @@ export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
|
|
|
634
703
|
}
|
|
635
704
|
}
|
|
636
705
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
706
|
+
// Per-file plain reader (the pre-#652 behavior): tail/print one log file.
|
|
707
|
+
const readPlain = async (path: string): Promise<number> => {
|
|
708
|
+
if (follow) {
|
|
709
|
+
const spawner = opts.tailSpawner ?? {
|
|
710
|
+
spawn(cmd) {
|
|
711
|
+
// Inherit env so `tail` sees PATH, etc. Bun.spawn defaults to empty
|
|
712
|
+
// env — see api-modules-ops.ts:defaultRun.
|
|
713
|
+
try {
|
|
714
|
+
const proc = Bun.spawn([...cmd], {
|
|
715
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
716
|
+
env: process.env,
|
|
717
|
+
});
|
|
718
|
+
return proc.pid;
|
|
719
|
+
} catch (err) {
|
|
720
|
+
// A missing `tail` (minimal container without coreutils) surfaces
|
|
721
|
+
// the friendly install UX instead of a raw spawn throw. The CLI
|
|
722
|
+
// top-level catch in cli.ts renders the MissingDependencyError.
|
|
723
|
+
rethrowIfMissing(err, "tail");
|
|
724
|
+
throw err;
|
|
725
|
+
}
|
|
726
|
+
},
|
|
727
|
+
};
|
|
728
|
+
spawner.spawn(["tail", "-n", String(lines), "-f", path], path);
|
|
729
|
+
// tail runs until user Ctrl-C; block this process until it exits.
|
|
730
|
+
// When called from the real CLI, process.exit wraps us; in tests a
|
|
731
|
+
// stub spawner returns immediately and we fall through.
|
|
732
|
+
return 0;
|
|
733
|
+
}
|
|
734
|
+
// Non-follow path: read last N lines synchronously for a clean one-shot.
|
|
735
|
+
const tail = splitLogLines(await Bun.file(path).text()).slice(-lines);
|
|
736
|
+
for (const line of tail) log(line);
|
|
737
|
+
return 0;
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
const legacyPath = logPathFor(svc, configDir);
|
|
741
|
+
const hubLogPath = logPathFor(HUB_SVC, configDir);
|
|
742
|
+
const legacyExists = svc !== HUB_SVC && existsSync(legacyPath);
|
|
743
|
+
const hubLogExists = existsSync(hubLogPath);
|
|
744
|
+
|
|
745
|
+
// Source selection (hub#652): under hub-as-supervisor (Phase 5b) a module's
|
|
746
|
+
// stdout/stderr is multiplexed into the HUB log with a `[<svc>] ` line
|
|
747
|
+
// prefix (supervisor.ts pipeOutput) — the per-service file stops advancing
|
|
748
|
+
// at the cutover. Prefer whichever file is fresher: a pre-supervised
|
|
749
|
+
// install is still actively writing the per-service file (it wins); on a
|
|
750
|
+
// supervised box the hub log is the live stream and the per-service file
|
|
751
|
+
// is a stale remnant (it loses). `logs hub` always reads the hub log
|
|
752
|
+
// unfiltered — the interleaved prefixed stream IS the hub's own log.
|
|
753
|
+
const useHubStream =
|
|
754
|
+
svc !== HUB_SVC &&
|
|
755
|
+
hubLogExists &&
|
|
756
|
+
(!legacyExists || statSync(hubLogPath).mtimeMs >= statSync(legacyPath).mtimeMs);
|
|
757
|
+
|
|
758
|
+
if (!useHubStream) {
|
|
759
|
+
if (!existsSync(legacyPath)) {
|
|
760
|
+
// Distinguish "daemon never started" from "daemon is running but the
|
|
761
|
+
// log file is missing" (hub#335). The latter shape surfaces when a
|
|
762
|
+
// module self-registers + spawns its own logger without going through
|
|
763
|
+
// the hub (no hub-managed log file), or when an operator deletes the
|
|
764
|
+
// log mid-run. Previously both shapes printed the same
|
|
765
|
+
// `parachute start ${svc}` hint, leading operators to think their
|
|
766
|
+
// running daemon hadn't started.
|
|
767
|
+
const state = processState(svc, configDir, alive);
|
|
768
|
+
if (state.status === "running") {
|
|
769
|
+
const whereabouts =
|
|
770
|
+
svc === HUB_SVC
|
|
771
|
+
? `no log file at ${legacyPath}`
|
|
772
|
+
: `no log file at ${legacyPath} and no hub log at ${hubLogPath}`;
|
|
773
|
+
log(
|
|
774
|
+
`${svc} is running (pid ${state.pid}) but ${whereabouts}. The daemon may be writing logs elsewhere — check its stdout/stderr or its own log destination.`,
|
|
775
|
+
);
|
|
776
|
+
return 0;
|
|
777
|
+
}
|
|
778
|
+
log(`no logs yet for ${svc}. \`parachute start ${svc}\` to begin.`);
|
|
779
|
+
return 0;
|
|
780
|
+
}
|
|
781
|
+
return readPlain(legacyPath);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Supervised path (hub#652): the service's lines in the hub log, prefix
|
|
785
|
+
// stripped. Stripped rather than kept: every line would otherwise repeat
|
|
786
|
+
// the name the operator just typed, while the module's own output shape
|
|
787
|
+
// (e.g. surface's `[app-dcr]` sub-prefixes) stays intact — the same shape
|
|
788
|
+
// its per-service file had pre-cutover.
|
|
789
|
+
const prefix = `[${svc}] `;
|
|
790
|
+
const matched = splitLogLines(await Bun.file(hubLogPath).text())
|
|
791
|
+
.filter((l) => l.startsWith(prefix))
|
|
792
|
+
.map((l) => l.slice(prefix.length));
|
|
793
|
+
|
|
794
|
+
if (matched.length === 0 && !follow) {
|
|
795
|
+
if (legacyExists) {
|
|
796
|
+
// Transitional shape: nothing for this service in the hub log, but a
|
|
797
|
+
// (staler) per-service file exists — e.g. the module last ran detached,
|
|
798
|
+
// pre-cutover. Show it, with a note so a stale file isn't mistaken for
|
|
799
|
+
// the live stream (the exact hub#652 trap).
|
|
800
|
+
log(
|
|
801
|
+
`note: no ${svc} lines in the hub log (${hubLogPath}); showing the per-service log at ${legacyPath}.`,
|
|
802
|
+
);
|
|
803
|
+
return readPlain(legacyPath);
|
|
804
|
+
}
|
|
805
|
+
// Keep the hub#335 shapes coherent with the hub-stream source: a live
|
|
806
|
+
// pidfile here means a daemon the hub isn't logging for.
|
|
646
807
|
const state = processState(svc, configDir, alive);
|
|
647
808
|
if (state.status === "running") {
|
|
648
809
|
log(
|
|
649
|
-
`${svc} is running (pid ${state.pid}) but no log file at ${
|
|
810
|
+
`${svc} is running (pid ${state.pid}) but has no lines in the hub log (${hubLogPath}) and no log file at ${legacyPath}. The daemon may be writing logs elsewhere — check its stdout/stderr or its own log destination.`,
|
|
650
811
|
);
|
|
651
812
|
return 0;
|
|
652
813
|
}
|
|
@@ -654,38 +815,17 @@ export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
|
|
|
654
815
|
return 0;
|
|
655
816
|
}
|
|
656
817
|
|
|
818
|
+
for (const line of matched.slice(-lines)) log(line);
|
|
819
|
+
|
|
657
820
|
if (follow) {
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
});
|
|
667
|
-
return proc.pid;
|
|
668
|
-
} catch (err) {
|
|
669
|
-
// A missing `tail` (minimal container without coreutils) surfaces
|
|
670
|
-
// the friendly install UX instead of a raw spawn throw. The CLI
|
|
671
|
-
// top-level catch in cli.ts renders the MissingDependencyError.
|
|
672
|
-
rethrowIfMissing(err, "tail");
|
|
673
|
-
throw err;
|
|
674
|
-
}
|
|
675
|
-
},
|
|
676
|
-
};
|
|
677
|
-
spawner.spawn(["tail", "-n", String(lines), "-f", path], path);
|
|
678
|
-
// tail runs until user Ctrl-C; block this process until it exits.
|
|
679
|
-
// When called from the real CLI, process.exit wraps us; in tests a
|
|
680
|
-
// stub spawner returns immediately and we fall through.
|
|
681
|
-
return 0;
|
|
821
|
+
// Print the filtered backlog above, then follow new hub-log lines through
|
|
822
|
+
// the same filter. Runs until the tail is killed (Ctrl-C takes down the
|
|
823
|
+
// whole foreground process group); in tests the injected stream closes.
|
|
824
|
+
if (matched.length === 0) {
|
|
825
|
+
log(`(no prior ${svc} lines — waiting for new output…)`);
|
|
826
|
+
}
|
|
827
|
+
const source = opts.followStream ?? defaultFollowStream;
|
|
828
|
+
await pumpFilteredLines(source(hubLogPath), prefix, log);
|
|
682
829
|
}
|
|
683
|
-
|
|
684
|
-
// Non-follow path: read last N lines synchronously for a clean one-shot.
|
|
685
|
-
const content = await Bun.file(path).text();
|
|
686
|
-
const trimmed = content.replace(/\n$/, "");
|
|
687
|
-
const allLines = trimmed === "" ? [] : trimmed.split("\n");
|
|
688
|
-
const tail = allLines.slice(-lines);
|
|
689
|
-
for (const line of tail) log(line);
|
|
690
830
|
return 0;
|
|
691
831
|
}
|
|
@@ -78,12 +78,13 @@ export interface BuildSpawnRequestOpts {
|
|
|
78
78
|
*
|
|
79
79
|
* Why (channel#41): the hub is the port authority, and a first-party module
|
|
80
80
|
* with a compiled-in canonical port (`canonicalPortForManifest` ≠ undefined —
|
|
81
|
-
* vault / scribe / surface /
|
|
81
|
+
* vault / scribe / surface / agent / notes / runner) has exactly ONE correct
|
|
82
82
|
* port: its canonical one. The injected `PORT`, the supervisor's readiness
|
|
83
83
|
* probe, and the reverse-proxy target are ALL derived from the services.json
|
|
84
|
-
* row's `port`. So once a transiently-wrong port lands in that row (the
|
|
85
|
-
* row was observed live carrying `19415` instead of
|
|
86
|
-
*
|
|
84
|
+
* row's `port`. So once a transiently-wrong port lands in that row (the agent
|
|
85
|
+
* row — then named channel — was observed live carrying `19415` instead of
|
|
86
|
+
* `1941`), it self-perpetuates:
|
|
87
|
+
* the supervisor probes/proxies the wrong port forever and `/agent/*` routes
|
|
87
88
|
* to a dead port. The canonical port is authoritative, so we reconcile the row
|
|
88
89
|
* before spawn rather than honoring the drift.
|
|
89
90
|
*
|
package/src/commands/serve.ts
CHANGED
|
@@ -38,6 +38,7 @@ import { createDbHolder, defaultStatInode, startDbPathLivenessTimer } from "../h
|
|
|
38
38
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
39
39
|
import { hubFetch } from "../hub-server.ts";
|
|
40
40
|
import { writeHubFile } from "../hub.ts";
|
|
41
|
+
import { createWsBridgeHandlers } from "../ws-bridge.ts";
|
|
41
42
|
import { enrichedPath } from "../spawn-path.ts";
|
|
42
43
|
import { Supervisor } from "../supervisor.ts";
|
|
43
44
|
import { createUser, userCount } from "../users.ts";
|
|
@@ -45,6 +46,35 @@ import { sanitizePublicOrigin } from "../vault-hub-origin-env.ts";
|
|
|
45
46
|
import { WELL_KNOWN_DIR } from "../well-known.ts";
|
|
46
47
|
import { bootSupervisedModules } from "./serve-boot.ts";
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Build the `Bun.serve` options for the hub listener. Extracted (pure) so the
|
|
51
|
+
* load-bearing wiring — crucially the `websocket` bridge handler — is unit-
|
|
52
|
+
* testable and can't silently drift from the other `Bun.serve` in
|
|
53
|
+
* `hub-server.ts`. That drift is exactly how the in-page-terminal WS upgrade
|
|
54
|
+
* started 500ing: the bridge was wired into hub-server.ts's serve but NOT this
|
|
55
|
+
* production `parachute serve` path, so `server.upgrade()` (in `hubFetch`'s
|
|
56
|
+
* `maybeUpgradeWebSocket`) threw "set the websocket object in Bun.serve({})".
|
|
57
|
+
*/
|
|
58
|
+
export function hubServeOptions(args: {
|
|
59
|
+
port: number;
|
|
60
|
+
hostname: string;
|
|
61
|
+
fetch: ReturnType<typeof hubFetch>;
|
|
62
|
+
}) {
|
|
63
|
+
return {
|
|
64
|
+
port: args.port,
|
|
65
|
+
hostname: args.hostname,
|
|
66
|
+
// Hold idle keep-alive connections for Bun's maximum 255s so reverse-proxy
|
|
67
|
+
// edges (Render, Cloudflare, fly.io) don't race us reusing pooled
|
|
68
|
+
// connections (hub#399).
|
|
69
|
+
idleTimeout: 255,
|
|
70
|
+
fetch: args.fetch,
|
|
71
|
+
// The WebSocket upgrade bridge. `maybeUpgradeWebSocket` (in `hubFetch`) calls
|
|
72
|
+
// `server.upgrade()`, which THROWS unless the server declares this handler —
|
|
73
|
+
// so it MUST be present on every Bun.serve that runs `hubFetch`.
|
|
74
|
+
websocket: createWsBridgeHandlers(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
48
78
|
export interface ServeOpts {
|
|
49
79
|
/** Override PORT (test-only). Real callers thread env via process.env. */
|
|
50
80
|
port?: number;
|
|
@@ -456,26 +486,22 @@ export async function serve(opts: ServeOpts = {}): Promise<{
|
|
|
456
486
|
// fail fast and cleanly, leaving the live hub's children untouched.
|
|
457
487
|
let server: ReturnType<typeof Bun.serve>;
|
|
458
488
|
try {
|
|
459
|
-
server = Bun.serve(
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
probeDbPath: () => dbHolder.probePath(),
|
|
474
|
-
issuer,
|
|
475
|
-
loopbackPort: port,
|
|
476
|
-
supervisor,
|
|
489
|
+
server = Bun.serve(
|
|
490
|
+
hubServeOptions({
|
|
491
|
+
port,
|
|
492
|
+
hostname,
|
|
493
|
+
fetch: hubFetch(WELL_KNOWN_DIR, {
|
|
494
|
+
getDb: () => dbHolder.get(),
|
|
495
|
+
onDbError: (err) => dbHolder.healOrExit(err),
|
|
496
|
+
// #610: /health's db check probes the path so monitoring + the #591
|
|
497
|
+
// adoption probe see a wipe instead of the ghost-fd lie.
|
|
498
|
+
probeDbPath: () => dbHolder.probePath(),
|
|
499
|
+
issuer,
|
|
500
|
+
loopbackPort: port,
|
|
501
|
+
supervisor,
|
|
502
|
+
}),
|
|
477
503
|
}),
|
|
478
|
-
|
|
504
|
+
);
|
|
479
505
|
} catch (err) {
|
|
480
506
|
const conflict = hubPortConflictMessage(err, port);
|
|
481
507
|
if (conflict) throw new Error(conflict);
|
package/src/commands/setup.ts
CHANGED
|
@@ -109,13 +109,13 @@ function defaultAvailability(): InteractiveAvailability {
|
|
|
109
109
|
|
|
110
110
|
/**
|
|
111
111
|
* Survey the eligible services. We include the four first-party shortnames
|
|
112
|
-
* (vault / notes / scribe /
|
|
112
|
+
* (vault / notes / scribe / agent + runner) but flag agent as exploratory
|
|
113
113
|
* in the blurb so operators don't grab it by reflex. `installed` is true when
|
|
114
114
|
* the service has a row in services.json.
|
|
115
115
|
*
|
|
116
116
|
* The full ServiceSpec is only available pre-install for FIRST_PARTY_FALLBACKS
|
|
117
117
|
* shorts (notes — it carries a vendored manifest). KNOWN_MODULES shorts
|
|
118
|
-
* (vault / scribe / runner /
|
|
118
|
+
* (vault / scribe / runner / agent / surface) ship `.parachute/module.json`
|
|
119
119
|
* and self-register; pre-install we know manifestName + the urlForEntry quirk
|
|
120
120
|
* from `KNOWN_MODULES[short].extras`, which is all the survey/summary needs.
|
|
121
121
|
*/
|
|
@@ -155,7 +155,8 @@ const BLURBS: Record<string, string> = {
|
|
|
155
155
|
notes: "Notes PWA — web/mobile UI on top of vault (notes-daemon; superseded by `app`)",
|
|
156
156
|
scribe: "audio transcription for dictation + recordings",
|
|
157
157
|
runner: "vault-as-job-substrate — scheduled claude -p against vault job notes",
|
|
158
|
-
|
|
158
|
+
agent:
|
|
159
|
+
"(exploratory) chat with your Claude Code sessions — a channel per session (renamed from channel)",
|
|
159
160
|
};
|
|
160
161
|
|
|
161
162
|
function blurbFor(choice: ServiceChoice): string {
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -44,6 +44,26 @@
|
|
|
44
44
|
* downgrades: if `npm view <pkg>@<channel> version` resolves to something
|
|
45
45
|
* lower than what's installed, we abort with an actionable message
|
|
46
46
|
* (override with `--allow-downgrade`).
|
|
47
|
+
*
|
|
48
|
+
* RC-channel best-of resolution (hub#659). Channel preservation (#332) made
|
|
49
|
+
* the rc channel sticky — `parachute upgrade` follows `@rc`. But the rc
|
|
50
|
+
* channel is a *canary*: when a train ships stable-direct without cutting an
|
|
51
|
+
* rc (the common case under the post-#332 governance where trains start at
|
|
52
|
+
* stable), `@rc` doesn't advance and the box strands BELOW `@latest` with no
|
|
53
|
+
* visible path forward (the live case: friends.parachute.computer pinned at
|
|
54
|
+
* 0.6.5-rc.8 while @latest moved to 0.7.1). The fix: on the rc channel we
|
|
55
|
+
* resolve the target to the HIGHEST version above installed across BOTH `@rc`
|
|
56
|
+
* AND `@latest`.
|
|
57
|
+
* - mid-chain (a newer rc exists) → take the rc; the canary stays ahead
|
|
58
|
+
* (UNCHANGED — this is the #332 behavior).
|
|
59
|
+
* - end-of-chain / skipped-rc train (no newer rc, but @latest > installed)
|
|
60
|
+
* → CONVERGE to @latest, with a loud log line. The operator stays ON the
|
|
61
|
+
* rc channel (the install/config notion of channel is unchanged) — only
|
|
62
|
+
* the resolved VERSION changes; they pick up the next rc when it ships.
|
|
63
|
+
* - nothing newer anywhere → the existing up-to-date no-op.
|
|
64
|
+
* The `@latest` (stable) channel path is UNCHANGED — stable never reaches for
|
|
65
|
+
* `@rc`. `--channel rc|latest` explicit overrides and `--allow-downgrade`
|
|
66
|
+
* keep working.
|
|
47
67
|
*/
|
|
48
68
|
|
|
49
69
|
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
@@ -689,10 +709,102 @@ function pickChannel(installedVersion: string | null, r: Resolved): string {
|
|
|
689
709
|
return "latest";
|
|
690
710
|
}
|
|
691
711
|
|
|
712
|
+
/**
|
|
713
|
+
* The dist-tag / version `upgradeNpm` will hand to `bun add -g`, after the
|
|
714
|
+
* rc-channel best-of resolution (hub#659). `installSpec` is what follows the
|
|
715
|
+
* `@` in `bun add -g <pkg>@<installSpec>` — usually a dist-tag (`rc` /
|
|
716
|
+
* `latest`), but a pinned concrete version when we converge an rc-channel box
|
|
717
|
+
* onto stable (so a moving `@latest` can't race the resolution). `channel` is
|
|
718
|
+
* the dist-tag we resolved against, used by the downgrade-guard messaging.
|
|
719
|
+
*/
|
|
720
|
+
interface ResolvedNpmTarget {
|
|
721
|
+
installSpec: string;
|
|
722
|
+
channel: string;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Resolve which version the rc channel should actually move to (hub#659).
|
|
727
|
+
*
|
|
728
|
+
* Channel preservation (#332) keeps an rc operator following `@rc`. But `@rc`
|
|
729
|
+
* is a canary that only advances when a train cuts an rc; a stable-direct
|
|
730
|
+
* train leaves it stranded below `@latest`. So on the rc channel we look at
|
|
731
|
+
* BOTH dist-tags and take the highest one that's actually ABOVE installed:
|
|
732
|
+
* - a newer `@rc` exists → stay on rc (mid-chain canary; UNCHANGED).
|
|
733
|
+
* - no newer rc but `@latest` > installed → converge to `@latest`, pinned to
|
|
734
|
+
* the concrete resolved version, with a LOUD log. The operator stays ON
|
|
735
|
+
* the rc channel; only this upgrade's resolved version changes.
|
|
736
|
+
* - neither tag is above installed → fall through to the rc tag and let the
|
|
737
|
+
* normal "already at <v>" no-op fire after `bun add -g`.
|
|
738
|
+
*
|
|
739
|
+
* Returns the original `{ installSpec: "rc" }` unchanged whenever we can't read
|
|
740
|
+
* the installed version or can't resolve a higher stable (fail-open: never
|
|
741
|
+
* block a legitimate `@rc` upgrade on a flaky probe).
|
|
742
|
+
*/
|
|
743
|
+
async function resolveRcBestOf(
|
|
744
|
+
target: ResolvedTarget,
|
|
745
|
+
beforeVersion: string | null,
|
|
746
|
+
r: Resolved,
|
|
747
|
+
): Promise<ResolvedNpmTarget> {
|
|
748
|
+
const rcTarget: ResolvedNpmTarget = { installSpec: "rc", channel: "rc" };
|
|
749
|
+
if (!beforeVersion) return rcTarget;
|
|
750
|
+
|
|
751
|
+
const [rcVersion, latestVersion] = await Promise.all([
|
|
752
|
+
r.resolveChannelVersion(target.packageName, "rc"),
|
|
753
|
+
r.resolveChannelVersion(target.packageName, "latest"),
|
|
754
|
+
]);
|
|
755
|
+
|
|
756
|
+
const rcAbove = rcVersion !== null && (compareVersions(rcVersion, beforeVersion) ?? -1) > 0;
|
|
757
|
+
if (rcAbove) {
|
|
758
|
+
// A newer rc exists — the canary stays ahead (the #332 mid-chain path).
|
|
759
|
+
return rcTarget;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const latestAbove =
|
|
763
|
+
latestVersion !== null && (compareVersions(latestVersion, beforeVersion) ?? -1) > 0;
|
|
764
|
+
// (`latestVersion !== null` already guaranteed by `latestAbove`; the second
|
|
765
|
+
// term only narrows it for TS.)
|
|
766
|
+
if (latestAbove && latestVersion) {
|
|
767
|
+
// End-of-chain / skipped-rc train: nothing newer on @rc, but stable moved
|
|
768
|
+
// ahead. Converge to @latest — pinned to the concrete version so a moving
|
|
769
|
+
// dist-tag can't race us — and say so LOUDLY (the hub#659 stranding fix).
|
|
770
|
+
r.log(
|
|
771
|
+
`${target.short}: the rc channel has nothing newer than ${beforeVersion}; ` +
|
|
772
|
+
`converging to stable ${latestVersion} — you'll pick up the next rc when it ships.`,
|
|
773
|
+
);
|
|
774
|
+
return { installSpec: latestVersion, channel: "latest" };
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Neither tag is above installed — stay on @rc and let the post-install
|
|
778
|
+
// "already at <v>" no-op fire (handled in upgradeNpm). When both probes
|
|
779
|
+
// resolved and both are ≤ installed, name that explicitly.
|
|
780
|
+
if (rcVersion !== null && latestVersion !== null) {
|
|
781
|
+
r.log(
|
|
782
|
+
`${target.short}: on the rc channel — @rc (${rcVersion}) and @latest (${latestVersion}) ` +
|
|
783
|
+
`are both at or below installed ${beforeVersion}.`,
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
return rcTarget;
|
|
787
|
+
}
|
|
788
|
+
|
|
692
789
|
async function upgradeNpm(target: ResolvedTarget, sourceDir: string, r: Resolved): Promise<number> {
|
|
693
790
|
r.log(`${target.short}: npm-installed (${sourceDir})`);
|
|
694
791
|
const beforeVersion = readPackageVersion(join(sourceDir, "package.json"));
|
|
695
|
-
const
|
|
792
|
+
const pickedChannel = pickChannel(beforeVersion, r);
|
|
793
|
+
|
|
794
|
+
// RC-channel best-of resolution (hub#659). On the rc channel, resolve to the
|
|
795
|
+
// highest version above installed across @rc AND @latest — so an end-of-chain
|
|
796
|
+
// box converges to stable instead of stranding below it. The stable channel
|
|
797
|
+
// and explicit programmatic `--tag` are UNCHANGED: only an *auto-detected or
|
|
798
|
+
// --channel-rc* resolution reaches for stable. An explicit `--channel rc`
|
|
799
|
+
// ALSO flows through best-of (a deliberate rc operator still gets converge);
|
|
800
|
+
// `r.tag` (programmatic pin) and `--channel latest` both leave it untouched.
|
|
801
|
+
let installSpec = pickedChannel;
|
|
802
|
+
let channel = pickedChannel;
|
|
803
|
+
if (pickedChannel === "rc" && !r.tag) {
|
|
804
|
+
const resolved = await resolveRcBestOf(target, beforeVersion, r);
|
|
805
|
+
installSpec = resolved.installSpec;
|
|
806
|
+
channel = resolved.channel;
|
|
807
|
+
}
|
|
696
808
|
|
|
697
809
|
// Downgrade guard: refuse to silently move backward. Only applies when
|
|
698
810
|
// we can read both sides — beforeVersion from disk, targetVersion via
|
|
@@ -700,6 +812,10 @@ async function upgradeNpm(target: ResolvedTarget, sourceDir: string, r: Resolved
|
|
|
700
812
|
// behavior: just run `bun add -g`). This is the load-bearing fix for
|
|
701
813
|
// hub#332 — Aaron got `0.5.13-rc.13` → `0.5.10` because the implicit
|
|
702
814
|
// `@latest` resolved to a prior stable while he was on the rc chain.
|
|
815
|
+
//
|
|
816
|
+
// After hub#659's best-of resolution `channel` is already the tag we're
|
|
817
|
+
// actually shipping (rc on the canary path, latest on the converge path), so
|
|
818
|
+
// the guard checks the version we'll really install.
|
|
703
819
|
if (beforeVersion && !r.allowDowngrade) {
|
|
704
820
|
const targetVersion = await r.resolveChannelVersion(target.packageName, channel);
|
|
705
821
|
if (targetVersion) {
|
|
@@ -725,7 +841,7 @@ async function upgradeNpm(target: ResolvedTarget, sourceDir: string, r: Resolved
|
|
|
725
841
|
}
|
|
726
842
|
}
|
|
727
843
|
|
|
728
|
-
const spec = `${target.packageName}@${
|
|
844
|
+
const spec = `${target.packageName}@${installSpec}`;
|
|
729
845
|
r.log(`${target.short}: bun add -g ${spec}`);
|
|
730
846
|
const code = await r.runner.run(["bun", "add", "-g", spec]);
|
|
731
847
|
if (code !== 0) {
|