@openparachute/hub 0.5.0 → 0.5.2
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 +1 -1
- package/src/__tests__/auth.test.ts +352 -1
- package/src/__tests__/hub-server.test.ts +580 -1
- package/src/__tests__/lifecycle.test.ts +101 -4
- package/src/__tests__/module-manifest.test.ts +13 -0
- package/src/__tests__/services-manifest.test.ts +26 -0
- package/src/commands/auth.ts +200 -1
- package/src/commands/lifecycle.ts +39 -13
- package/src/hub-server.ts +126 -29
- package/src/jwt-audience.ts +40 -0
- package/src/module-manifest.ts +19 -0
- package/src/oauth-handlers.ts +1 -32
- package/src/service-spec.ts +8 -0
- package/src/services-manifest.ts +21 -0
|
@@ -517,9 +517,13 @@ describe("parachute start", () => {
|
|
|
517
517
|
}
|
|
518
518
|
});
|
|
519
519
|
|
|
520
|
-
test("third-party
|
|
521
|
-
// A row whose name isn't
|
|
522
|
-
//
|
|
520
|
+
test("start: installDir-less third-party row surfaces an actionable error", async () => {
|
|
521
|
+
// A services.json row whose name isn't first-party AND has no installDir
|
|
522
|
+
// can't yield a startCmd. Pre-fix this hit the generic "unknown service"
|
|
523
|
+
// path (misleading — the row exists, just with stale shape). Post-fix
|
|
524
|
+
// resolveTargets returns the entry with spec=undefined and start prints
|
|
525
|
+
// an actionable message that points at the real fix (re-install or
|
|
526
|
+
// upgrade-the-module).
|
|
523
527
|
const h = makeHarness();
|
|
524
528
|
try {
|
|
525
529
|
upsertService(
|
|
@@ -539,7 +543,30 @@ describe("parachute start", () => {
|
|
|
539
543
|
log: (l) => lines.push(l),
|
|
540
544
|
});
|
|
541
545
|
expect(code).toBe(1);
|
|
542
|
-
|
|
546
|
+
const out = lines.join("\n");
|
|
547
|
+
expect(out).toMatch(/services\.json entry has no installDir/);
|
|
548
|
+
expect(out).toMatch(/parachute install <path-to-mystery>/);
|
|
549
|
+
expect(out).not.toMatch(/unknown service/);
|
|
550
|
+
} finally {
|
|
551
|
+
h.cleanup();
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("start: name absent from services.json still errors as unknown service", async () => {
|
|
556
|
+
// The genuinely-unknown path: no first-party fallback, no row in
|
|
557
|
+
// services.json. Distinguish from the above (row exists but lacks
|
|
558
|
+
// installDir) so the error message is right-shaped for each.
|
|
559
|
+
const h = makeHarness();
|
|
560
|
+
try {
|
|
561
|
+
seedVault(h.manifestPath);
|
|
562
|
+
const lines: string[] = [];
|
|
563
|
+
const code = await start("ghost", {
|
|
564
|
+
configDir: h.configDir,
|
|
565
|
+
manifestPath: h.manifestPath,
|
|
566
|
+
log: (l) => lines.push(l),
|
|
567
|
+
});
|
|
568
|
+
expect(code).toBe(1);
|
|
569
|
+
expect(lines.join("\n")).toMatch(/unknown service "ghost"/);
|
|
543
570
|
} finally {
|
|
544
571
|
h.cleanup();
|
|
545
572
|
}
|
|
@@ -721,6 +748,45 @@ describe("parachute stop", () => {
|
|
|
721
748
|
h.cleanup();
|
|
722
749
|
}
|
|
723
750
|
});
|
|
751
|
+
|
|
752
|
+
test("third-party row without installDir: stops via pidfile", async () => {
|
|
753
|
+
// Graceful-degradation path: an installed-but-stale third-party row
|
|
754
|
+
// (no installDir field — pre-installDir-contract self-registration)
|
|
755
|
+
// should still be stoppable. stop only needs the short name to find
|
|
756
|
+
// the pidfile; spec resolution isn't on the critical path for stop.
|
|
757
|
+
const h = makeHarness();
|
|
758
|
+
try {
|
|
759
|
+
upsertService(
|
|
760
|
+
{
|
|
761
|
+
name: "mystery",
|
|
762
|
+
port: 1944,
|
|
763
|
+
paths: ["/mystery"],
|
|
764
|
+
health: "/mystery/health",
|
|
765
|
+
version: "0.0.1",
|
|
766
|
+
},
|
|
767
|
+
h.manifestPath,
|
|
768
|
+
);
|
|
769
|
+
writePid("mystery", 4242, h.configDir);
|
|
770
|
+
const killed: Array<[number, string | number]> = [];
|
|
771
|
+
let aliveCall = 0;
|
|
772
|
+
const code = await stop("mystery", {
|
|
773
|
+
configDir: h.configDir,
|
|
774
|
+
manifestPath: h.manifestPath,
|
|
775
|
+
kill: (pid, sig) => killed.push([pid, sig]),
|
|
776
|
+
alive: () => {
|
|
777
|
+
aliveCall++;
|
|
778
|
+
return aliveCall === 1;
|
|
779
|
+
},
|
|
780
|
+
sleep: async () => {},
|
|
781
|
+
log: () => {},
|
|
782
|
+
});
|
|
783
|
+
expect(code).toBe(0);
|
|
784
|
+
expect(killed).toEqual([[4242, "SIGTERM"]]);
|
|
785
|
+
expect(readPid("mystery", h.configDir)).toBeUndefined();
|
|
786
|
+
} finally {
|
|
787
|
+
h.cleanup();
|
|
788
|
+
}
|
|
789
|
+
});
|
|
724
790
|
});
|
|
725
791
|
|
|
726
792
|
describe("parachute restart", () => {
|
|
@@ -822,6 +888,37 @@ describe("parachute logs", () => {
|
|
|
822
888
|
h.cleanup();
|
|
823
889
|
}
|
|
824
890
|
});
|
|
891
|
+
|
|
892
|
+
test("third-party row without installDir: tails by short name", async () => {
|
|
893
|
+
// Graceful-degradation path: log file is keyed by short name, written by
|
|
894
|
+
// start. installDir is irrelevant for tailing — the entry just needs to
|
|
895
|
+
// exist in services.json.
|
|
896
|
+
const h = makeHarness();
|
|
897
|
+
try {
|
|
898
|
+
upsertService(
|
|
899
|
+
{
|
|
900
|
+
name: "mystery",
|
|
901
|
+
port: 1944,
|
|
902
|
+
paths: ["/mystery"],
|
|
903
|
+
health: "/mystery/health",
|
|
904
|
+
version: "0.0.1",
|
|
905
|
+
},
|
|
906
|
+
h.manifestPath,
|
|
907
|
+
);
|
|
908
|
+
const p = ensureLogPath("mystery", h.configDir);
|
|
909
|
+
writeFileSync(p, "mystery line 1\nmystery line 2\n");
|
|
910
|
+
const lines: string[] = [];
|
|
911
|
+
const code = await logs("mystery", {
|
|
912
|
+
configDir: h.configDir,
|
|
913
|
+
manifestPath: h.manifestPath,
|
|
914
|
+
log: (l) => lines.push(l),
|
|
915
|
+
});
|
|
916
|
+
expect(code).toBe(0);
|
|
917
|
+
expect(lines).toEqual(["mystery line 1", "mystery line 2"]);
|
|
918
|
+
} finally {
|
|
919
|
+
h.cleanup();
|
|
920
|
+
}
|
|
921
|
+
});
|
|
825
922
|
});
|
|
826
923
|
|
|
827
924
|
describe("process-group lifecycle (hub#88)", () => {
|
|
@@ -130,6 +130,19 @@ describe("validateModuleManifest", () => {
|
|
|
130
130
|
const m = validateModuleManifest(VALID, "x");
|
|
131
131
|
expect(m.managementUrl).toBeUndefined();
|
|
132
132
|
});
|
|
133
|
+
|
|
134
|
+
test("stripPrefix accepts boolean true and false; rejects non-boolean", () => {
|
|
135
|
+
expect(validateModuleManifest({ ...VALID, stripPrefix: true }, "x").stripPrefix).toBe(true);
|
|
136
|
+
expect(validateModuleManifest({ ...VALID, stripPrefix: false }, "x").stripPrefix).toBe(false);
|
|
137
|
+
expect(() => validateModuleManifest({ ...VALID, stripPrefix: "yes" }, "x")).toThrow(
|
|
138
|
+
/stripPrefix/,
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("stripPrefix absent stays absent", () => {
|
|
143
|
+
const m = validateModuleManifest(VALID, "x");
|
|
144
|
+
expect(m.stripPrefix).toBeUndefined();
|
|
145
|
+
});
|
|
133
146
|
});
|
|
134
147
|
|
|
135
148
|
describe("readModuleManifest", () => {
|
|
@@ -196,6 +196,32 @@ describe("services-manifest", () => {
|
|
|
196
196
|
cleanup();
|
|
197
197
|
}
|
|
198
198
|
});
|
|
199
|
+
|
|
200
|
+
test("round-trips optional stripPrefix (true and false)", () => {
|
|
201
|
+
const { path, cleanup } = makeTempPath();
|
|
202
|
+
try {
|
|
203
|
+
const stripping: ServiceEntry = { ...vault, stripPrefix: true };
|
|
204
|
+
upsertService(stripping, path);
|
|
205
|
+
expect(readManifest(path).services[0]).toEqual(stripping);
|
|
206
|
+
|
|
207
|
+
const explicitFalse: ServiceEntry = { ...vault, stripPrefix: false };
|
|
208
|
+
upsertService(explicitFalse, path);
|
|
209
|
+
expect(readManifest(path).services[0]).toEqual(explicitFalse);
|
|
210
|
+
} finally {
|
|
211
|
+
cleanup();
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("rejects non-boolean stripPrefix", () => {
|
|
216
|
+
const { path, cleanup } = makeTempPath();
|
|
217
|
+
try {
|
|
218
|
+
expect(() =>
|
|
219
|
+
upsertService({ ...vault, stripPrefix: "yes" as unknown as boolean }, path),
|
|
220
|
+
).toThrow(/stripPrefix/);
|
|
221
|
+
} finally {
|
|
222
|
+
cleanup();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
199
225
|
});
|
|
200
226
|
|
|
201
227
|
describe("claw → agent migration", () => {
|
package/src/commands/auth.ts
CHANGED
|
@@ -25,7 +25,13 @@ import { listGrantsForUser, revokeGrant } from "../grants.ts";
|
|
|
25
25
|
import { HUB_DEFAULT_PORT, readHubPort } from "../hub-control.ts";
|
|
26
26
|
import { openHubDb } from "../hub-db.ts";
|
|
27
27
|
import { deriveHubOrigin } from "../hub-origin.ts";
|
|
28
|
-
import {
|
|
28
|
+
import { inferAudience } from "../jwt-audience.ts";
|
|
29
|
+
import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
|
|
30
|
+
import {
|
|
31
|
+
OPERATOR_TOKEN_CLIENT_ID,
|
|
32
|
+
issueOperatorToken,
|
|
33
|
+
readOperatorTokenFile,
|
|
34
|
+
} from "../operator-token.ts";
|
|
29
35
|
import { rotateSigningKey } from "../signing-keys.ts";
|
|
30
36
|
import {
|
|
31
37
|
SingleUserModeError,
|
|
@@ -54,6 +60,7 @@ const HUB_LOCAL_SUBCOMMANDS = new Set([
|
|
|
54
60
|
"set-password",
|
|
55
61
|
"list-users",
|
|
56
62
|
"rotate-operator",
|
|
63
|
+
"mint-token",
|
|
57
64
|
"pending-clients",
|
|
58
65
|
"approve-client",
|
|
59
66
|
"list-grants",
|
|
@@ -73,6 +80,9 @@ Usage:
|
|
|
73
80
|
parachute auth 2fa backup-codes Regenerate backup codes
|
|
74
81
|
parachute auth rotate-key Rotate the hub's JWT signing key
|
|
75
82
|
parachute auth rotate-operator Mint a fresh ~/.parachute/operator.token
|
|
83
|
+
parachute auth mint-token --scope <scope> [--aud <aud>] [--ttl <duration>] [--sub <sub>]
|
|
84
|
+
Mint a scope-narrow JWT against the
|
|
85
|
+
operator's identity (stdout = JWT)
|
|
76
86
|
parachute auth pending-clients List OAuth clients awaiting approval
|
|
77
87
|
parachute auth approve-client <id> Approve a pending OAuth client
|
|
78
88
|
parachute auth list-grants [--username <name>]
|
|
@@ -104,6 +114,15 @@ rotate-operator mints a fresh long-lived operator token at
|
|
|
104
114
|
as their bearer when calling on-box services. set-password also writes
|
|
105
115
|
the file on first-run / password reset.
|
|
106
116
|
|
|
117
|
+
mint-token issues a single scope-narrow JWT against the operator's
|
|
118
|
+
identity, signed with the same key as OAuth-issued tokens. Pipeable:
|
|
119
|
+
\`parachute auth mint-token --scope scribe:transcribe | pbcopy\`. The
|
|
120
|
+
audience defaults via the same inference rule the OAuth flow uses
|
|
121
|
+
(named \`vault:<name>:<verb>\` → \`vault.<name>\`, otherwise the first
|
|
122
|
+
colon-prefixed scope's namespace, fallback \`hub\`). TTL defaults to 90d,
|
|
123
|
+
caps at 365d. Requires a valid ~/.parachute/operator.token (run
|
|
124
|
+
\`parachute auth set-password\` or \`rotate-operator\` first).
|
|
125
|
+
|
|
107
126
|
pending-clients + approve-client gate /oauth/register against operator
|
|
108
127
|
approval (closes #74). Self-served DCR registrations land as 'pending'
|
|
109
128
|
and cannot OAuth until you run \`parachute auth approve-client <id>\`.
|
|
@@ -571,6 +590,177 @@ function runRevokeGrant(args: readonly string[], deps: AuthDeps): number {
|
|
|
571
590
|
}
|
|
572
591
|
}
|
|
573
592
|
|
|
593
|
+
interface MintTokenFlags {
|
|
594
|
+
scope?: string;
|
|
595
|
+
aud?: string;
|
|
596
|
+
ttl?: string;
|
|
597
|
+
sub?: string;
|
|
598
|
+
error?: string;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function parseMintTokenFlags(args: readonly string[]): MintTokenFlags {
|
|
602
|
+
let scope: string | undefined;
|
|
603
|
+
let aud: string | undefined;
|
|
604
|
+
let ttl: string | undefined;
|
|
605
|
+
let sub: string | undefined;
|
|
606
|
+
for (let i = 0; i < args.length; i++) {
|
|
607
|
+
const a = args[i];
|
|
608
|
+
if (a === "--scope") {
|
|
609
|
+
const v = args[++i];
|
|
610
|
+
if (!v) return { error: "--scope requires a value" };
|
|
611
|
+
scope = v;
|
|
612
|
+
} else if (a?.startsWith("--scope=")) {
|
|
613
|
+
scope = a.slice("--scope=".length);
|
|
614
|
+
if (!scope) return { error: "--scope requires a value" };
|
|
615
|
+
} else if (a === "--aud") {
|
|
616
|
+
const v = args[++i];
|
|
617
|
+
if (!v) return { error: "--aud requires a value" };
|
|
618
|
+
aud = v;
|
|
619
|
+
} else if (a?.startsWith("--aud=")) {
|
|
620
|
+
aud = a.slice("--aud=".length);
|
|
621
|
+
if (!aud) return { error: "--aud requires a value" };
|
|
622
|
+
} else if (a === "--ttl") {
|
|
623
|
+
const v = args[++i];
|
|
624
|
+
if (!v) return { error: "--ttl requires a value" };
|
|
625
|
+
ttl = v;
|
|
626
|
+
} else if (a?.startsWith("--ttl=")) {
|
|
627
|
+
ttl = a.slice("--ttl=".length);
|
|
628
|
+
if (!ttl) return { error: "--ttl requires a value" };
|
|
629
|
+
} else if (a === "--sub") {
|
|
630
|
+
const v = args[++i];
|
|
631
|
+
if (!v) return { error: "--sub requires a value" };
|
|
632
|
+
sub = v;
|
|
633
|
+
} else if (a?.startsWith("--sub=")) {
|
|
634
|
+
sub = a.slice("--sub=".length);
|
|
635
|
+
if (!sub) return { error: "--sub requires a value" };
|
|
636
|
+
} else {
|
|
637
|
+
return { error: `unknown flag "${a}"` };
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return { scope, aud, ttl, sub };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const MINT_TOKEN_TTL_DEFAULT_SECONDS = 90 * 24 * 60 * 60;
|
|
644
|
+
const MINT_TOKEN_TTL_MAX_SECONDS = 365 * 24 * 60 * 60;
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Parse a Go-ish duration string: integer + one of d/h/m/s. Caps at 365d.
|
|
648
|
+
* `90d` → 7776000. We don't honor Go's stdlib `time.ParseDuration` exactly
|
|
649
|
+
* (no `d` there), so this is a small custom parser to keep the operator
|
|
650
|
+
* surface obvious.
|
|
651
|
+
*/
|
|
652
|
+
function parseTtl(input: string): { seconds: number } | { error: string } {
|
|
653
|
+
const m = /^(\d+)(d|h|m|s)$/.exec(input);
|
|
654
|
+
if (!m) return { error: `invalid --ttl "${input}" — expected e.g. 90d, 24h, 30m, 60s` };
|
|
655
|
+
const n = Number.parseInt(m[1]!, 10);
|
|
656
|
+
if (!Number.isFinite(n) || n <= 0) return { error: `invalid --ttl "${input}" — must be > 0` };
|
|
657
|
+
const unit = m[2]!;
|
|
658
|
+
const mult = unit === "d" ? 86400 : unit === "h" ? 3600 : unit === "m" ? 60 : 1;
|
|
659
|
+
const seconds = n * mult;
|
|
660
|
+
if (seconds > MINT_TOKEN_TTL_MAX_SECONDS) {
|
|
661
|
+
return { error: `--ttl "${input}" exceeds 365d cap` };
|
|
662
|
+
}
|
|
663
|
+
return { seconds };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async function runMintToken(args: readonly string[], deps: AuthDeps): Promise<number> {
|
|
667
|
+
const flags = parseMintTokenFlags(args);
|
|
668
|
+
if (flags.error) {
|
|
669
|
+
console.error(`parachute auth mint-token: ${flags.error}`);
|
|
670
|
+
return 1;
|
|
671
|
+
}
|
|
672
|
+
if (!flags.scope) {
|
|
673
|
+
console.error("parachute auth mint-token: --scope is required");
|
|
674
|
+
console.error(
|
|
675
|
+
"usage: parachute auth mint-token --scope <scope> [--aud <aud>] [--ttl <duration>] [--sub <sub>]",
|
|
676
|
+
);
|
|
677
|
+
return 1;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const scopes = flags.scope.split(/\s+/).filter((s) => s.length > 0);
|
|
681
|
+
if (scopes.length === 0) {
|
|
682
|
+
console.error("parachute auth mint-token: --scope must contain at least one scope");
|
|
683
|
+
return 1;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
let ttlSeconds = MINT_TOKEN_TTL_DEFAULT_SECONDS;
|
|
687
|
+
if (flags.ttl) {
|
|
688
|
+
const parsed = parseTtl(flags.ttl);
|
|
689
|
+
if ("error" in parsed) {
|
|
690
|
+
console.error(`parachute auth mint-token: ${parsed.error}`);
|
|
691
|
+
return 1;
|
|
692
|
+
}
|
|
693
|
+
ttlSeconds = parsed.seconds;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const configDir = deps.configDir ?? CONFIG_DIR;
|
|
697
|
+
const operatorToken = await readOperatorTokenFile(configDir);
|
|
698
|
+
if (!operatorToken) {
|
|
699
|
+
console.error(
|
|
700
|
+
"parachute auth mint-token: no operator token found at ~/.parachute/operator.token",
|
|
701
|
+
);
|
|
702
|
+
console.error(
|
|
703
|
+
"run `parachute auth set-password` (first run) or `parachute auth rotate-operator` to mint one",
|
|
704
|
+
);
|
|
705
|
+
return 1;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const issuer = resolveHubIssuer(deps.hubOrigin, configDir);
|
|
709
|
+
|
|
710
|
+
const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
|
|
711
|
+
try {
|
|
712
|
+
let operatorSub: string;
|
|
713
|
+
try {
|
|
714
|
+
const validated = await validateAccessToken(db, operatorToken, issuer);
|
|
715
|
+
const sub = validated.payload.sub;
|
|
716
|
+
if (typeof sub !== "string" || sub.length === 0) {
|
|
717
|
+
console.error("parachute auth mint-token: operator token has no sub claim");
|
|
718
|
+
return 1;
|
|
719
|
+
}
|
|
720
|
+
// Scope gate: a valid signature + non-expired JWT at this path is not
|
|
721
|
+
// sufficient — the token must carry operator-equivalent scope. Without
|
|
722
|
+
// this, a narrowly-scoped JWT stashed at ~/.parachute/operator.token
|
|
723
|
+
// would be treated as operator-bearer and mint arbitrary tokens
|
|
724
|
+
// (privilege escalation: narrow → arbitrary). Only set-password and
|
|
725
|
+
// rotate-operator legitimately write to this path; both seed the full
|
|
726
|
+
// OPERATOR_TOKEN_SCOPES set, so hub:admin is the right gate.
|
|
727
|
+
const tokenScope =
|
|
728
|
+
typeof validated.payload.scope === "string"
|
|
729
|
+
? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
|
|
730
|
+
: [];
|
|
731
|
+
if (!tokenScope.includes("hub:admin")) {
|
|
732
|
+
console.error("parachute auth mint-token: operator token lacks hub:admin scope");
|
|
733
|
+
console.error("run `parachute auth rotate-operator` to mint a fresh one");
|
|
734
|
+
return 1;
|
|
735
|
+
}
|
|
736
|
+
operatorSub = sub;
|
|
737
|
+
} catch (err) {
|
|
738
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
739
|
+
console.error(`parachute auth mint-token: operator token invalid — ${msg}`);
|
|
740
|
+
console.error(
|
|
741
|
+
"run `parachute auth rotate-operator` to mint a fresh one, or check that the hub origin matches",
|
|
742
|
+
);
|
|
743
|
+
return 1;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const audience = flags.aud ?? inferAudience(scopes);
|
|
747
|
+
const sub = flags.sub ?? operatorSub;
|
|
748
|
+
|
|
749
|
+
const minted = await signAccessToken(db, {
|
|
750
|
+
sub,
|
|
751
|
+
scopes,
|
|
752
|
+
audience,
|
|
753
|
+
clientId: OPERATOR_TOKEN_CLIENT_ID,
|
|
754
|
+
issuer,
|
|
755
|
+
ttlSeconds,
|
|
756
|
+
});
|
|
757
|
+
console.log(minted.token);
|
|
758
|
+
return 0;
|
|
759
|
+
} finally {
|
|
760
|
+
db.close();
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
574
764
|
function runListUsers(deps: AuthDeps): number {
|
|
575
765
|
const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
|
|
576
766
|
try {
|
|
@@ -641,6 +831,15 @@ export async function auth(args: readonly string[], deps: AuthDeps | Runner = {}
|
|
|
641
831
|
return 1;
|
|
642
832
|
}
|
|
643
833
|
}
|
|
834
|
+
if (sub === "mint-token") {
|
|
835
|
+
try {
|
|
836
|
+
return await runMintToken(args.slice(1), normalized);
|
|
837
|
+
} catch (err) {
|
|
838
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
839
|
+
console.error(`parachute auth mint-token: ${msg}`);
|
|
840
|
+
return 1;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
644
843
|
if (sub === "pending-clients") {
|
|
645
844
|
try {
|
|
646
845
|
return runPendingClients(normalized);
|
|
@@ -214,8 +214,10 @@ interface ResolvedTarget {
|
|
|
214
214
|
* Lifecycle spec resolved at request time. First-party comes from
|
|
215
215
|
* `getSpec(short)`; third-party comes from
|
|
216
216
|
* `getSpecFromInstallDir(entry.installDir, ...)`. May be undefined when
|
|
217
|
-
* a row has neither —
|
|
218
|
-
*
|
|
217
|
+
* a row has neither — `start` prints the actionable "no installDir"
|
|
218
|
+
* re-install message for an installDir-less third-party row, or
|
|
219
|
+
* "lifecycle not yet supported" otherwise; `stop`/`logs` keep working
|
|
220
|
+
* via pidfile/logfile semantics keyed by `short`.
|
|
219
221
|
*/
|
|
220
222
|
spec: ServiceSpec | undefined;
|
|
221
223
|
}
|
|
@@ -248,6 +250,13 @@ async function specForEntry(
|
|
|
248
250
|
* `module.json` (which is what install copied to `entry.name` for
|
|
249
251
|
* third-party). First-party are addressed by their short name (vault,
|
|
250
252
|
* notes, …) and matched via `shortNameForManifest`.
|
|
253
|
+
*
|
|
254
|
+
* Named-path detail: a third-party row whose name matches but lacks
|
|
255
|
+
* `installDir` resolves to the entry with `spec: undefined` (rather than
|
|
256
|
+
* an "unknown service" error). `stop`/`logs` handle the spec-less case
|
|
257
|
+
* via pidfile/logfile semantics; `start` surfaces an actionable
|
|
258
|
+
* re-install hint downstream. The genuinely-unknown path (no first-party
|
|
259
|
+
* fallback AND no row in services.json) still errors as `unknown service`.
|
|
251
260
|
*/
|
|
252
261
|
async function resolveTargets(
|
|
253
262
|
svc: string | undefined,
|
|
@@ -268,13 +277,19 @@ async function resolveTargets(
|
|
|
268
277
|
}
|
|
269
278
|
return { targets: [{ short: svc, entry, spec: firstPartySpec }] };
|
|
270
279
|
}
|
|
271
|
-
// Third-party: match a services.json row by name.
|
|
272
|
-
//
|
|
280
|
+
// Third-party: match a services.json row by name. Rows with `installDir`
|
|
281
|
+
// resolve a full spec from the on-disk module.json. Rows without it are
|
|
282
|
+
// still managed (stop/logs use pidfile/logfile semantics keyed by short
|
|
283
|
+
// name), but with `spec: undefined` — `start` will surface an
|
|
284
|
+
// installDir-specific error downstream rather than reject up front.
|
|
273
285
|
const entry = manifest.services.find((s) => s.name === svc);
|
|
274
|
-
if (entry
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
286
|
+
if (entry) {
|
|
287
|
+
if (entry.installDir) {
|
|
288
|
+
const { spec, error } = await specForEntry(svc, entry);
|
|
289
|
+
if (error) return { error: `${svc}: invalid module.json — ${error}` };
|
|
290
|
+
return { targets: [{ short: svc, entry, spec }] };
|
|
291
|
+
}
|
|
292
|
+
return { targets: [{ short: svc, entry, spec: undefined }] };
|
|
278
293
|
}
|
|
279
294
|
return {
|
|
280
295
|
error: `unknown service "${svc}". known: ${knownServices().join(", ")}`,
|
|
@@ -323,7 +338,17 @@ export async function start(svc: string | undefined, opts: LifecycleOpts = {}):
|
|
|
323
338
|
|
|
324
339
|
const cmd = spec?.startCmd?.(entry);
|
|
325
340
|
if (!cmd || cmd.length === 0) {
|
|
326
|
-
|
|
341
|
+
// Distinguish the missing-installDir case from "spec resolved but has
|
|
342
|
+
// no startCmd" — the former is fixable by re-registering the module,
|
|
343
|
+
// the latter is a hub-level limitation. Third-party rows hit the first
|
|
344
|
+
// branch when their self-registration predates the installDir contract.
|
|
345
|
+
if (!getSpec(short) && !entry.installDir) {
|
|
346
|
+
r.log(
|
|
347
|
+
`${short}: services.json entry has no installDir, so the start command can't be resolved. Re-run \`parachute install <path-to-${short}>\` to refresh its registration, or upgrade the module to a version that self-registers with installDir.`,
|
|
348
|
+
);
|
|
349
|
+
} else {
|
|
350
|
+
r.log(`${short}: lifecycle not yet supported for this service.`);
|
|
351
|
+
}
|
|
327
352
|
failures++;
|
|
328
353
|
continue;
|
|
329
354
|
}
|
|
@@ -487,13 +512,14 @@ export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
|
|
|
487
512
|
|
|
488
513
|
// logs only needs a valid short name to find the log file. First-party
|
|
489
514
|
// wins via the spec lookup; third-party rows match by `entry.name`; the
|
|
490
|
-
// internal hub is a known short outside of services.json.
|
|
491
|
-
//
|
|
492
|
-
//
|
|
515
|
+
// internal hub is a known short outside of services.json. installDir is
|
|
516
|
+
// irrelevant here — the log file is keyed by short name and exists once
|
|
517
|
+
// the service has run, regardless of how it was registered. We just need
|
|
518
|
+
// to confirm the name maps to something the CLI manages.
|
|
493
519
|
const isFirstParty = getSpec(svc) !== undefined;
|
|
494
520
|
if (!isFirstParty && svc !== HUB_SVC) {
|
|
495
521
|
const entry = readManifest(manifestPath).services.find((s) => s.name === svc);
|
|
496
|
-
if (!entry
|
|
522
|
+
if (!entry) {
|
|
497
523
|
log(`unknown service "${svc}". known: ${[HUB_SVC, ...knownServices()].join(", ")}`);
|
|
498
524
|
return 1;
|
|
499
525
|
}
|