@crvouga/personal 0.1.0 → 0.1.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 +136 -3
- package/cli.mjs +2174 -294
- package/commands.json +264 -0
- package/package.json +3 -3
- package/probe.mjs +561 -30
- package/releasing.md +6 -5
- package/schema.json +11 -1
package/cli.mjs
CHANGED
|
@@ -72,15 +72,19 @@ function reproduction(session, args) {
|
|
|
72
72
|
}
|
|
73
73
|
function commandChoices(commands, term) {
|
|
74
74
|
return searchCommands(commands, term).map((command) => ({
|
|
75
|
-
name:
|
|
75
|
+
name: `${clean(command.group)}: ${clean(command.title)}`,
|
|
76
76
|
value: `command:${command.id}`,
|
|
77
77
|
description: `${clean(command.description)}${command.mutation ? " · confirmation required" : ""}`
|
|
78
78
|
}));
|
|
79
79
|
}
|
|
80
80
|
function reportChecks(data) {
|
|
81
|
-
if (!record2(data)
|
|
81
|
+
if (!record2(data))
|
|
82
82
|
return [];
|
|
83
|
-
|
|
83
|
+
const nodes = Array.isArray(data["nodes"]) ? data["nodes"] : [];
|
|
84
|
+
return [
|
|
85
|
+
...nodes.flatMap(nodeChecks),
|
|
86
|
+
...nodeChecks({ node: "tailscale", checks: data["checks"] })
|
|
87
|
+
];
|
|
84
88
|
}
|
|
85
89
|
function nodeChecks(value) {
|
|
86
90
|
if (!record2(value) || !Array.isArray(value["checks"]))
|
|
@@ -117,6 +121,27 @@ var init_ui_data = __esm(() => {
|
|
|
117
121
|
init_search();
|
|
118
122
|
});
|
|
119
123
|
|
|
124
|
+
// packages/personal-cli/src/ui-repairs.ts
|
|
125
|
+
function showRepairs(session, data) {
|
|
126
|
+
const catalog = data["command"] === "repairs";
|
|
127
|
+
const entries = data[catalog ? "recipes" : "steps"];
|
|
128
|
+
if (!Array.isArray(entries))
|
|
129
|
+
return;
|
|
130
|
+
const steps = entries.filter(record2);
|
|
131
|
+
session.display.table(["Computer / owner", "Repair", "Execution"], steps.slice(0, 15).map((step) => [
|
|
132
|
+
text(step[catalog ? "owner" : "node"]),
|
|
133
|
+
text(step["title"]),
|
|
134
|
+
step["automatic"] === true && (catalog || step["eligible"] === true) ? "Owner command after explicit apply" : "Operator action; inspect instructions"
|
|
135
|
+
]));
|
|
136
|
+
if (steps.length > 15)
|
|
137
|
+
session.display.write(`Showing 15 of ${steps.length} repair entries. Inspect structured details for the complete plan and native login commands.`);
|
|
138
|
+
if (!catalog)
|
|
139
|
+
session.display.write(data["applied"] === true ? `Owner commands completed: ${text(data["completed"])}. Fresh health verified: ${data["healthOk"] === true ? "yes" : "no; review remaining checks"}.` : "Preview only. Review the repair effects and native login instructions in structured details before applying.");
|
|
140
|
+
}
|
|
141
|
+
var init_ui_repairs = __esm(() => {
|
|
142
|
+
init_ui_data();
|
|
143
|
+
});
|
|
144
|
+
|
|
120
145
|
// packages/personal-cli/src/ui-execute.ts
|
|
121
146
|
async function execute2(session, args, title) {
|
|
122
147
|
session.display.write(`
|
|
@@ -138,11 +163,13 @@ function successful(outcome) {
|
|
|
138
163
|
function showOutcome(session, outcome) {
|
|
139
164
|
const checks = reportChecks(outcome.data);
|
|
140
165
|
const incomplete = checks.filter((check) => ["warn", "skip"].includes(check.status)).length;
|
|
141
|
-
const state =
|
|
166
|
+
const state = outcomeState(outcome, incomplete);
|
|
142
167
|
const counts = ["pass", "warn", "fail", "skip"].map((status) => `${checks.filter((check) => check.status === status).length} ${status}`);
|
|
143
168
|
session.display.panel(state, checks.length ? counts.join(" · ") : outcomeMessage(outcome));
|
|
169
|
+
showAttention(session, checks);
|
|
170
|
+
showData(session, outcome);
|
|
144
171
|
if (!successful(outcome))
|
|
145
|
-
session.display.write("No automatic repair was attempted. Review the command, context and setup.");
|
|
172
|
+
session.display.write(record2(outcome.data) && outcome.data["applied"] === true ? "Actions were attempted. Review execution results, partial changes and fresh checks before retrying." : "No automatic repair was attempted. Review the command, context and setup.");
|
|
146
173
|
}
|
|
147
174
|
function outcomeMessage(outcome) {
|
|
148
175
|
if (!successful(outcome))
|
|
@@ -155,6 +182,8 @@ function outcomeMessage(outcome) {
|
|
|
155
182
|
function failureMessage(data) {
|
|
156
183
|
if (!record2(data))
|
|
157
184
|
return "Operation failed. Raw errors and subprocess output are intentionally hidden.";
|
|
185
|
+
if (data["code"] === "invalid_usage" && typeof data["error"] === "string")
|
|
186
|
+
return clean(data["error"]);
|
|
158
187
|
const messages = {
|
|
159
188
|
invalid_usage: "Configuration or arguments need attention. Use Fleet setup, validate, or session context.",
|
|
160
189
|
config_missing: "No roster found. Add a node in Fleet setup, or run controller-only diagnostics.",
|
|
@@ -162,7 +191,58 @@ function failureMessage(data) {
|
|
|
162
191
|
};
|
|
163
192
|
return messages[String(data["code"])] ?? "Operation did not complete. Review setup and the noninteractive command.";
|
|
164
193
|
}
|
|
194
|
+
function showNodes(session, data) {
|
|
195
|
+
const nodes = Array.isArray(data["nodes"]) ? data["nodes"].filter(record2) : [];
|
|
196
|
+
if (!nodes.length) {
|
|
197
|
+
session.display.write("No computers registered yet. Choose Fleet: Register a developer computer to add one, or Fleet: Discover Tailscale computers.");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
session.display.table(["Computer", "SSH", "Workspace"], nodes.map((node) => [
|
|
201
|
+
text(node["id"]),
|
|
202
|
+
text(node["ssh"]),
|
|
203
|
+
text(node["workspace"])
|
|
204
|
+
]));
|
|
205
|
+
}
|
|
206
|
+
function showComputers(session, data) {
|
|
207
|
+
const computers = Array.isArray(data["computers"]) ? data["computers"].filter(record2) : [];
|
|
208
|
+
session.display.table(["Computer", "OS", "Online"], computers.map((peer) => [
|
|
209
|
+
text(peer["dns"]),
|
|
210
|
+
text(peer["os"]),
|
|
211
|
+
peer["online"] === true ? "yes" : "no"
|
|
212
|
+
]));
|
|
213
|
+
session.display.write("Visible Tailscale computers only; mobile devices excluded. Register each remote computer with its existing SSH alias and checkout path before full health checks.");
|
|
214
|
+
}
|
|
215
|
+
function showAttention(session, checks) {
|
|
216
|
+
const attention = checks.filter((check) => check.status !== "pass");
|
|
217
|
+
if (!attention.length)
|
|
218
|
+
return;
|
|
219
|
+
session.display.table(["Computer", "Check", "Status / next step"], attention.slice(0, 15).map((check) => [
|
|
220
|
+
check.node,
|
|
221
|
+
check.id,
|
|
222
|
+
`${check.status}: ${check.remedy ?? check.summary}`
|
|
223
|
+
]));
|
|
224
|
+
if (attention.length > 15)
|
|
225
|
+
session.display.write(`Showing 15 of ${attention.length} checks needing attention. Explore checks and remedies for the complete report.`);
|
|
226
|
+
}
|
|
227
|
+
function showData(session, outcome) {
|
|
228
|
+
if (!record2(outcome.data))
|
|
229
|
+
return;
|
|
230
|
+
if (["repair", "repairs"].includes(String(outcome.data["command"])))
|
|
231
|
+
showRepairs(session, outcome.data);
|
|
232
|
+
if (successful(outcome) && outcome.data["command"] === "nodes")
|
|
233
|
+
showNodes(session, outcome.data);
|
|
234
|
+
if (outcome.data["command"] === "discover")
|
|
235
|
+
showComputers(session, outcome.data);
|
|
236
|
+
}
|
|
237
|
+
function outcomeState(outcome, incomplete) {
|
|
238
|
+
if (record2(outcome.data) && outcome.data["applied"] === false)
|
|
239
|
+
return "PREVIEW";
|
|
240
|
+
if (!successful(outcome))
|
|
241
|
+
return "FAILED";
|
|
242
|
+
return incomplete ? "WARNING" : "SUCCESS";
|
|
243
|
+
}
|
|
165
244
|
var init_ui_execute = __esm(() => {
|
|
245
|
+
init_ui_repairs();
|
|
166
246
|
init_ui_data();
|
|
167
247
|
});
|
|
168
248
|
|
|
@@ -343,9 +423,9 @@ async function chooseTargets(session, action) {
|
|
|
343
423
|
description: "No roster or SSH required; uses this session workspace."
|
|
344
424
|
},
|
|
345
425
|
{
|
|
346
|
-
name:
|
|
426
|
+
name: allTargetsName(action),
|
|
347
427
|
value: "all",
|
|
348
|
-
description: action
|
|
428
|
+
description: allTargetsDescription(action)
|
|
349
429
|
}
|
|
350
430
|
],
|
|
351
431
|
{ name: "Select roster nodes…", value: "selected" },
|
|
@@ -355,6 +435,8 @@ async function chooseTargets(session, action) {
|
|
|
355
435
|
return;
|
|
356
436
|
if (selection === "local")
|
|
357
437
|
return ["--local"];
|
|
438
|
+
if (selection === "all" && action === "repair")
|
|
439
|
+
return [];
|
|
358
440
|
const nodes = await availableNodes(session);
|
|
359
441
|
if (!nodes.length)
|
|
360
442
|
return missingRoster2(session, action);
|
|
@@ -402,6 +484,14 @@ async function missingRoster2(session, action) {
|
|
|
402
484
|
]);
|
|
403
485
|
return choice === "local" ? ["--local"] : undefined;
|
|
404
486
|
}
|
|
487
|
+
function allTargetsName(action) {
|
|
488
|
+
return action === "repair" ? "All visible Tailscale computers" : "All roster nodes";
|
|
489
|
+
}
|
|
490
|
+
function allTargetsDescription(action) {
|
|
491
|
+
if (action === "doctor")
|
|
492
|
+
return "Controller + every configured node; read-only.";
|
|
493
|
+
return action === "repair" ? "Inspect this controller and every visible computer; preview repairs before applying." : "Every configured remote checkout.";
|
|
494
|
+
}
|
|
405
495
|
var init_ui_targets = __esm(() => {
|
|
406
496
|
init_ui_execute();
|
|
407
497
|
init_ui_data();
|
|
@@ -413,7 +503,7 @@ async function runCommand2(session, command) {
|
|
|
413
503
|
const args = await actionArgs(session, command);
|
|
414
504
|
if (!args)
|
|
415
505
|
return;
|
|
416
|
-
const preview = args[0] === "fleet" && ["sync", "init"].includes(args[1] ?? "");
|
|
506
|
+
const preview = args[0] === "fleet" && ["sync", "init", "repair"].includes(args[1] ?? "");
|
|
417
507
|
if (args[1] === "init")
|
|
418
508
|
session.display.write("EXAMPLE ONLY: init previews a starter roster, not a configured fleet. Saving requires --apply --yes.");
|
|
419
509
|
const mutation = !preview && (command.mutation || args.includes("--apply"));
|
|
@@ -428,7 +518,7 @@ async function actionArgs(session, command) {
|
|
|
428
518
|
if (args.includes("--local"))
|
|
429
519
|
return args;
|
|
430
520
|
const action = args[1] ?? "";
|
|
431
|
-
if (["sync", "init"].includes(action)) {
|
|
521
|
+
if (["sync", "init", "repair"].includes(action)) {
|
|
432
522
|
const preview = args.filter((arg) => !["--apply", "--yes"].includes(arg));
|
|
433
523
|
if (action === "init")
|
|
434
524
|
return preview;
|
|
@@ -452,7 +542,7 @@ async function runWithResults(session, initialArgs, title, settings = {}) {
|
|
|
452
542
|
return;
|
|
453
543
|
await inspectSelection(session, outcome, selection);
|
|
454
544
|
if (selection === "apply") {
|
|
455
|
-
const appliedArgs =
|
|
545
|
+
const appliedArgs = applyArgs(args, outcome);
|
|
456
546
|
if (!await confirmMutation(session, appliedArgs))
|
|
457
547
|
continue;
|
|
458
548
|
args = appliedArgs;
|
|
@@ -475,16 +565,16 @@ async function inspectSelection(session, outcome, selection) {
|
|
|
475
565
|
}
|
|
476
566
|
function resultChoices(outcome, preview) {
|
|
477
567
|
return [
|
|
568
|
+
{ name: "Back to commands", value: "back" },
|
|
478
569
|
...reportChecks(outcome.data).length ? [{ name: "Explore checks and remedies…", value: "checks" }] : [],
|
|
479
570
|
{ name: "Inspect structured details / plan…", value: "details" },
|
|
480
571
|
...preview && successful(outcome) ? [{ name: "Review and explicitly apply…", value: "apply" }] : [],
|
|
481
|
-
...!successful(outcome) ? [{ name: "Retry command…", value: "retry" }] : []
|
|
482
|
-
{ name: "Back to palette", value: "back" }
|
|
572
|
+
...!successful(outcome) ? [{ name: "Retry command…", value: "retry" }] : []
|
|
483
573
|
];
|
|
484
574
|
}
|
|
485
575
|
async function confirmMutation(session, args) {
|
|
486
576
|
const action = args[1];
|
|
487
|
-
const warning = action === "sync" ? "Sync runs trusted checkout lifecycle scripts and installs tools/dependencies. Failure can leave partial changes; remote installers may continue after disconnect. Inspect the node before retrying. No automatic repairs or credentials." : "This writes the local roster only. No cloud resources, SSH configuration, credentials or host keys are changed.";
|
|
577
|
+
const warning = action === "repair" ? "Repair runs the eligible owner actions in the displayed plan, including trusted setup/install scripts. Workstation actions may read this machine’s Vault session and write managed 0600 provider configuration. No login session is transferred between computers. Failures can leave partial changes; fresh diagnostics follow." : action === "sync" ? "Sync runs trusted checkout lifecycle scripts and installs tools/dependencies. Failure can leave partial changes; remote installers may continue after disconnect. Inspect the node before retrying. No automatic repairs or credentials." : "This writes the local roster only. No cloud resources, SSH configuration, credentials or host keys are changed.";
|
|
488
578
|
session.display.panel("Explicit approval required", [
|
|
489
579
|
action === "init" ? "Create an EXAMPLE roster only. Replace sample aliases and paths before diagnostics." : warning,
|
|
490
580
|
`Workspace: ${clean(session.workspace ?? session.options.workspace)}`,
|
|
@@ -494,6 +584,10 @@ async function confirmMutation(session, args) {
|
|
|
494
584
|
`));
|
|
495
585
|
return session.prompts.confirm("Apply this exact command? (default: no)");
|
|
496
586
|
}
|
|
587
|
+
function applyArgs(args, outcome) {
|
|
588
|
+
const guard = args[1] === "repair" && record2(outcome.data) && typeof outcome.data["planHash"] === "string" ? ["--plan-hash", outcome.data["planHash"]] : [];
|
|
589
|
+
return [...args, ...guard, "--apply", "--yes"];
|
|
590
|
+
}
|
|
497
591
|
var init_ui_actions = __esm(() => {
|
|
498
592
|
init_ui_execute();
|
|
499
593
|
init_ui_data();
|
|
@@ -604,64 +698,24 @@ function paletteChoices(session, term) {
|
|
|
604
698
|
const commands = commandChoices(session.options.commands, term);
|
|
605
699
|
const navigation = [
|
|
606
700
|
{
|
|
607
|
-
name: "
|
|
608
|
-
value: "setup",
|
|
609
|
-
description: "Register an existing node; preview + explicit approval before saving."
|
|
610
|
-
},
|
|
611
|
-
{
|
|
612
|
-
name: "Session workspace / config…",
|
|
701
|
+
name: "Session: Workspace / config…",
|
|
613
702
|
value: "context",
|
|
614
703
|
description: "Session-only overrides; no hidden configuration writes."
|
|
615
|
-
}
|
|
704
|
+
},
|
|
705
|
+
{ name: "Exit", value: "exit", description: "Close the command palette." }
|
|
616
706
|
].filter((choice) => `${choice.name} ${choice.description}`.toLowerCase().includes(term.toLowerCase().trim()));
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
].map((group) => ({
|
|
620
|
-
name: `Browse ${clean(group)}…`,
|
|
621
|
-
value: `group:${group}`
|
|
622
|
-
}));
|
|
623
|
-
return [
|
|
624
|
-
...groups,
|
|
625
|
-
...navigation,
|
|
626
|
-
...commands,
|
|
627
|
-
...!commands.length && !navigation.length && term.trim() ? [
|
|
628
|
-
{
|
|
629
|
-
name: "No matching commands — change search or go back",
|
|
630
|
-
value: "none"
|
|
631
|
-
}
|
|
632
|
-
] : [],
|
|
633
|
-
{ name: "Exit", value: "exit" }
|
|
634
|
-
];
|
|
707
|
+
const choices = [...navigation, ...commands].sort((a, b) => a.name.localeCompare(b.name, "en"));
|
|
708
|
+
return choices.length ? choices : [{ name: "No matching commands — change search", value: "none" }];
|
|
635
709
|
}
|
|
636
710
|
async function dispatch(session, choice) {
|
|
637
711
|
if (choice === "exit")
|
|
638
712
|
return false;
|
|
639
|
-
if (choice === "setup")
|
|
640
|
-
await addNode2(session);
|
|
641
713
|
if (choice === "context")
|
|
642
714
|
await changeContext(session);
|
|
643
|
-
if (choice.startsWith("group:"))
|
|
644
|
-
await browseGroup(session, choice.slice(6));
|
|
645
715
|
if (choice.startsWith("command:"))
|
|
646
716
|
await openCommand(session, choice.slice(8));
|
|
647
717
|
return true;
|
|
648
718
|
}
|
|
649
|
-
async function browseGroup(session, group) {
|
|
650
|
-
const commands = session.options.commands.filter((command) => command.group === group);
|
|
651
|
-
while (true) {
|
|
652
|
-
const choice = await session.prompts.search(`${clean(group)} · searchable submenu`, (term) => {
|
|
653
|
-
const choices = commandChoices(commands, term);
|
|
654
|
-
return [
|
|
655
|
-
...choices.length ? choices : [{ name: "No matches — try another keyword", value: "none" }],
|
|
656
|
-
{ name: "Back to all commands", value: "back" }
|
|
657
|
-
];
|
|
658
|
-
});
|
|
659
|
-
if (choice === "back")
|
|
660
|
-
return;
|
|
661
|
-
if (choice.startsWith("command:"))
|
|
662
|
-
await openCommand(session, choice.slice(8));
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
719
|
async function openCommand(session, id) {
|
|
666
720
|
const command = session.options.commands.find((entry) => entry.id === id);
|
|
667
721
|
if (!command)
|
|
@@ -796,9 +850,149 @@ var init_ui = __esm(() => {
|
|
|
796
850
|
init_ui_terminal();
|
|
797
851
|
});
|
|
798
852
|
|
|
853
|
+
// packages/personal-cli/src/workstation.ts
|
|
854
|
+
import { join } from "node:path";
|
|
855
|
+
|
|
856
|
+
// packages/fleet/src/runner.ts
|
|
857
|
+
import { spawn } from "node:child_process";
|
|
858
|
+
var MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
859
|
+
function terminate(child) {
|
|
860
|
+
try {
|
|
861
|
+
if (process.platform !== "win32" && child.pid)
|
|
862
|
+
process.kill(-child.pid, "SIGKILL");
|
|
863
|
+
else
|
|
864
|
+
child.kill("SIGKILL");
|
|
865
|
+
} catch {}
|
|
866
|
+
}
|
|
867
|
+
function runCommand(command, options = {}) {
|
|
868
|
+
return new Promise((resolve) => {
|
|
869
|
+
const child = spawn(command[0], command.slice(1), {
|
|
870
|
+
cwd: options.cwd,
|
|
871
|
+
env: {
|
|
872
|
+
...process.env,
|
|
873
|
+
...options.env,
|
|
874
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
875
|
+
CI: "1"
|
|
876
|
+
},
|
|
877
|
+
stdio: "pipe",
|
|
878
|
+
windowsHide: true,
|
|
879
|
+
detached: process.platform !== "win32"
|
|
880
|
+
});
|
|
881
|
+
let stdout = "";
|
|
882
|
+
let stderr = "";
|
|
883
|
+
let bytes = 0;
|
|
884
|
+
let finished = false;
|
|
885
|
+
let reason = "exit";
|
|
886
|
+
const finish = (ok) => {
|
|
887
|
+
if (finished)
|
|
888
|
+
return;
|
|
889
|
+
finished = true;
|
|
890
|
+
clearTimeout(timer);
|
|
891
|
+
child.stdin.destroy();
|
|
892
|
+
child.stdout.destroy();
|
|
893
|
+
child.stderr.destroy();
|
|
894
|
+
resolve({ ok, stdout, stderr, reason });
|
|
895
|
+
};
|
|
896
|
+
const abort = (cause) => {
|
|
897
|
+
reason = cause;
|
|
898
|
+
terminate(child);
|
|
899
|
+
finish(false);
|
|
900
|
+
};
|
|
901
|
+
const timer = setTimeout(() => {
|
|
902
|
+
abort("timeout");
|
|
903
|
+
}, options.timeoutMs ?? 15000);
|
|
904
|
+
const capture = (stream, chunk) => {
|
|
905
|
+
if (finished)
|
|
906
|
+
return;
|
|
907
|
+
bytes += Buffer.byteLength(chunk);
|
|
908
|
+
if (bytes > MAX_OUTPUT_BYTES) {
|
|
909
|
+
abort("overflow");
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
if (stream === "stdout")
|
|
913
|
+
stdout += chunk;
|
|
914
|
+
else
|
|
915
|
+
stderr += chunk;
|
|
916
|
+
};
|
|
917
|
+
child.stdout.setEncoding("utf8").on("data", (chunk) => capture("stdout", chunk));
|
|
918
|
+
child.stderr.setEncoding("utf8").on("data", (chunk) => capture("stderr", chunk));
|
|
919
|
+
child.stdin.on("error", () => {
|
|
920
|
+
return;
|
|
921
|
+
});
|
|
922
|
+
child.on("error", () => {
|
|
923
|
+
abort("missing");
|
|
924
|
+
});
|
|
925
|
+
child.on("close", (code) => {
|
|
926
|
+
finish(code === 0 && reason === "exit");
|
|
927
|
+
});
|
|
928
|
+
child.stdin.end(options.input);
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// packages/personal-cli/src/workstation.ts
|
|
933
|
+
async function workstationCommand(args, settings) {
|
|
934
|
+
const help = args.length === 0 || args.includes("--help");
|
|
935
|
+
const result = await runCommand([
|
|
936
|
+
"bun",
|
|
937
|
+
join(settings.workspace, "packages/workstation/cli/internal.ts"),
|
|
938
|
+
...help ? [...args, "--help"] : [...args, "--json", "--non-interactive"]
|
|
939
|
+
], { cwd: settings.workspace, timeoutMs: 1200000 });
|
|
940
|
+
if (help && result.ok)
|
|
941
|
+
return {
|
|
942
|
+
exitCode: 0,
|
|
943
|
+
data: { version: 1, help: result.stdout },
|
|
944
|
+
text: result.stdout
|
|
945
|
+
};
|
|
946
|
+
if (result.ok) {
|
|
947
|
+
try {
|
|
948
|
+
return { exitCode: 0, data: JSON.parse(result.stdout) };
|
|
949
|
+
} catch {}
|
|
950
|
+
}
|
|
951
|
+
return {
|
|
952
|
+
exitCode: 1,
|
|
953
|
+
data: {
|
|
954
|
+
version: 1,
|
|
955
|
+
ok: false,
|
|
956
|
+
error: "Workstation operation failed. Check the personal checkout, Bun, conflicts and per-machine authentication."
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
async function forwardWorkstation(args, settings) {
|
|
961
|
+
const { spawn } = await import("node:child_process");
|
|
962
|
+
const childArgs = [];
|
|
963
|
+
let commandSeen = false;
|
|
964
|
+
for (let index = 0;index < args.length; index++) {
|
|
965
|
+
if (["--workspace", "--config"].includes(args[index])) {
|
|
966
|
+
index++;
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
if (args[index] === "workstation" && !commandSeen) {
|
|
970
|
+
commandSeen = true;
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
childArgs.push(args[index]);
|
|
974
|
+
}
|
|
975
|
+
return new Promise((resolve) => {
|
|
976
|
+
const child = spawn("bun", [
|
|
977
|
+
join(settings.workspace, "packages/workstation/cli/internal.ts"),
|
|
978
|
+
...childArgs.length ? childArgs : ["--help"]
|
|
979
|
+
], {
|
|
980
|
+
cwd: settings.workspace,
|
|
981
|
+
stdio: "inherit",
|
|
982
|
+
env: process.env
|
|
983
|
+
});
|
|
984
|
+
child.on("error", () => {
|
|
985
|
+
process.stderr.write(`Workstation requires the personal checkout and shared Bun toolchain.
|
|
986
|
+
`);
|
|
987
|
+
resolve(1);
|
|
988
|
+
});
|
|
989
|
+
child.on("exit", (code) => resolve(code ?? 1));
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
|
|
799
993
|
// packages/personal-cli/src/settings.ts
|
|
800
994
|
import { access } from "node:fs/promises";
|
|
801
|
-
import { dirname, join, resolve } from "node:path";
|
|
995
|
+
import { dirname, join as join2, resolve } from "node:path";
|
|
802
996
|
import { homedir } from "node:os";
|
|
803
997
|
async function exists(path) {
|
|
804
998
|
try {
|
|
@@ -811,8 +1005,8 @@ async function exists(path) {
|
|
|
811
1005
|
async function findUp(start, name) {
|
|
812
1006
|
let current = resolve(start);
|
|
813
1007
|
while (true) {
|
|
814
|
-
if (await exists(
|
|
815
|
-
return
|
|
1008
|
+
if (await exists(join2(current, name)))
|
|
1009
|
+
return join2(current, name);
|
|
816
1010
|
const parent = dirname(current);
|
|
817
1011
|
if (parent === current)
|
|
818
1012
|
return null;
|
|
@@ -820,11 +1014,378 @@ async function findUp(start, name) {
|
|
|
820
1014
|
}
|
|
821
1015
|
}
|
|
822
1016
|
async function resolveSettings(overrides = {}, cwd = process.cwd(), env = process.env) {
|
|
823
|
-
const workspace = resolve(overrides.workspace ?? env["PERSONAL_WORKSPACE"] ?? dirname(await findUp(cwd, "mise.toml") ??
|
|
824
|
-
const config = resolve(overrides.config ?? env["PERSONAL_FLEET_CONFIG"] ?? await findUp(cwd, ".fleet.local.json") ??
|
|
1017
|
+
const workspace = resolve(overrides.workspace ?? env["PERSONAL_WORKSPACE"] ?? dirname(await findUp(cwd, "mise.toml") ?? join2(cwd, "mise.toml")));
|
|
1018
|
+
const config = resolve(overrides.config ?? env["PERSONAL_FLEET_CONFIG"] ?? await findUp(cwd, ".fleet.local.json") ?? join2(homedir(), ".config/personal/fleet.json"));
|
|
825
1019
|
return { workspace, config };
|
|
826
1020
|
}
|
|
827
1021
|
|
|
1022
|
+
// packages/personal-cli/src/node-deployment.ts
|
|
1023
|
+
import { join as join3 } from "node:path";
|
|
1024
|
+
|
|
1025
|
+
// packages/personal-cli/src/t3/files.ts
|
|
1026
|
+
import {
|
|
1027
|
+
lstat,
|
|
1028
|
+
mkdir,
|
|
1029
|
+
readFile,
|
|
1030
|
+
rename,
|
|
1031
|
+
rm,
|
|
1032
|
+
writeFile
|
|
1033
|
+
} from "node:fs/promises";
|
|
1034
|
+
import { dirname as dirname2, parse } from "node:path";
|
|
1035
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
1036
|
+
|
|
1037
|
+
// packages/fleet/src/errors.ts
|
|
1038
|
+
class FleetUsageError extends Error {
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// packages/personal-cli/src/t3/files.ts
|
|
1042
|
+
var hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
1043
|
+
async function safePath(path) {
|
|
1044
|
+
for (let current = path;current !== parse(current).root; current = dirname2(current)) {
|
|
1045
|
+
try {
|
|
1046
|
+
if ((await lstat(current)).isSymbolicLink())
|
|
1047
|
+
throw new FleetUsageError("Managed T3 paths cannot be symlinks.");
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
if (error.code !== "ENOENT")
|
|
1050
|
+
throw error;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
async function read(path) {
|
|
1055
|
+
await safePath(path);
|
|
1056
|
+
try {
|
|
1057
|
+
const info = await lstat(path);
|
|
1058
|
+
if (!info.isFile() || info.size > 4 * 1024 * 1024)
|
|
1059
|
+
throw new FleetUsageError("T3 configuration must be a regular file smaller than 4 MiB.");
|
|
1060
|
+
return await readFile(path, "utf8");
|
|
1061
|
+
} catch (error) {
|
|
1062
|
+
if (error.code === "ENOENT")
|
|
1063
|
+
return null;
|
|
1064
|
+
throw error;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
async function write(path, contents) {
|
|
1068
|
+
await safePath(path);
|
|
1069
|
+
await mkdir(dirname2(path), { recursive: true, mode: 448 });
|
|
1070
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
1071
|
+
try {
|
|
1072
|
+
await writeFile(temporary, contents, { mode: 384, flag: "wx" });
|
|
1073
|
+
await rename(temporary, path);
|
|
1074
|
+
} finally {
|
|
1075
|
+
await rm(temporary, { force: true });
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
function object(raw) {
|
|
1079
|
+
if (raw === null)
|
|
1080
|
+
return {};
|
|
1081
|
+
try {
|
|
1082
|
+
const value = JSON.parse(raw);
|
|
1083
|
+
if (value && typeof value === "object" && !Array.isArray(value))
|
|
1084
|
+
return value;
|
|
1085
|
+
} catch {}
|
|
1086
|
+
throw new FleetUsageError("Invalid T3 JSON object. Repair the file before reconciliation.");
|
|
1087
|
+
}
|
|
1088
|
+
function canonical(value) {
|
|
1089
|
+
if (Array.isArray(value))
|
|
1090
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
1091
|
+
if (value && typeof value === "object") {
|
|
1092
|
+
return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`;
|
|
1093
|
+
}
|
|
1094
|
+
return JSON.stringify(value) ?? "null";
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// packages/personal-cli/src/node-deployment.ts
|
|
1098
|
+
async function deployment(root) {
|
|
1099
|
+
const raw = await read(join3(root, "node_modules/.personal-node.json"));
|
|
1100
|
+
if (raw === null)
|
|
1101
|
+
return null;
|
|
1102
|
+
const data = object(raw);
|
|
1103
|
+
if (data["version"] !== 1 || typeof data["revision"] !== "string" || !/^[a-f0-9]{40}$/.test(data["revision"]) || typeof data["node"] !== "string" || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(data["node"]))
|
|
1104
|
+
throw new FleetUsageError("Invalid local node receipt. Restore it before reconciling.");
|
|
1105
|
+
return {
|
|
1106
|
+
workspace: join3(root, "node_modules/.fleet-revisions", data["revision"]),
|
|
1107
|
+
node: data["node"],
|
|
1108
|
+
revision: data["revision"]
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
async function recordDeployment(root, node, revision) {
|
|
1112
|
+
const path = join3(root, "node_modules/.personal-node.json");
|
|
1113
|
+
const content = JSON.stringify({ version: 1, node, revision }) + `
|
|
1114
|
+
`;
|
|
1115
|
+
if (await read(path) !== content)
|
|
1116
|
+
await write(path, content);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// packages/personal-cli/src/t3/desired.ts
|
|
1120
|
+
import { join as join4, resolve as resolve2 } from "node:path";
|
|
1121
|
+
import { homedir as homedir2, hostname } from "node:os";
|
|
1122
|
+
function nodeId(value) {
|
|
1123
|
+
const id = value ?? hostname().toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 63);
|
|
1124
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(id))
|
|
1125
|
+
throw new FleetUsageError("Invalid node ID. Use --id NAME.");
|
|
1126
|
+
return id;
|
|
1127
|
+
}
|
|
1128
|
+
function paths(options, workspace) {
|
|
1129
|
+
const base = resolve2(options.t3Home ?? process.env["T3CODE_HOME"] ?? join4(homedir2(), ".t3"));
|
|
1130
|
+
return {
|
|
1131
|
+
settings: join4(base, "userdata/settings.json"),
|
|
1132
|
+
state: join4(base, "userdata/personal-managed.json"),
|
|
1133
|
+
definition: resolve2(options.definition ?? join4(workspace, "packages/workstation/agents/t3.json"))
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
async function desired(path, workspace, id) {
|
|
1137
|
+
const raw = await read(path);
|
|
1138
|
+
if (raw === null)
|
|
1139
|
+
throw new FleetUsageError("T3 definition is missing in this checkout.");
|
|
1140
|
+
const doc = object(raw);
|
|
1141
|
+
if (doc["version"] !== 1 || Object.keys(doc).some((key) => !["version", "instances"].includes(key)))
|
|
1142
|
+
throw new FleetUsageError("Unsupported T3 definition. Expected version 1 and instances.");
|
|
1143
|
+
const instances = object(JSON.stringify(doc["instances"] ?? null));
|
|
1144
|
+
for (const [key, value] of Object.entries(instances))
|
|
1145
|
+
validateInstance(key, value);
|
|
1146
|
+
const agents = process.platform === "win32" ? join4(homedir2(), "AppData/Local/personal/agents") : join4(homedir2(), ".config/personal/agents");
|
|
1147
|
+
const variables = {
|
|
1148
|
+
workspace: resolve2(workspace),
|
|
1149
|
+
agents,
|
|
1150
|
+
node: id
|
|
1151
|
+
};
|
|
1152
|
+
return expand(instances, variables);
|
|
1153
|
+
}
|
|
1154
|
+
function expand(value, variables) {
|
|
1155
|
+
if (typeof value === "string")
|
|
1156
|
+
return value.replace(/\$\{([^}]+)\}/g, (_, key) => {
|
|
1157
|
+
if (!(key in variables))
|
|
1158
|
+
throw new FleetUsageError("Unknown variable in T3 definition.");
|
|
1159
|
+
return variables[key];
|
|
1160
|
+
});
|
|
1161
|
+
if (Array.isArray(value))
|
|
1162
|
+
return value.map((item) => expand(item, variables));
|
|
1163
|
+
if (value && typeof value === "object")
|
|
1164
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, expand(item, variables)]));
|
|
1165
|
+
return value;
|
|
1166
|
+
}
|
|
1167
|
+
function validateInstance(id, value) {
|
|
1168
|
+
const instance = object(JSON.stringify(value));
|
|
1169
|
+
const config = object(JSON.stringify(instance["config"]));
|
|
1170
|
+
const env = instance["environment"];
|
|
1171
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/.test(id) || instance["driver"] !== "codex" || typeof instance["displayName"] !== "string" || typeof instance["enabled"] !== "boolean" || Object.keys(instance).some((key) => !["driver", "displayName", "enabled", "config", "environment"].includes(key)) || !validConfig(config) || !validEnvironment(env))
|
|
1172
|
+
throw new FleetUsageError("Invalid T3 instance definition; only the supported Codex adapter fields are accepted.");
|
|
1173
|
+
}
|
|
1174
|
+
function validConfig(config) {
|
|
1175
|
+
return Object.keys(config).every((key) => ["binaryPath", "homePath"].includes(key)) && typeof config["binaryPath"] === "string" && typeof config["homePath"] === "string";
|
|
1176
|
+
}
|
|
1177
|
+
function validEnvironment(env) {
|
|
1178
|
+
if (!Array.isArray(env) || env.length !== 1)
|
|
1179
|
+
return false;
|
|
1180
|
+
const variable = object(JSON.stringify(env[0]));
|
|
1181
|
+
return variable["name"] === "WS_AGENT_NODE" && variable["value"] === "${node}" && variable["sensitive"] === false && Object.keys(variable).length === 3;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// packages/personal-cli/src/t3/reconcile.ts
|
|
1185
|
+
import { mkdir as mkdir2, rm as rm2 } from "node:fs/promises";
|
|
1186
|
+
import { dirname as dirname3 } from "node:path";
|
|
1187
|
+
|
|
1188
|
+
// packages/personal-cli/src/t3/plan.ts
|
|
1189
|
+
async function planT3(paths, desired) {
|
|
1190
|
+
const raw = await read(paths.settings);
|
|
1191
|
+
const stateRaw = await read(paths.state);
|
|
1192
|
+
const settings = object(raw);
|
|
1193
|
+
const current = object(JSON.stringify(settings["providerInstances"] ?? {}));
|
|
1194
|
+
const owned = readOwnership(stateRaw, paths.settings);
|
|
1195
|
+
const next = { ...current };
|
|
1196
|
+
const changes = [];
|
|
1197
|
+
for (const [id, instance] of Object.entries(desired)) {
|
|
1198
|
+
const exists = Object.hasOwn(current, id);
|
|
1199
|
+
const action = actionFor(exists, owned.includes(id), current[id], instance);
|
|
1200
|
+
changes.push({ id, action });
|
|
1201
|
+
if (action !== "conflict")
|
|
1202
|
+
next[id] = instance;
|
|
1203
|
+
}
|
|
1204
|
+
for (const id of owned.filter((id) => !Object.hasOwn(desired, id))) {
|
|
1205
|
+
changes.push({
|
|
1206
|
+
id,
|
|
1207
|
+
action: Object.hasOwn(current, id) ? "delete" : "unchanged"
|
|
1208
|
+
});
|
|
1209
|
+
delete next[id];
|
|
1210
|
+
}
|
|
1211
|
+
const settingsChanged = canonical(next) !== canonical(current);
|
|
1212
|
+
const state = ownership(paths.settings, Object.keys(desired));
|
|
1213
|
+
return {
|
|
1214
|
+
raw,
|
|
1215
|
+
stateRaw,
|
|
1216
|
+
settings: settingsChanged ? { ...settings, providerInstances: next } : settings,
|
|
1217
|
+
state,
|
|
1218
|
+
settingsChanged,
|
|
1219
|
+
stateChanged: canonical(object(stateRaw)) !== canonical(state),
|
|
1220
|
+
changes,
|
|
1221
|
+
planHash: hash(canonical({ target: paths.settings, raw, stateRaw, desired }))
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
function ownership(target, ids) {
|
|
1225
|
+
return { version: 1, target, instances: [...ids].sort() };
|
|
1226
|
+
}
|
|
1227
|
+
function readOwnership(raw, target) {
|
|
1228
|
+
if (raw === null)
|
|
1229
|
+
return [];
|
|
1230
|
+
const state = object(raw);
|
|
1231
|
+
const ids = state["instances"];
|
|
1232
|
+
if (state["version"] !== 1 || state["target"] !== target || !Array.isArray(ids) || ids.some((id) => typeof id !== "string" || !/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/.test(id)) || new Set(ids).size !== ids.length)
|
|
1233
|
+
throw new FleetUsageError("Invalid T3 ownership state; restore its backup before reconciliation.");
|
|
1234
|
+
return ids;
|
|
1235
|
+
}
|
|
1236
|
+
function actionFor(exists, owned, current, desired) {
|
|
1237
|
+
if (exists && !owned)
|
|
1238
|
+
return "conflict";
|
|
1239
|
+
if (!exists)
|
|
1240
|
+
return "create";
|
|
1241
|
+
return canonical(current) === canonical(desired) ? "unchanged" : "update";
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// packages/personal-cli/src/t3/reconcile.ts
|
|
1245
|
+
async function reconcileT3(paths, desired, options) {
|
|
1246
|
+
if (!options.apply)
|
|
1247
|
+
return report(await planT3(paths, desired), false);
|
|
1248
|
+
return locked(paths, async () => {
|
|
1249
|
+
const plan = await planT3(paths, desired);
|
|
1250
|
+
if (options.planHash && options.planHash !== plan.planHash)
|
|
1251
|
+
throw new FleetUsageError("T3 plan is stale. Run personal t3-plan again.");
|
|
1252
|
+
if (plan.changes.some((change) => change.action === "conflict"))
|
|
1253
|
+
throw new FleetUsageError("T3 instance already exists without ownership. Review it and use personal t3-import --instance ID.");
|
|
1254
|
+
if (!plan.settingsChanged && !plan.stateChanged)
|
|
1255
|
+
return report(plan, true);
|
|
1256
|
+
await persist(paths, desired, plan);
|
|
1257
|
+
const verified = await planT3(paths, desired);
|
|
1258
|
+
if (verified.settingsChanged || verified.stateChanged)
|
|
1259
|
+
throw new FleetUsageError("T3 changed after apply; reconciliation is incomplete. Replan and retry.");
|
|
1260
|
+
return report(plan, true);
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
async function importT3(paths, id) {
|
|
1264
|
+
return locked(paths, async () => {
|
|
1265
|
+
const raw = await read(paths.settings);
|
|
1266
|
+
const instances = object(JSON.stringify(object(raw)["providerInstances"] ?? {}));
|
|
1267
|
+
if (!Object.hasOwn(instances, id))
|
|
1268
|
+
throw new FleetUsageError("T3 import requires an existing instance ID.");
|
|
1269
|
+
const state = await read(paths.state);
|
|
1270
|
+
const owned = readOwnership(state, paths.settings);
|
|
1271
|
+
if (!owned.includes(id)) {
|
|
1272
|
+
if (state !== null)
|
|
1273
|
+
await write(`${paths.state}.backup`, state);
|
|
1274
|
+
if (await read(paths.settings) !== raw)
|
|
1275
|
+
throw new FleetUsageError("T3 changed during import; retry.");
|
|
1276
|
+
await write(paths.state, canonical(ownership(paths.settings, [...owned, id])) + `
|
|
1277
|
+
`);
|
|
1278
|
+
}
|
|
1279
|
+
return { version: 1, ok: true, imported: id, changed: !owned.includes(id) };
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
async function locked(paths, action) {
|
|
1283
|
+
await safePath(paths.state);
|
|
1284
|
+
await mkdir2(dirname3(paths.state), { recursive: true, mode: 448 });
|
|
1285
|
+
const lock = `${paths.state}.lock`;
|
|
1286
|
+
try {
|
|
1287
|
+
await mkdir2(lock, { mode: 448 });
|
|
1288
|
+
} catch {
|
|
1289
|
+
throw new FleetUsageError("Another personal T3 operation holds the lock. Inspect interrupted operations before removing a stale lock.");
|
|
1290
|
+
}
|
|
1291
|
+
try {
|
|
1292
|
+
return await action();
|
|
1293
|
+
} finally {
|
|
1294
|
+
await rm2(lock, { recursive: true });
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
function report(plan, applied) {
|
|
1298
|
+
return {
|
|
1299
|
+
version: 1,
|
|
1300
|
+
ok: !plan.changes.some((change) => change.action === "conflict"),
|
|
1301
|
+
applied,
|
|
1302
|
+
changed: plan.settingsChanged || plan.stateChanged,
|
|
1303
|
+
planHash: plan.planHash,
|
|
1304
|
+
changes: plan.changes,
|
|
1305
|
+
runtime: "T3 watches settings.json; new sessions use the reconciled instances. Active sessions are not restarted."
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
async function persist(paths, desired, plan) {
|
|
1309
|
+
if (plan.raw !== null && plan.settingsChanged)
|
|
1310
|
+
await write(`${paths.state}.settings-backup`, plan.raw);
|
|
1311
|
+
if (plan.stateRaw !== null)
|
|
1312
|
+
await write(`${paths.state}.backup`, plan.stateRaw);
|
|
1313
|
+
if (await read(paths.settings) !== plan.raw || await read(paths.state) !== plan.stateRaw)
|
|
1314
|
+
throw new FleetUsageError("T3 configuration changed during apply. Replan and retry.");
|
|
1315
|
+
const previous = readOwnership(plan.stateRaw, paths.settings);
|
|
1316
|
+
await write(paths.state, canonical(ownership(paths.settings, [
|
|
1317
|
+
...new Set([...previous, ...Object.keys(desired)])
|
|
1318
|
+
])) + `
|
|
1319
|
+
`);
|
|
1320
|
+
if (plan.settingsChanged) {
|
|
1321
|
+
if (await read(paths.settings) !== plan.raw)
|
|
1322
|
+
throw new FleetUsageError("T3 changed during apply. Ownership intent was saved; replan and retry.");
|
|
1323
|
+
await write(paths.settings, JSON.stringify(plan.settings, null, 2) + `
|
|
1324
|
+
`);
|
|
1325
|
+
}
|
|
1326
|
+
await write(paths.state, canonical(plan.state) + `
|
|
1327
|
+
`);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// packages/personal-cli/src/t3/command.ts
|
|
1331
|
+
async function t3Command(command, options, settings) {
|
|
1332
|
+
const deployed = options.definition ? null : await deployment(settings.workspace);
|
|
1333
|
+
const workspace = deployed?.workspace ?? settings.workspace;
|
|
1334
|
+
const target = paths(options, workspace);
|
|
1335
|
+
if (command === "t3-import")
|
|
1336
|
+
return importCommand(target, options);
|
|
1337
|
+
const instances = command === "t3-cleanup" ? {} : await desired(target.definition, workspace, nodeId(options.id ?? deployed?.node));
|
|
1338
|
+
const apply = shouldApply(command, options);
|
|
1339
|
+
if (apply)
|
|
1340
|
+
requireSupportedPlatform(instances);
|
|
1341
|
+
const data = await reconcileT3(target, instances, {
|
|
1342
|
+
apply,
|
|
1343
|
+
...options.planHash ? { planHash: options.planHash } : {}
|
|
1344
|
+
});
|
|
1345
|
+
return {
|
|
1346
|
+
exitCode: outcomeCode(command, data),
|
|
1347
|
+
data: { command, ...data }
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
async function importCommand(target, options) {
|
|
1351
|
+
if (options.dryRun)
|
|
1352
|
+
throw new FleetUsageError("t3-import is an explicit ownership operation; inspect t3-plan first.");
|
|
1353
|
+
if (!options.instance || !/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/.test(options.instance))
|
|
1354
|
+
throw new FleetUsageError("t3-import requires --instance ID. Import authorizes management of that entire provider instance.");
|
|
1355
|
+
return { exitCode: 0, data: await importT3(target, options.instance) };
|
|
1356
|
+
}
|
|
1357
|
+
function shouldApply(command, options) {
|
|
1358
|
+
return ["t3-apply", "t3-cleanup"].includes(command) && !options.dryRun;
|
|
1359
|
+
}
|
|
1360
|
+
function outcomeCode(command, data) {
|
|
1361
|
+
return !data.ok || command === "t3-status" && data.changed ? 1 : 0;
|
|
1362
|
+
}
|
|
1363
|
+
function requireSupportedPlatform(instances) {
|
|
1364
|
+
if (process.platform === "win32" && Object.keys(instances).length)
|
|
1365
|
+
throw new FleetUsageError("Managed Codex proxy execution requires macOS/Linux/WSL; native Windows settings were preserved.");
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// packages/personal-cli/src/t3/register.ts
|
|
1369
|
+
var T3_COMMANDS = [
|
|
1370
|
+
["t3-plan", "Preview creates, updates and owned removals"],
|
|
1371
|
+
["t3-apply", "Reconcile declared T3 provider instances"],
|
|
1372
|
+
["t3-status", "Check managed T3 configuration for drift"],
|
|
1373
|
+
[
|
|
1374
|
+
"t3-cleanup",
|
|
1375
|
+
"Remove only provider instances recorded in personal ownership state"
|
|
1376
|
+
],
|
|
1377
|
+
["t3-import", "Adopt an existing provider instance into personal ownership"]
|
|
1378
|
+
];
|
|
1379
|
+
function registerT3(program, defaults, complete) {
|
|
1380
|
+
for (const [name, description] of T3_COMMANDS) {
|
|
1381
|
+
program.command(name).description(description).option("--id <name>", "Node ID; defaults to hostname").option("--t3-home <path>", "T3 base directory; defaults to T3CODE_HOME or ~/.t3").option("--definition <path>", "Version 1 declarative T3 provider definition").option("--plan-hash <sha256>", "Reject any changes since the reviewed plan").option("--dry-run", "Preview without writing configuration or state").option("--instance <id>", "Existing instance to adopt with t3-import").action(async (_options, command) => {
|
|
1382
|
+
const options = command.optsWithGlobals();
|
|
1383
|
+
const settings = await resolveSettings({ ...defaults, ...options });
|
|
1384
|
+
complete(await t3Command(name, options, settings));
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
828
1389
|
// packages/personal-cli/src/registry.ts
|
|
829
1390
|
init_search();
|
|
830
1391
|
function fleet(command, title, description, keywords) {
|
|
@@ -836,10 +1397,79 @@ function fleet(command, title, description, keywords) {
|
|
|
836
1397
|
keywords,
|
|
837
1398
|
args: ["fleet", command],
|
|
838
1399
|
examples: [`personal fleet ${command} --json`],
|
|
839
|
-
mutation: ["add", "sync", "init"].includes(command)
|
|
1400
|
+
mutation: ["add", "sync", "init", "repair"].includes(command)
|
|
840
1401
|
};
|
|
841
1402
|
}
|
|
842
1403
|
var COMMANDS = [
|
|
1404
|
+
...T3_COMMANDS.map(([id, description]) => ({
|
|
1405
|
+
id,
|
|
1406
|
+
title: description,
|
|
1407
|
+
description,
|
|
1408
|
+
group: "T3",
|
|
1409
|
+
keywords: ["t3", "configuration", "terraform", "drift"],
|
|
1410
|
+
args: [id],
|
|
1411
|
+
examples: [`personal ${id} --json`],
|
|
1412
|
+
mutation: ["t3-apply", "t3-cleanup", "t3-import"].includes(id)
|
|
1413
|
+
})),
|
|
1414
|
+
{
|
|
1415
|
+
id: "workstation",
|
|
1416
|
+
title: "Workstation configuration",
|
|
1417
|
+
description: "Tools, editor configuration, agent profiles, notifications and launcher lifecycle.",
|
|
1418
|
+
group: "Workstation",
|
|
1419
|
+
keywords: ["tools", "agents", "opencode", "install"],
|
|
1420
|
+
args: ["workstation"],
|
|
1421
|
+
examples: ["personal workstation --help"],
|
|
1422
|
+
mutation: false
|
|
1423
|
+
},
|
|
1424
|
+
...[
|
|
1425
|
+
[
|
|
1426
|
+
"fleet-node-sync",
|
|
1427
|
+
"Sync this node",
|
|
1428
|
+
"Upsert and reconcile locked tools and the Codex proxy profile on this machine. Applies by default; --dry-run previews."
|
|
1429
|
+
],
|
|
1430
|
+
[
|
|
1431
|
+
"fleet-status",
|
|
1432
|
+
"Fleet status",
|
|
1433
|
+
"Check this machine and all registered computers without changing them."
|
|
1434
|
+
],
|
|
1435
|
+
[
|
|
1436
|
+
"fleet-discover",
|
|
1437
|
+
"Discover computers",
|
|
1438
|
+
"List computers visible through Tailscale."
|
|
1439
|
+
]
|
|
1440
|
+
].map(([id, title, description]) => ({
|
|
1441
|
+
id,
|
|
1442
|
+
title,
|
|
1443
|
+
description,
|
|
1444
|
+
group: "Fleet",
|
|
1445
|
+
keywords: ["node", "sync", "proxy", "status"],
|
|
1446
|
+
args: [id],
|
|
1447
|
+
examples: [`personal ${id} --json`],
|
|
1448
|
+
mutation: id === "fleet-node-sync"
|
|
1449
|
+
})),
|
|
1450
|
+
fleet("repairs", "Explore repairs and login commands", "List existing tool owners, automated repairs, native per-machine login commands and shared Vault access guidance. No roster or credentials required.", ["fix", "repair", "login", "credentials", "sharing", "vault", "resources"]),
|
|
1451
|
+
fleet("repair", "Repair tools and configuration", "Inspect visible Tailscale computers and preview owner-managed repairs. --apply --yes executes eligible tools/config repairs and reruns diagnostics. Logins and host trust remain explicit operator steps.", [
|
|
1452
|
+
"fix",
|
|
1453
|
+
"repair",
|
|
1454
|
+
"sync",
|
|
1455
|
+
"setup",
|
|
1456
|
+
"versions",
|
|
1457
|
+
"config",
|
|
1458
|
+
"login",
|
|
1459
|
+
"credentials"
|
|
1460
|
+
]),
|
|
1461
|
+
fleet("health", "Check all Tailscale computers", "Discover every visible non-mobile Tailscale computer, test reachability, pinned tools, configuration and service logins. Offline, unregistered and unverified computers fail coverage.", [
|
|
1462
|
+
"tailscale",
|
|
1463
|
+
"tailnet",
|
|
1464
|
+
"all",
|
|
1465
|
+
"health",
|
|
1466
|
+
"versions",
|
|
1467
|
+
"codex",
|
|
1468
|
+
"claude",
|
|
1469
|
+
"login",
|
|
1470
|
+
"auth"
|
|
1471
|
+
]),
|
|
1472
|
+
fleet("discover", "Discover Tailscale computers", "List all computers visible to this Tailscale account, including offline devices. Excludes iOS, Android and tvOS; never writes a roster.", ["tailscale", "tailnet", "computers", "discover", "machines"]),
|
|
843
1473
|
fleet("doctor", "Diagnose the entire fleet", "Read-only SSH, authentication, T3, VS Code and dependency checks for this controller and every roster node.", [
|
|
844
1474
|
"health",
|
|
845
1475
|
"ssh",
|
|
@@ -891,54 +1521,9 @@ function commandCatalog(search = "", group) {
|
|
|
891
1521
|
// packages/personal-cli/src/program.ts
|
|
892
1522
|
import { Command, CommanderError } from "commander";
|
|
893
1523
|
|
|
894
|
-
// packages/fleet/src/errors.ts
|
|
895
|
-
class FleetUsageError extends Error {
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
// packages/personal-cli/src/agent-guide.ts
|
|
899
|
-
var AGENT_GUIDE = {
|
|
900
|
-
version: 1,
|
|
901
|
-
name: "@crvouga/personal",
|
|
902
|
-
invocation: "npx --yes @crvouga/personal@<exact-version> <command> --json --non-interactive",
|
|
903
|
-
discovery: "personal commands --json",
|
|
904
|
-
configuration: {
|
|
905
|
-
workspace: "--workspace PATH, PERSONAL_WORKSPACE, closest ancestor mise.toml, then current directory",
|
|
906
|
-
roster: "--config PATH, PERSONAL_FLEET_CONFIG, closest ancestor .fleet.local.json, then ~/.config/personal/fleet.json"
|
|
907
|
-
},
|
|
908
|
-
output: {
|
|
909
|
-
stdout: "One versioned JSON document with --json. Prompts and progress never contaminate stdout.",
|
|
910
|
-
exitCodes: {
|
|
911
|
-
"0": "Success or preview",
|
|
912
|
-
"1": "Failed check or operation; strict also rejects warnings/skips",
|
|
913
|
-
"2": "Invalid usage or configuration",
|
|
914
|
-
"130": "Cancelled"
|
|
915
|
-
},
|
|
916
|
-
errors: "{version:1,ok:false,code,error}; raw subprocess output and exception details are withheld",
|
|
917
|
-
reports: "{version:1,command,ok,generatedAt,nodes:[{node,checks:[{id,status,summary,remedy?}]}],summary,coverage}",
|
|
918
|
-
statuses: ["pass", "warn", "fail", "skip"],
|
|
919
|
-
coverage: "Default doctor tests the controller and all configured nodes. --local tests only this controller. A missing roster fails explicitly; configured nodes are not every computer that exists. T3/VS Code end-to-end checks remain manual."
|
|
920
|
-
},
|
|
921
|
-
safety: [
|
|
922
|
-
"No arguments opens a menu only on an interactive terminal. --json and --non-interactive never prompt.",
|
|
923
|
-
"Diagnostics, onboarding instructions, schema, catalog and sync previews are read-only.",
|
|
924
|
-
"Roster writes and sync require both --apply and --yes. npx --yes only approves downloading the CLI.",
|
|
925
|
-
"Do not copy credentials, forward SSH agents, bypass host keys, provision infrastructure or execute unreviewed remedies.",
|
|
926
|
-
"A healthy controller does not prove another machine can connect to it. Run doctor from each intended controller.",
|
|
927
|
-
"Use exact npm versions for automation. The application never self-updates."
|
|
928
|
-
],
|
|
929
|
-
examples: [
|
|
930
|
-
"personal fleet doctor --local --workspace /path/to/personal --json",
|
|
931
|
-
"personal fleet doctor --all --json --strict --timeout 120000",
|
|
932
|
-
"personal fleet add --id dev-linux --ssh dev-linux --path /home/dev/workspace --require github,t3,toolchain --json",
|
|
933
|
-
"personal fleet add --id dev-linux --ssh dev-linux --path /home/dev/workspace --apply --yes --json",
|
|
934
|
-
"personal fleet doctor --node dev-linux --json",
|
|
935
|
-
"personal fleet sync --node dev-linux --json"
|
|
936
|
-
]
|
|
937
|
-
};
|
|
938
|
-
|
|
939
1524
|
// packages/personal-cli/src/fleet.ts
|
|
940
|
-
import { writeFile as
|
|
941
|
-
import { dirname as
|
|
1525
|
+
import { writeFile as writeFile3, mkdir as mkdir5 } from "node:fs/promises";
|
|
1526
|
+
import { dirname as dirname5 } from "node:path";
|
|
942
1527
|
|
|
943
1528
|
// packages/fleet/src/config.ts
|
|
944
1529
|
var CAPABILITIES = [
|
|
@@ -946,7 +1531,11 @@ var CAPABILITIES = [
|
|
|
946
1531
|
"vault",
|
|
947
1532
|
"t3",
|
|
948
1533
|
"vscode",
|
|
949
|
-
"toolchain"
|
|
1534
|
+
"toolchain",
|
|
1535
|
+
"codex",
|
|
1536
|
+
"claude",
|
|
1537
|
+
"services",
|
|
1538
|
+
"workstation"
|
|
950
1539
|
];
|
|
951
1540
|
function record(value) {
|
|
952
1541
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -1020,13 +1609,13 @@ function selectNodes(config, ids) {
|
|
|
1020
1609
|
}
|
|
1021
1610
|
|
|
1022
1611
|
// packages/fleet/src/fingerprint.ts
|
|
1023
|
-
import { readFile as
|
|
1024
|
-
import { join as
|
|
1612
|
+
import { readFile as readFile3, mkdir as mkdir3, writeFile as writeFile2 } from "node:fs/promises";
|
|
1613
|
+
import { join as join6 } from "node:path";
|
|
1025
1614
|
|
|
1026
1615
|
// packages/fleet/src/input-hashes.ts
|
|
1027
|
-
import { createHash } from "node:crypto";
|
|
1028
|
-
import { readFile, readdir } from "node:fs/promises";
|
|
1029
|
-
import { join as
|
|
1616
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1617
|
+
import { readFile as readFile2, readdir } from "node:fs/promises";
|
|
1618
|
+
import { join as join5 } from "node:path";
|
|
1030
1619
|
var INPUT_FILES = [
|
|
1031
1620
|
"package.json",
|
|
1032
1621
|
"bun.lock",
|
|
@@ -1040,19 +1629,19 @@ var FINGERPRINT_FILES = [
|
|
|
1040
1629
|
];
|
|
1041
1630
|
async function fileHash(path) {
|
|
1042
1631
|
try {
|
|
1043
|
-
return digest(await
|
|
1632
|
+
return digest(await readFile2(path));
|
|
1044
1633
|
} catch {
|
|
1045
1634
|
return null;
|
|
1046
1635
|
}
|
|
1047
1636
|
}
|
|
1048
1637
|
async function manifestHash(root) {
|
|
1049
1638
|
try {
|
|
1050
|
-
const entries = await readdir(
|
|
1639
|
+
const entries = await readdir(join5(root, "packages"), {
|
|
1051
1640
|
withFileTypes: true
|
|
1052
1641
|
});
|
|
1053
1642
|
const hashes = [];
|
|
1054
1643
|
for (const entry of entries.filter((item) => item.isDirectory()).sort((left, right) => left.name.localeCompare(right.name, "en"))) {
|
|
1055
|
-
hashes.push(`${entry.name}:${await fileHash(
|
|
1644
|
+
hashes.push(`${entry.name}:${await fileHash(join5(root, "packages", entry.name, "package.json"))}`);
|
|
1056
1645
|
}
|
|
1057
1646
|
return digest(hashes.join(`
|
|
1058
1647
|
`));
|
|
@@ -1062,19 +1651,19 @@ async function manifestHash(root) {
|
|
|
1062
1651
|
}
|
|
1063
1652
|
async function sidecarHash(root) {
|
|
1064
1653
|
try {
|
|
1065
|
-
const files = await readdir(
|
|
1654
|
+
const files = await readdir(join5(root, ".mise/locks"), {
|
|
1066
1655
|
recursive: true,
|
|
1067
1656
|
withFileTypes: true
|
|
1068
1657
|
});
|
|
1069
1658
|
if (files.length > 4096 || files.some((entry) => entry.isSymbolicLink()))
|
|
1070
1659
|
return null;
|
|
1071
1660
|
const hashes = [];
|
|
1072
|
-
const paths = files.filter((entry) => entry.isFile()).map((entry) =>
|
|
1661
|
+
const paths = files.filter((entry) => entry.isFile()).map((entry) => join5(entry.parentPath, entry.name)).sort();
|
|
1073
1662
|
for (const path of paths) {
|
|
1074
1663
|
const hash = await fileHash(path);
|
|
1075
1664
|
if (hash === null)
|
|
1076
1665
|
return null;
|
|
1077
|
-
const relative = path.slice(
|
|
1666
|
+
const relative = path.slice(join5(root, ".mise/locks").length).replaceAll("\\", "/");
|
|
1078
1667
|
hashes.push(`${relative}:${hash}`);
|
|
1079
1668
|
}
|
|
1080
1669
|
return digest(hashes.join(`
|
|
@@ -1084,7 +1673,7 @@ async function sidecarHash(root) {
|
|
|
1084
1673
|
}
|
|
1085
1674
|
}
|
|
1086
1675
|
function digest(value) {
|
|
1087
|
-
return
|
|
1676
|
+
return createHash2("sha256").update(value).digest("hex");
|
|
1088
1677
|
}
|
|
1089
1678
|
|
|
1090
1679
|
// packages/fleet/src/fingerprint.ts
|
|
@@ -1092,7 +1681,7 @@ var RECEIPT = "node_modules/.fleet-sync.json";
|
|
|
1092
1681
|
async function fingerprint(root, run) {
|
|
1093
1682
|
const files = {};
|
|
1094
1683
|
for (const name of INPUT_FILES)
|
|
1095
|
-
files[name] = await fileHash(
|
|
1684
|
+
files[name] = await fileHash(join6(root, name));
|
|
1096
1685
|
files["workspace-manifests"] = await manifestHash(root);
|
|
1097
1686
|
files["mise-sidecars"] = await sidecarHash(root);
|
|
1098
1687
|
const head = await run(["git", "rev-parse", "HEAD"], { cwd: root });
|
|
@@ -1106,7 +1695,7 @@ async function dependencyCheck(root, current) {
|
|
|
1106
1695
|
action: "Review fleet sync, then explicitly apply a frozen toolchain/dependency sync."
|
|
1107
1696
|
};
|
|
1108
1697
|
try {
|
|
1109
|
-
const receipt = JSON.parse(await
|
|
1698
|
+
const receipt = JSON.parse(await readFile3(join6(root, RECEIPT), "utf8"));
|
|
1110
1699
|
const matches = record(receipt) && receipt["version"] === 1 && JSON.stringify(receipt["files"]) === JSON.stringify(current.files);
|
|
1111
1700
|
return {
|
|
1112
1701
|
id: "dependencies",
|
|
@@ -1124,8 +1713,8 @@ async function dependencyCheck(root, current) {
|
|
|
1124
1713
|
}
|
|
1125
1714
|
}
|
|
1126
1715
|
async function writeReceipt(root, current) {
|
|
1127
|
-
await
|
|
1128
|
-
await
|
|
1716
|
+
await mkdir3(join6(root, "node_modules"), { recursive: true });
|
|
1717
|
+
await writeFile2(join6(root, RECEIPT), JSON.stringify({ version: 1, files: current.files }), { mode: 384 });
|
|
1129
1718
|
}
|
|
1130
1719
|
function compareFingerprints(expected, actual) {
|
|
1131
1720
|
return [
|
|
@@ -1148,11 +1737,200 @@ function compare(id, expected, actual) {
|
|
|
1148
1737
|
};
|
|
1149
1738
|
}
|
|
1150
1739
|
|
|
1740
|
+
// packages/fleet/src/service-auth.ts
|
|
1741
|
+
function json(result) {
|
|
1742
|
+
try {
|
|
1743
|
+
const data = JSON.parse(result.stdout);
|
|
1744
|
+
return record(data) ? data : null;
|
|
1745
|
+
} catch {
|
|
1746
|
+
return null;
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
async function codingAuth(id, run) {
|
|
1750
|
+
return [
|
|
1751
|
+
await serviceCheck(id === "codex" ? {
|
|
1752
|
+
id,
|
|
1753
|
+
command: ["codex", "login", "status"],
|
|
1754
|
+
action: "Run codex login on this computer as its operator.",
|
|
1755
|
+
valid: (result) => /^Logged in using (ChatGPT|an API key|API key)\b/im.test(`${result.stdout}
|
|
1756
|
+
${result.stderr}`)
|
|
1757
|
+
} : {
|
|
1758
|
+
id,
|
|
1759
|
+
command: ["claude", "auth", "status", "--json"],
|
|
1760
|
+
action: "Run claude auth login on this computer as its operator.",
|
|
1761
|
+
valid: (result) => json(result)?.["loggedIn"] === true
|
|
1762
|
+
}, run)
|
|
1763
|
+
];
|
|
1764
|
+
}
|
|
1765
|
+
async function serviceAuth(run) {
|
|
1766
|
+
const services = [
|
|
1767
|
+
{
|
|
1768
|
+
id: "railway",
|
|
1769
|
+
command: ["railway", "whoami", "--json"],
|
|
1770
|
+
action: "Run railway login on this computer.",
|
|
1771
|
+
valid: (result) => typeof json(result)?.["id"] === "string"
|
|
1772
|
+
},
|
|
1773
|
+
{
|
|
1774
|
+
id: "vercel",
|
|
1775
|
+
command: ["vercel", "whoami"],
|
|
1776
|
+
action: "Run vercel login on this computer.",
|
|
1777
|
+
valid: (result) => /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,100}$/.test(result.stdout.trim())
|
|
1778
|
+
},
|
|
1779
|
+
{
|
|
1780
|
+
id: "cloudflare",
|
|
1781
|
+
command: ["wrangler", "whoami", "--json"],
|
|
1782
|
+
action: "Run wrangler login on this computer.",
|
|
1783
|
+
valid: (result) => json(result)?.["loggedIn"] === true
|
|
1784
|
+
},
|
|
1785
|
+
{
|
|
1786
|
+
id: "aws",
|
|
1787
|
+
command: [
|
|
1788
|
+
"aws",
|
|
1789
|
+
"sts",
|
|
1790
|
+
"get-caller-identity",
|
|
1791
|
+
"--output",
|
|
1792
|
+
"json",
|
|
1793
|
+
"--no-cli-pager"
|
|
1794
|
+
],
|
|
1795
|
+
action: "Select and authenticate the intended AWS profile on this computer.",
|
|
1796
|
+
valid: (result) => typeof json(result)?.["Account"] === "string" && typeof json(result)?.["Arn"] === "string"
|
|
1797
|
+
},
|
|
1798
|
+
{
|
|
1799
|
+
id: "npm",
|
|
1800
|
+
command: ["npm", "whoami", "--registry=https://registry.npmjs.org/"],
|
|
1801
|
+
action: "Run npm login only if this computer should publish packages.",
|
|
1802
|
+
valid: (result) => /^[a-z0-9][a-z0-9._-]{0,213}$/.test(result.stdout.trim())
|
|
1803
|
+
}
|
|
1804
|
+
];
|
|
1805
|
+
const checks = [];
|
|
1806
|
+
for (const service of services)
|
|
1807
|
+
checks.push(await serviceCheck(service, run));
|
|
1808
|
+
for (const id of ["gemini", "copilot", "opencode"])
|
|
1809
|
+
checks.push({
|
|
1810
|
+
id: `auth:${id}`,
|
|
1811
|
+
status: "skip",
|
|
1812
|
+
summary: `${id} provider authorization has no verified noninteractive status adapter in this CLI. Installed binaries or saved keys are not proof of access.`,
|
|
1813
|
+
remedy: {
|
|
1814
|
+
action: `Verify ${id} provider access locally using its supported account diagnostics. No prompt or billable request was made.`
|
|
1815
|
+
}
|
|
1816
|
+
});
|
|
1817
|
+
return checks;
|
|
1818
|
+
}
|
|
1819
|
+
async function serviceCheck(service, run) {
|
|
1820
|
+
const result = await run(service.command, { timeoutMs: 15000 });
|
|
1821
|
+
const ok = result.ok && service.valid(result);
|
|
1822
|
+
return {
|
|
1823
|
+
id: `auth:${service.id}`,
|
|
1824
|
+
status: ok ? "pass" : "fail",
|
|
1825
|
+
summary: ok ? `${service.id} reports an authenticated session in this process context; no credential or account output retained.` : `${service.id} login is missing or could not be verified (${result.reason}); no credential output retained.`,
|
|
1826
|
+
...!ok ? { remedy: { action: service.action } } : {}
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// packages/fleet/src/workstation-checks.ts
|
|
1831
|
+
async function workstationChecks(root, run) {
|
|
1832
|
+
const result = await run(["personal", "workstation", "doctor", "--json", "--non-interactive"], {
|
|
1833
|
+
cwd: root,
|
|
1834
|
+
timeoutMs: 20000
|
|
1835
|
+
});
|
|
1836
|
+
const fallback = {
|
|
1837
|
+
id: "workstation:configuration",
|
|
1838
|
+
status: "fail",
|
|
1839
|
+
summary: "Managed workstation configuration could not be verified.",
|
|
1840
|
+
remedy: {
|
|
1841
|
+
action: "Run personal workstation doctor --json on this computer. Review managed links, PATH, coding-tool/provider configuration and notification settings; apply personal workstation sync only explicitly."
|
|
1842
|
+
}
|
|
1843
|
+
};
|
|
1844
|
+
try {
|
|
1845
|
+
const data = JSON.parse(result.stdout);
|
|
1846
|
+
if (!record(data) || !Array.isArray(data["checks"]) || !data["checks"].length || data["checks"].length > 100)
|
|
1847
|
+
return [fallback];
|
|
1848
|
+
const checks = data["checks"].map(configurationCheck);
|
|
1849
|
+
if (checks.some((check) => check === null))
|
|
1850
|
+
return [fallback];
|
|
1851
|
+
return [
|
|
1852
|
+
...!result.ok || data["ok"] !== true ? [fallback] : [],
|
|
1853
|
+
...checks
|
|
1854
|
+
];
|
|
1855
|
+
} catch {
|
|
1856
|
+
return [fallback];
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
function configurationCheck(value) {
|
|
1860
|
+
if (!record(value) || typeof value["id"] !== "string" || !/^[a-zA-Z0-9:._-]{1,75}$/.test(value["id"]) || !["pass", "warn", "fail"].includes(String(value["severity"])))
|
|
1861
|
+
return null;
|
|
1862
|
+
const ok = value["severity"] === "pass";
|
|
1863
|
+
return {
|
|
1864
|
+
id: `workstation:${value["id"].toLowerCase()}`,
|
|
1865
|
+
status: value["severity"],
|
|
1866
|
+
summary: ok ? "Managed configuration check succeeded (configuration presence; provider authorization is checked separately)." : "Managed configuration is missing, conflicting or unverified.",
|
|
1867
|
+
...!ok ? {
|
|
1868
|
+
remedy: {
|
|
1869
|
+
action: "Inspect personal workstation doctor --json on this computer. Use explicit personal workstation sync only after reviewing the configuration changes."
|
|
1870
|
+
}
|
|
1871
|
+
} : {}
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
// packages/fleet/src/toolchain-checks.ts
|
|
1876
|
+
import { join as join7 } from "node:path";
|
|
1877
|
+
async function toolchainChecks(root, run) {
|
|
1878
|
+
const result = await run(["bun", join7(root, "scripts/dev/doctor.ts"), "--json", "--docker"], { cwd: root, timeoutMs: 90000 });
|
|
1879
|
+
const fallback = {
|
|
1880
|
+
id: "auth:toolchain",
|
|
1881
|
+
status: "fail",
|
|
1882
|
+
summary: "Pinned tool/dependency and Docker checks could not be verified.",
|
|
1883
|
+
remedy: {
|
|
1884
|
+
action: "Run the checkout scripts/dev/doctor.ts --json --docker on this computer. Reconcile versions only through the shared mise setup."
|
|
1885
|
+
}
|
|
1886
|
+
};
|
|
1887
|
+
try {
|
|
1888
|
+
const data = JSON.parse(result.stdout);
|
|
1889
|
+
if (!record(data) || typeof data["ok"] !== "boolean" || !Array.isArray(data["checks"]) || !data["checks"].length || data["checks"].length > 150)
|
|
1890
|
+
return [fallback];
|
|
1891
|
+
const checks = data["checks"].map(toolCheck);
|
|
1892
|
+
if (checks.some((check) => check === null))
|
|
1893
|
+
return [fallback];
|
|
1894
|
+
return [
|
|
1895
|
+
...!result.ok || !data["ok"] ? [fallback] : [],
|
|
1896
|
+
...checks
|
|
1897
|
+
];
|
|
1898
|
+
} catch {
|
|
1899
|
+
return [fallback];
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
function toolCheck(value) {
|
|
1903
|
+
if (!record(value) || typeof value["id"] !== "string" || !/^[a-zA-Z0-9@:/._-]{1,75}$/.test(value["id"]) || !["pass", "fail", "warn", "skip"].includes(String(value["status"])))
|
|
1904
|
+
return null;
|
|
1905
|
+
const ok = value["status"] === "pass";
|
|
1906
|
+
const version = typeof value["detail"] === "string" && /^v?\d{1,6}(?:\.\d{1,6}){1,3}$/.test(value["detail"]) ? ` ${value["detail"]}` : "";
|
|
1907
|
+
return {
|
|
1908
|
+
id: `tool:${value["id"].toLowerCase().replace(/[^a-z0-9:._-]/g, "_")}`,
|
|
1909
|
+
status: value["status"],
|
|
1910
|
+
summary: ok ? `Expected tool version or runtime check succeeded${version}.` : "Expected tool version, runtime or authentication check did not pass.",
|
|
1911
|
+
...!ok ? {
|
|
1912
|
+
remedy: {
|
|
1913
|
+
action: "Review scripts/dev/doctor.ts --json --docker locally. Use shared mise/workstation setup to reconcile this computer."
|
|
1914
|
+
}
|
|
1915
|
+
} : {}
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1151
1919
|
// packages/fleet/src/auth-checks.ts
|
|
1152
|
-
import { join as join4 } from "node:path";
|
|
1153
1920
|
async function authCheck(capability, run, root) {
|
|
1154
1921
|
if (capability === "t3")
|
|
1155
1922
|
return t3Checks(run);
|
|
1923
|
+
if (capability === "codex" || capability === "claude")
|
|
1924
|
+
return codingAuth(capability, run);
|
|
1925
|
+
if (capability === "services")
|
|
1926
|
+
return serviceAuth(run);
|
|
1927
|
+
if (capability === "workstation")
|
|
1928
|
+
return workstationChecks(root, run);
|
|
1929
|
+
if (capability === "toolchain")
|
|
1930
|
+
return toolchainChecks(root, run);
|
|
1931
|
+
return basicAuth(capability, run, root);
|
|
1932
|
+
}
|
|
1933
|
+
async function basicAuth(capability, run, root) {
|
|
1156
1934
|
const spec = {
|
|
1157
1935
|
github: {
|
|
1158
1936
|
command: ["gh", "auth", "status", "--active"],
|
|
@@ -1165,15 +1943,11 @@ async function authCheck(capability, run, root) {
|
|
|
1165
1943
|
vscode: {
|
|
1166
1944
|
command: ["code", "--list-extensions"],
|
|
1167
1945
|
action: "Install the VS Code CLI on PATH and ms-vscode-remote.remote-ssh in the local editor profile."
|
|
1168
|
-
},
|
|
1169
|
-
toolchain: {
|
|
1170
|
-
command: ["bun", join4(root, "scripts/dev/doctor.ts"), "--json"],
|
|
1171
|
-
action: "Run ws tools check --json; use the shared mise setup to reconcile tools."
|
|
1172
1946
|
}
|
|
1173
1947
|
}[capability];
|
|
1174
1948
|
const result = await run(spec.command, {
|
|
1175
1949
|
cwd: root,
|
|
1176
|
-
timeoutMs:
|
|
1950
|
+
timeoutMs: 15000
|
|
1177
1951
|
});
|
|
1178
1952
|
const extensions = result.stdout.toLowerCase().split(/\s+/);
|
|
1179
1953
|
const ok = result.ok && (capability !== "vscode" || extensions.includes("ms-vscode-remote.remote-ssh"));
|
|
@@ -1252,13 +2026,7 @@ async function probe(root, required, run) {
|
|
|
1252
2026
|
summary: dirty.ok && !dirty.stdout.trim() ? "Working tree is clean." : "Working tree is dirty or unreadable; sync --pull will refuse."
|
|
1253
2027
|
});
|
|
1254
2028
|
checks.push(await dependencyCheck(root, current));
|
|
1255
|
-
for (const capability of
|
|
1256
|
-
"github",
|
|
1257
|
-
"vault",
|
|
1258
|
-
"t3",
|
|
1259
|
-
"vscode",
|
|
1260
|
-
"toolchain"
|
|
1261
|
-
]) {
|
|
2029
|
+
for (const capability of CAPABILITIES) {
|
|
1262
2030
|
if (required.includes(capability))
|
|
1263
2031
|
checks.push(...await authCheck(capability, run, root));
|
|
1264
2032
|
else
|
|
@@ -1506,8 +2274,11 @@ async function bundleProbe() {
|
|
|
1506
2274
|
async function inspectRemote(node, options, run) {
|
|
1507
2275
|
const command = remoteCommand(node, {
|
|
1508
2276
|
action: options.action,
|
|
2277
|
+
configuration: options.configuration,
|
|
1509
2278
|
node,
|
|
1510
|
-
apply: options.action
|
|
2279
|
+
apply: options.action !== "probe",
|
|
2280
|
+
repairs: options.repairs,
|
|
2281
|
+
expected: options.expected,
|
|
1511
2282
|
pull: options.pull === true
|
|
1512
2283
|
});
|
|
1513
2284
|
const result = await run(command, {
|
|
@@ -1516,7 +2287,7 @@ async function inspectRemote(node, options, run) {
|
|
|
1516
2287
|
});
|
|
1517
2288
|
if (!result.ok)
|
|
1518
2289
|
return { node: node.id, checks: [sshFailure(result)] };
|
|
1519
|
-
if (options.action
|
|
2290
|
+
if (options.action !== "probe")
|
|
1520
2291
|
return decodeSync(node.id, result.stdout);
|
|
1521
2292
|
const snapshot = decodeSnapshot(result.stdout);
|
|
1522
2293
|
if (!snapshot)
|
|
@@ -1542,7 +2313,7 @@ async function inspectRemote(node, options, run) {
|
|
|
1542
2313
|
function decodeSync(node, output) {
|
|
1543
2314
|
try {
|
|
1544
2315
|
const data = JSON.parse(output);
|
|
1545
|
-
if (record(data) && data["version"] === 1 && Array.isArray(data["checks"]) && data["checks"].length <= 200 && data["checks"].every(validCheck)) {
|
|
2316
|
+
if (record(data) && data["version"] === 1 && Array.isArray(data["checks"]) && data["checks"].length > 0 && data["checks"].length <= 200 && data["checks"].every(validCheck)) {
|
|
1546
2317
|
return { node, checks: data["checks"].map(cleanCheck) };
|
|
1547
2318
|
}
|
|
1548
2319
|
} catch {
|
|
@@ -1604,7 +2375,7 @@ async function doctor(nodes, options, run) {
|
|
|
1604
2375
|
|
|
1605
2376
|
// packages/fleet/src/sync.ts
|
|
1606
2377
|
import { unlink } from "node:fs/promises";
|
|
1607
|
-
import { join as
|
|
2378
|
+
import { join as join8 } from "node:path";
|
|
1608
2379
|
function syncPlan(pull) {
|
|
1609
2380
|
return [
|
|
1610
2381
|
"Validate the checkout and shared input files; invalidate any prior sync receipt.",
|
|
@@ -1629,7 +2400,7 @@ async function syncWorkspace(root, pull, run) {
|
|
|
1629
2400
|
if (!dirty.ok || dirty.stdout.trim() || !branch.ok)
|
|
1630
2401
|
return failed("Refusing pull: dirty, detached, or unreadable checkout. Reconcile manually.");
|
|
1631
2402
|
}
|
|
1632
|
-
await unlink(
|
|
2403
|
+
await unlink(join8(root, "node_modules/.fleet-sync.json")).catch(() => {
|
|
1633
2404
|
return;
|
|
1634
2405
|
});
|
|
1635
2406
|
if (pull) {
|
|
@@ -1719,82 +2490,6 @@ function onboarding(node, launcher = "bun run fleet") {
|
|
|
1719
2490
|
}
|
|
1720
2491
|
};
|
|
1721
2492
|
}
|
|
1722
|
-
|
|
1723
|
-
// packages/fleet/src/runner.ts
|
|
1724
|
-
import { spawn } from "node:child_process";
|
|
1725
|
-
var MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
1726
|
-
function terminate(child) {
|
|
1727
|
-
try {
|
|
1728
|
-
if (process.platform !== "win32" && child.pid)
|
|
1729
|
-
process.kill(-child.pid, "SIGKILL");
|
|
1730
|
-
else
|
|
1731
|
-
child.kill("SIGKILL");
|
|
1732
|
-
} catch {}
|
|
1733
|
-
}
|
|
1734
|
-
function runCommand(command, options = {}) {
|
|
1735
|
-
return new Promise((resolve) => {
|
|
1736
|
-
const child = spawn(command[0], command.slice(1), {
|
|
1737
|
-
cwd: options.cwd,
|
|
1738
|
-
env: {
|
|
1739
|
-
...process.env,
|
|
1740
|
-
...options.env,
|
|
1741
|
-
GIT_TERMINAL_PROMPT: "0",
|
|
1742
|
-
CI: "1"
|
|
1743
|
-
},
|
|
1744
|
-
stdio: "pipe",
|
|
1745
|
-
windowsHide: true,
|
|
1746
|
-
detached: process.platform !== "win32"
|
|
1747
|
-
});
|
|
1748
|
-
let stdout = "";
|
|
1749
|
-
let stderr = "";
|
|
1750
|
-
let bytes = 0;
|
|
1751
|
-
let finished = false;
|
|
1752
|
-
let reason = "exit";
|
|
1753
|
-
const finish = (ok) => {
|
|
1754
|
-
if (finished)
|
|
1755
|
-
return;
|
|
1756
|
-
finished = true;
|
|
1757
|
-
clearTimeout(timer);
|
|
1758
|
-
child.stdin.destroy();
|
|
1759
|
-
child.stdout.destroy();
|
|
1760
|
-
child.stderr.destroy();
|
|
1761
|
-
resolve({ ok, stdout, stderr, reason });
|
|
1762
|
-
};
|
|
1763
|
-
const abort = (cause) => {
|
|
1764
|
-
reason = cause;
|
|
1765
|
-
terminate(child);
|
|
1766
|
-
finish(false);
|
|
1767
|
-
};
|
|
1768
|
-
const timer = setTimeout(() => {
|
|
1769
|
-
abort("timeout");
|
|
1770
|
-
}, options.timeoutMs ?? 15000);
|
|
1771
|
-
const capture = (stream, chunk) => {
|
|
1772
|
-
if (finished)
|
|
1773
|
-
return;
|
|
1774
|
-
bytes += Buffer.byteLength(chunk);
|
|
1775
|
-
if (bytes > MAX_OUTPUT_BYTES) {
|
|
1776
|
-
abort("overflow");
|
|
1777
|
-
return;
|
|
1778
|
-
}
|
|
1779
|
-
if (stream === "stdout")
|
|
1780
|
-
stdout += chunk;
|
|
1781
|
-
else
|
|
1782
|
-
stderr += chunk;
|
|
1783
|
-
};
|
|
1784
|
-
child.stdout.setEncoding("utf8").on("data", (chunk) => capture("stdout", chunk));
|
|
1785
|
-
child.stderr.setEncoding("utf8").on("data", (chunk) => capture("stderr", chunk));
|
|
1786
|
-
child.stdin.on("error", () => {
|
|
1787
|
-
return;
|
|
1788
|
-
});
|
|
1789
|
-
child.on("error", () => {
|
|
1790
|
-
abort("missing");
|
|
1791
|
-
});
|
|
1792
|
-
child.on("close", (code) => {
|
|
1793
|
-
finish(code === 0 && reason === "exit");
|
|
1794
|
-
});
|
|
1795
|
-
child.stdin.end(options.input);
|
|
1796
|
-
});
|
|
1797
|
-
}
|
|
1798
2493
|
// packages/fleet/schema.json
|
|
1799
2494
|
var schema_default = {
|
|
1800
2495
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
@@ -1834,7 +2529,17 @@ var schema_default = {
|
|
|
1834
2529
|
uniqueItems: true,
|
|
1835
2530
|
default: ["github", "vault", "t3", "toolchain"],
|
|
1836
2531
|
items: {
|
|
1837
|
-
enum: [
|
|
2532
|
+
enum: [
|
|
2533
|
+
"github",
|
|
2534
|
+
"vault",
|
|
2535
|
+
"t3",
|
|
2536
|
+
"vscode",
|
|
2537
|
+
"toolchain",
|
|
2538
|
+
"codex",
|
|
2539
|
+
"claude",
|
|
2540
|
+
"services",
|
|
2541
|
+
"workstation"
|
|
2542
|
+
]
|
|
1838
2543
|
}
|
|
1839
2544
|
}
|
|
1840
2545
|
}
|
|
@@ -1861,7 +2566,7 @@ var fleet_example_default = {
|
|
|
1861
2566
|
function capabilities(value) {
|
|
1862
2567
|
const values = value === "none" ? [] : value?.split(",") ?? CAPABILITIES;
|
|
1863
2568
|
if (values.some((entry) => !CAPABILITIES.includes(entry)))
|
|
1864
|
-
throw new FleetUsageError(
|
|
2569
|
+
throw new FleetUsageError(`--require expects ${CAPABILITIES.join(",")} or none.`);
|
|
1865
2570
|
return [...new Set(values)];
|
|
1866
2571
|
}
|
|
1867
2572
|
function timeout(options) {
|
|
@@ -1872,24 +2577,29 @@ function timeout(options) {
|
|
|
1872
2577
|
}
|
|
1873
2578
|
function validateOptions(command, options) {
|
|
1874
2579
|
validateMutation(command, options);
|
|
2580
|
+
validateRepair(command, options);
|
|
1875
2581
|
validateScope(command, options);
|
|
2582
|
+
if (["discover", "health", "repair"].includes(command) && options.require !== undefined)
|
|
2583
|
+
throw new FleetUsageError("Tailscale health always checks the complete capability set; --require is not supported.");
|
|
1876
2584
|
if (options.timeout !== undefined)
|
|
1877
2585
|
timeout(options);
|
|
1878
2586
|
}
|
|
1879
2587
|
function validateMutation(command, options) {
|
|
1880
|
-
if (options.apply && !["add", "init", "sync"].includes(command))
|
|
1881
|
-
throw new FleetUsageError("--apply is only valid for fleet add, init or
|
|
2588
|
+
if (options.apply && !["add", "init", "sync", "repair"].includes(command))
|
|
2589
|
+
throw new FleetUsageError("--apply is only valid for fleet add, init, sync or repair.");
|
|
1882
2590
|
if (options.apply && !options.yes)
|
|
1883
2591
|
throw new FleetUsageError("Mutation requires both --apply and --yes.");
|
|
1884
2592
|
if (options.pull && command !== "sync")
|
|
1885
2593
|
throw new FleetUsageError("--pull is only valid for fleet sync.");
|
|
1886
2594
|
}
|
|
1887
2595
|
function validateScope(command, options) {
|
|
1888
|
-
if (
|
|
1889
|
-
throw new FleetUsageError("
|
|
2596
|
+
if (["discover", "health"].includes(command) && (options.local || options.all || options.node?.length))
|
|
2597
|
+
throw new FleetUsageError("Tailscale discovery and health always cover all visible computers; scope filters are not supported.");
|
|
2598
|
+
if (options.local && !["doctor", "sync", "repair"].includes(command))
|
|
2599
|
+
throw new FleetUsageError("--local is only valid for fleet doctor, sync or repair.");
|
|
1890
2600
|
validateExclusive(options);
|
|
1891
|
-
if (options.all &&
|
|
1892
|
-
throw new FleetUsageError("--all is only valid for fleet doctor; sync already targets all configured nodes by default.");
|
|
2601
|
+
if (options.all && !["doctor", "repair"].includes(command))
|
|
2602
|
+
throw new FleetUsageError("--all is only valid for fleet doctor or repair; sync already targets all configured nodes by default.");
|
|
1893
2603
|
}
|
|
1894
2604
|
function validateExclusive(options) {
|
|
1895
2605
|
const selected = [
|
|
@@ -1900,17 +2610,30 @@ function validateExclusive(options) {
|
|
|
1900
2610
|
if (selected.length > 1)
|
|
1901
2611
|
throw new FleetUsageError("Choose --local, --all, or --node, not a combination.");
|
|
1902
2612
|
}
|
|
2613
|
+
function validateRepair(command, options) {
|
|
2614
|
+
if (options.planHash !== undefined && !/^[a-f0-9]{64}$/.test(options.planHash))
|
|
2615
|
+
throw new FleetUsageError("--plan-hash expects the SHA-256 value from a repair preview.");
|
|
2616
|
+
if ((options.repair !== undefined || options.script || options.planHash !== undefined) && command !== "repair")
|
|
2617
|
+
throw new FleetUsageError("--repair and --script are only valid for fleet repair.");
|
|
2618
|
+
if (options.script && options.apply)
|
|
2619
|
+
throw new FleetUsageError("--script only generates a preview; run a reviewed repair separately with --apply --yes.");
|
|
2620
|
+
validateCatalogScope(command, options);
|
|
2621
|
+
}
|
|
2622
|
+
function validateCatalogScope(command, options) {
|
|
2623
|
+
if (command === "repairs" && (options.local || options.node?.length || options.all || options.require !== undefined))
|
|
2624
|
+
throw new FleetUsageError("fleet repairs is a static catalog and does not accept target or capability filters.");
|
|
2625
|
+
}
|
|
1903
2626
|
|
|
1904
2627
|
// packages/personal-cli/src/roster.ts
|
|
1905
|
-
import { lstat, mkdir as
|
|
1906
|
-
import { dirname as
|
|
1907
|
-
import { randomUUID } from "node:crypto";
|
|
2628
|
+
import { lstat as lstat2, mkdir as mkdir4, open, readFile as readFile4, rename as rename2, unlink as unlink2 } from "node:fs/promises";
|
|
2629
|
+
import { dirname as dirname4 } from "node:path";
|
|
2630
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1908
2631
|
async function readRoster(path) {
|
|
1909
2632
|
try {
|
|
1910
|
-
const info = await
|
|
2633
|
+
const info = await lstat2(path);
|
|
1911
2634
|
if (!info.isFile() || info.size > 1024 * 1024)
|
|
1912
2635
|
throw new FleetUsageError("Roster must be a regular file smaller than 1 MiB; symlinks are not supported.");
|
|
1913
|
-
return parseConfig(JSON.parse(await
|
|
2636
|
+
return parseConfig(JSON.parse(await readFile4(path, "utf8")));
|
|
1914
2637
|
} catch (error) {
|
|
1915
2638
|
if (record(error) && error["code"] === "ENOENT")
|
|
1916
2639
|
return null;
|
|
@@ -1937,7 +2660,7 @@ function appendNode(existing, node) {
|
|
|
1937
2660
|
async function addNode(path, node, apply) {
|
|
1938
2661
|
if (!apply)
|
|
1939
2662
|
return appendNode(await readRoster(path), node);
|
|
1940
|
-
await
|
|
2663
|
+
await mkdir4(dirname4(path), { recursive: true, mode: 448 });
|
|
1941
2664
|
const lock = await acquireLock(path);
|
|
1942
2665
|
try {
|
|
1943
2666
|
const roster = appendNode(await readRoster(path), node);
|
|
@@ -1956,14 +2679,14 @@ async function acquireLock(path) {
|
|
|
1956
2679
|
}
|
|
1957
2680
|
}
|
|
1958
2681
|
async function atomicWrite(path, roster) {
|
|
1959
|
-
const temporary = `${path}.${
|
|
2682
|
+
const temporary = `${path}.${randomUUID2()}.tmp`;
|
|
1960
2683
|
const handle = await open(temporary, "wx", 384);
|
|
1961
2684
|
try {
|
|
1962
2685
|
await handle.writeFile(`${JSON.stringify(roster, null, 2)}
|
|
1963
2686
|
`);
|
|
1964
2687
|
await handle.sync();
|
|
1965
2688
|
await handle.close();
|
|
1966
|
-
await
|
|
2689
|
+
await rename2(temporary, path);
|
|
1967
2690
|
} finally {
|
|
1968
2691
|
await handle.close();
|
|
1969
2692
|
await unlink2(temporary).catch(() => {
|
|
@@ -1972,60 +2695,903 @@ async function atomicWrite(path, roster) {
|
|
|
1972
2695
|
}
|
|
1973
2696
|
}
|
|
1974
2697
|
|
|
1975
|
-
// packages/
|
|
1976
|
-
import {
|
|
1977
|
-
async function
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
2698
|
+
// packages/fleet/src/tailscale.ts
|
|
2699
|
+
import { isIP } from "node:net";
|
|
2700
|
+
async function discoverTailnet(run) {
|
|
2701
|
+
const result = await run(["tailscale", "status", "--json"], {
|
|
2702
|
+
timeoutMs: 15000
|
|
2703
|
+
});
|
|
2704
|
+
if (!result.ok)
|
|
2705
|
+
return unavailable("Tailscale status is unavailable; check the CLI, daemon and account login.");
|
|
2706
|
+
try {
|
|
2707
|
+
return parseTailnet(JSON.parse(result.stdout));
|
|
2708
|
+
} catch {
|
|
2709
|
+
return unavailable("Tailscale returned an unsupported status document. Update the CLI and check its daemon.");
|
|
2710
|
+
}
|
|
1981
2711
|
}
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
if (
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2712
|
+
function parseTailnet(data) {
|
|
2713
|
+
if (!record(data) || data["BackendState"] !== "Running")
|
|
2714
|
+
return unavailable("Tailscale is not running and signed in. Review tailscale status locally.");
|
|
2715
|
+
validateStatusFields(data);
|
|
2716
|
+
const peers = Object.values(data["Peer"] ?? {});
|
|
2717
|
+
if (peers.length > 1024)
|
|
2718
|
+
throw new Error("Too many peers");
|
|
2719
|
+
const all = [
|
|
2720
|
+
parseComputer(data["Self"], true),
|
|
2721
|
+
...peers.map((peer) => parseComputer(peer, false))
|
|
2722
|
+
];
|
|
2723
|
+
if (new Set(all.map((peer) => peer.id)).size !== all.length)
|
|
2724
|
+
throw new Error("Duplicate peer");
|
|
2725
|
+
const mobile = (peer) => ["ios", "android", "tvos"].includes(peer.os.toLowerCase());
|
|
2726
|
+
const computers = all.filter((peer) => !mobile(peer)).sort((a, b) => a.name.localeCompare(b.name, "en"));
|
|
2727
|
+
const healthy = healthyDiscovery(data, computers.length);
|
|
2728
|
+
return {
|
|
2729
|
+
computers,
|
|
2730
|
+
excludedMobile: all.filter(mobile).length,
|
|
2731
|
+
checks: [
|
|
2732
|
+
{
|
|
2733
|
+
id: "tailscale:discovery",
|
|
2734
|
+
status: healthy ? "pass" : "fail",
|
|
2735
|
+
summary: healthy ? "Signed-in Tailscale daemon returned its visible computer inventory." : "Tailscale reports a daemon/coordination health problem or no computers; discovery may be incomplete.",
|
|
2736
|
+
...!healthy ? {
|
|
2737
|
+
remedy: {
|
|
2738
|
+
action: "Review tailscale status and network connectivity. Resolve coordination/daemon health before claiming complete coverage."
|
|
2739
|
+
}
|
|
2740
|
+
} : {}
|
|
2741
|
+
}
|
|
2742
|
+
]
|
|
2743
|
+
};
|
|
2005
2744
|
}
|
|
2006
|
-
function
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
if (options.node?.length && !roster)
|
|
2010
|
-
throw new FleetUsageError("Cannot select nodes without a roster. Use fleet add first.");
|
|
2011
|
-
return roster ? selectNodes(roster, options.node ?? []) : [];
|
|
2745
|
+
function healthyDiscovery(data, count) {
|
|
2746
|
+
const health = data["Health"] ?? [];
|
|
2747
|
+
return health.length === 0 && count > 0;
|
|
2012
2748
|
}
|
|
2013
|
-
function
|
|
2014
|
-
|
|
2749
|
+
function validateStatusFields(data) {
|
|
2750
|
+
const health = data["Health"] ?? [];
|
|
2751
|
+
const peers = data["Peer"] ?? {};
|
|
2752
|
+
if (!record(data["Self"]) || !record(peers) || !Array.isArray(health))
|
|
2753
|
+
throw new Error("Unsupported Tailscale status");
|
|
2015
2754
|
}
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2755
|
+
function parseComputer(value, self) {
|
|
2756
|
+
if (!record(value))
|
|
2757
|
+
throw new Error("Invalid peer");
|
|
2758
|
+
const {
|
|
2759
|
+
ID: id,
|
|
2760
|
+
HostName: name,
|
|
2761
|
+
DNSName: dns,
|
|
2762
|
+
OS: os,
|
|
2763
|
+
TailscaleIPs: ips,
|
|
2764
|
+
Online: online
|
|
2765
|
+
} = value;
|
|
2766
|
+
if (![id, name, os].every((field) => typeof field === "string" && field.length > 0 && field.length <= 256 && !/[\x00-\x1f\x7f]/.test(field)) || typeof dns !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}\.?$/.test(dns) || !Array.isArray(ips) || ips.length < 1 || ips.length > 16 || !ips.every((ip) => typeof ip === "string" && isIP(ip) !== 0) || typeof online !== "boolean")
|
|
2767
|
+
throw new Error("Invalid peer fields");
|
|
2768
|
+
return {
|
|
2769
|
+
id,
|
|
2770
|
+
name,
|
|
2771
|
+
dns: dns.replace(/\.$/, "").toLowerCase(),
|
|
2772
|
+
os,
|
|
2773
|
+
ips,
|
|
2774
|
+
online,
|
|
2775
|
+
self
|
|
2776
|
+
};
|
|
2777
|
+
}
|
|
2778
|
+
function unavailable(summary) {
|
|
2779
|
+
return {
|
|
2780
|
+
computers: [],
|
|
2781
|
+
excludedMobile: 0,
|
|
2782
|
+
checks: [{ id: "tailscale:discovery", status: "fail", summary }]
|
|
2783
|
+
};
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
// packages/fleet/src/tailnet-matching.ts
|
|
2787
|
+
function matchesComputer(host, peer) {
|
|
2788
|
+
const name = host.toLowerCase().replace(/\.$/, "");
|
|
2789
|
+
return [peer.dns, peer.dns.split(".")[0], ...peer.ips].includes(name);
|
|
2790
|
+
}
|
|
2791
|
+
async function matchComputers(nodes, peers, run) {
|
|
2792
|
+
const matches = new Map(peers.map((peer) => [peer.id, []]));
|
|
2793
|
+
for (const node of nodes) {
|
|
2794
|
+
const resolved = await run([
|
|
2795
|
+
"ssh",
|
|
2796
|
+
"-G",
|
|
2797
|
+
"-o",
|
|
2798
|
+
"CanonicalizeHostname=no",
|
|
2799
|
+
"-o",
|
|
2800
|
+
"PermitLocalCommand=no",
|
|
2801
|
+
node.ssh
|
|
2802
|
+
], { timeoutMs: 5000 });
|
|
2803
|
+
const hostname = resolved.ok ? /^hostname ([^\r\n]+)$/im.exec(resolved.stdout)?.[1]?.trim() : undefined;
|
|
2804
|
+
const targets = peers.filter((peer) => matchesComputer(hostname ?? node.ssh, peer));
|
|
2805
|
+
for (const peer of targets)
|
|
2806
|
+
matches.get(peer.id).push(node);
|
|
2807
|
+
}
|
|
2808
|
+
return matches;
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
// packages/fleet/src/tailnet-health.ts
|
|
2812
|
+
var HEALTH_CAPABILITIES = [
|
|
2813
|
+
"github",
|
|
2814
|
+
"vault",
|
|
2815
|
+
"t3",
|
|
2816
|
+
"toolchain",
|
|
2817
|
+
"codex",
|
|
2818
|
+
"claude",
|
|
2819
|
+
"services",
|
|
2820
|
+
"workstation"
|
|
2821
|
+
];
|
|
2822
|
+
async function tailnetHealth(tailnet, nodes, options, run) {
|
|
2823
|
+
const controller = await probe(options.root, HEALTH_CAPABILITIES, run);
|
|
2824
|
+
const matches = await matchComputers(nodes, tailnet.computers, run);
|
|
2825
|
+
let source;
|
|
2826
|
+
const reports = await mapBounded(tailnet.computers, async (peer) => {
|
|
2827
|
+
const checks = await connectivity(peer, run);
|
|
2828
|
+
if (peer.self)
|
|
2829
|
+
return {
|
|
2830
|
+
node: peer.dns,
|
|
2831
|
+
snapshot: controller,
|
|
2832
|
+
checks: [...checks, ...controller.checks]
|
|
2833
|
+
};
|
|
2834
|
+
const candidates = matches.get(peer.id);
|
|
2835
|
+
if (!uniqueMapping(candidates, matches))
|
|
2836
|
+
return missingMapping(peer, checks, candidates.length);
|
|
2837
|
+
if (!peer.online)
|
|
2838
|
+
return { node: peer.dns, checks };
|
|
2839
|
+
const node = candidates[0];
|
|
2840
|
+
const report = await inspectComputer({
|
|
2841
|
+
...node,
|
|
2842
|
+
required: [...new Set([...node.required, ...HEALTH_CAPABILITIES])]
|
|
2843
|
+
}, {
|
|
2844
|
+
action: "probe",
|
|
2845
|
+
source: source ??= Promise.resolve().then(options.source),
|
|
2846
|
+
timeoutMs: options.timeoutMs
|
|
2847
|
+
}, run);
|
|
2848
|
+
return combine(peer, checks, report, controller);
|
|
2849
|
+
});
|
|
2850
|
+
const absent = nodes.filter((node) => ![...matches.values()].some((matched) => matched.includes(node)));
|
|
2851
|
+
return [
|
|
2852
|
+
{ node: "tailscale", checks: tailnet.checks },
|
|
2853
|
+
...!tailnet.computers.some((peer) => peer.self) ? [
|
|
2854
|
+
{
|
|
2855
|
+
node: "controller",
|
|
2856
|
+
snapshot: controller,
|
|
2857
|
+
checks: controller.checks
|
|
2858
|
+
}
|
|
2859
|
+
] : [],
|
|
2860
|
+
...reports,
|
|
2861
|
+
...absent.map((node) => ({
|
|
2862
|
+
node: node.id,
|
|
2863
|
+
checks: [
|
|
2864
|
+
{
|
|
2865
|
+
id: "tailscale:membership",
|
|
2866
|
+
status: "fail",
|
|
2867
|
+
summary: "Registered node is absent from visible Tailscale inventory. It was not silently omitted.",
|
|
2868
|
+
remedy: {
|
|
2869
|
+
action: "Check the node account, tailnet visibility, and the registered SSH alias."
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
]
|
|
2873
|
+
}))
|
|
2874
|
+
];
|
|
2875
|
+
}
|
|
2876
|
+
function uniqueMapping(candidates, matches) {
|
|
2877
|
+
return candidates.length === 1 && [...matches.values()].filter((nodes) => nodes.includes(candidates[0])).length === 1;
|
|
2878
|
+
}
|
|
2879
|
+
async function inspectComputer(node, options, run) {
|
|
2880
|
+
try {
|
|
2881
|
+
return await inspectRemote(node, { ...options, source: await options.source }, run);
|
|
2882
|
+
} catch {
|
|
2883
|
+
return {
|
|
2884
|
+
node: node.id,
|
|
2885
|
+
checks: [
|
|
2886
|
+
{
|
|
2887
|
+
id: "fleet:probe",
|
|
2888
|
+
status: "fail",
|
|
2889
|
+
summary: "The current remote probe could not be prepared or completed; this computer remains unverified.",
|
|
2890
|
+
remedy: {
|
|
2891
|
+
action: "Reinstall the validated Personal CLI package and retry. No raw exception or remote output is retained."
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
]
|
|
2895
|
+
};
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
async function connectivity(peer, run) {
|
|
2899
|
+
const online = {
|
|
2900
|
+
id: "tailscale:online",
|
|
2901
|
+
status: peer.online ? "pass" : "fail",
|
|
2902
|
+
summary: peer.online ? "Computer is online in Tailscale status." : "Computer is offline or Tailscale cannot confirm its online state."
|
|
2903
|
+
};
|
|
2904
|
+
if (peer.self || !peer.online)
|
|
2905
|
+
return [online];
|
|
2906
|
+
const ping = await run(["tailscale", "ping", "--c=1", "--timeout=5s", peer.ips[0]], { timeoutMs: 8000 });
|
|
2907
|
+
return [
|
|
2908
|
+
online,
|
|
2909
|
+
{
|
|
2910
|
+
id: "tailscale:reachability",
|
|
2911
|
+
status: ping.ok ? "pass" : "fail",
|
|
2912
|
+
summary: ping.ok ? "Tailscale peer answered a bounded reachability probe." : "Tailscale peer did not answer; online inventory is not proof of connectivity."
|
|
2913
|
+
}
|
|
2914
|
+
];
|
|
2915
|
+
}
|
|
2916
|
+
function missingMapping(peer, checks, count) {
|
|
2917
|
+
return {
|
|
2918
|
+
node: peer.dns,
|
|
2919
|
+
checks: [
|
|
2920
|
+
...checks,
|
|
2921
|
+
{
|
|
2922
|
+
id: "fleet:registration",
|
|
2923
|
+
status: "fail",
|
|
2924
|
+
summary: count ? "Roster mapping is ambiguous between entries or computers; no SSH probe was run." : "Computer is not registered with a verified SSH alias and checkout path; dependencies and authentication are unverified.",
|
|
2925
|
+
remedy: {
|
|
2926
|
+
action: "Use Fleet: Register a developer computer with its existing SSH alias and actual checkout path, then rerun Fleet: Check all Tailscale computers."
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
]
|
|
2930
|
+
};
|
|
2931
|
+
}
|
|
2932
|
+
function combine(peer, checks, report, reference) {
|
|
2933
|
+
return {
|
|
2934
|
+
...report,
|
|
2935
|
+
node: peer.dns,
|
|
2936
|
+
checks: [
|
|
2937
|
+
...checks,
|
|
2938
|
+
...report.checks,
|
|
2939
|
+
...report.snapshot ? compareFingerprints(reference.fingerprint, report.snapshot.fingerprint) : []
|
|
2940
|
+
]
|
|
2941
|
+
};
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
// packages/personal-cli/src/tailnet.ts
|
|
2945
|
+
async function executeTailnet(command, options, settings, services) {
|
|
2946
|
+
const tailnet = await discoverTailnet(services.run);
|
|
2947
|
+
const coverage = {
|
|
2948
|
+
scope: "tailscale-visible-computers",
|
|
2949
|
+
computers: tailnet.computers.length,
|
|
2950
|
+
excludedMobile: tailnet.excludedMobile,
|
|
2951
|
+
limitation: "This controller can inspect only devices visible to its Tailscale account/ACLs. Other tailnets, hidden devices and all-pairs connectivity are not verified."
|
|
2952
|
+
};
|
|
2953
|
+
if (command === "discover") {
|
|
2954
|
+
const ok = tailnet.checks.every((check) => check.status === "pass");
|
|
2955
|
+
return {
|
|
2956
|
+
exitCode: ok ? 0 : 1,
|
|
2957
|
+
data: {
|
|
2958
|
+
version: 1,
|
|
2959
|
+
command,
|
|
2960
|
+
ok,
|
|
2961
|
+
coverage,
|
|
2962
|
+
computers: tailnet.computers,
|
|
2963
|
+
checks: tailnet.checks
|
|
2964
|
+
}
|
|
2965
|
+
};
|
|
2966
|
+
}
|
|
2967
|
+
const roster = await readRoster(settings.config);
|
|
2968
|
+
const nodes = await tailnetHealth(tailnet, roster?.nodes ?? [], {
|
|
2969
|
+
root: settings.workspace,
|
|
2970
|
+
timeoutMs: timeout({ ...options, timeout: options.timeout ?? "300000" }),
|
|
2971
|
+
source: services.source
|
|
2972
|
+
}, services.run);
|
|
2973
|
+
const report = makeReport("health", nodes);
|
|
2974
|
+
const exitCode = reportExitCode(report, true);
|
|
2975
|
+
const verified = exitCode === 0;
|
|
2976
|
+
return {
|
|
2977
|
+
exitCode,
|
|
2978
|
+
data: {
|
|
2979
|
+
...report,
|
|
2980
|
+
ok: verified,
|
|
2981
|
+
coverage: {
|
|
2982
|
+
...coverage,
|
|
2983
|
+
verified,
|
|
2984
|
+
inspected: nodes.filter((node) => node.snapshot).length
|
|
2985
|
+
}
|
|
2986
|
+
},
|
|
2987
|
+
text: `${renderReport({ ...report, ok: verified })}
|
|
2988
|
+
Coverage: ${coverage.computers} visible computers; ${coverage.excludedMobile} mobile devices excluded.
|
|
2989
|
+
${coverage.limitation}`
|
|
2990
|
+
};
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
// packages/personal-cli/src/probe-source.ts
|
|
2994
|
+
import { readFile as readFile5 } from "node:fs/promises";
|
|
2995
|
+
async function probeSource() {
|
|
2996
|
+
if (typeof Bun !== "undefined")
|
|
2997
|
+
return bundleProbe();
|
|
2998
|
+
return readFile5(new URL("./probe.mjs", import.meta.url), "utf8");
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
// packages/fleet/src/repair-catalog.ts
|
|
3002
|
+
var login = (id, title, commands, action) => ({
|
|
3003
|
+
id: `login:${id}`,
|
|
3004
|
+
title,
|
|
3005
|
+
owner: `${id} native CLI`,
|
|
3006
|
+
automatic: false,
|
|
3007
|
+
action,
|
|
3008
|
+
commands
|
|
3009
|
+
});
|
|
3010
|
+
var REPAIR_RECIPES = [
|
|
3011
|
+
{
|
|
3012
|
+
id: "workspace",
|
|
3013
|
+
title: "Install locked tools and frozen dependencies",
|
|
3014
|
+
owner: "mise / scripts/dev/setup.ts",
|
|
3015
|
+
automatic: true,
|
|
3016
|
+
action: "Runs the existing shared setup and trusted checkout lifecycle scripts. Updates user mise/PATH configuration and writes a sync receipt. Does not pull or reset Git.",
|
|
3017
|
+
commands: [
|
|
3018
|
+
[
|
|
3019
|
+
"mise",
|
|
3020
|
+
"exec",
|
|
3021
|
+
"--locked",
|
|
3022
|
+
"bun",
|
|
3023
|
+
"--",
|
|
3024
|
+
"bun",
|
|
3025
|
+
"scripts/dev/setup.ts",
|
|
3026
|
+
"--non-interactive"
|
|
3027
|
+
]
|
|
3028
|
+
]
|
|
3029
|
+
},
|
|
3030
|
+
{
|
|
3031
|
+
id: "workstation",
|
|
3032
|
+
title: "Install launcher and converge managed workstation configuration",
|
|
3033
|
+
owner: "workstation",
|
|
3034
|
+
automatic: true,
|
|
3035
|
+
action: "Runs personal:install --yes: frozen install, launcher/PATH, managed links, notifications and provider configuration. Reads available provider keys from this machine’s Vault session and writes owner-managed 0600 configuration. Conflicting unmanaged files fail.",
|
|
3036
|
+
commands: [["bun", "run", "personal:install", "--yes"]]
|
|
3037
|
+
},
|
|
3038
|
+
{
|
|
3039
|
+
id: "providers",
|
|
3040
|
+
title: "Refresh shared provider configuration from Vault",
|
|
3041
|
+
owner: "workstation / secret-store",
|
|
3042
|
+
automatic: true,
|
|
3043
|
+
action: "Uses this machine’s own Vault authorization to fetch the existing permitted provider keys. The workstation owner writes its 0600 tool configuration; no credentials are copied from another computer. Missing provider keys may remain skipped.",
|
|
3044
|
+
commands: [
|
|
3045
|
+
[
|
|
3046
|
+
"bun",
|
|
3047
|
+
"packages/workstation/cli/internal.ts",
|
|
3048
|
+
"opencode",
|
|
3049
|
+
"sync",
|
|
3050
|
+
"--strict",
|
|
3051
|
+
"--json",
|
|
3052
|
+
"--non-interactive",
|
|
3053
|
+
"--yes"
|
|
3054
|
+
]
|
|
3055
|
+
]
|
|
3056
|
+
},
|
|
3057
|
+
login("codex", "Sign in to Codex on this machine", [["codex", "login", "--device-auth"]], "Approve the device flow in your browser. Run codex login status afterward; retain this machine’s own credential store."),
|
|
3058
|
+
login("claude", "Sign in to Claude Code on this machine", [["claude", "auth", "login"]], "Complete the native browser login, then run claude auth status --json. API-key and subscription contexts can differ."),
|
|
3059
|
+
login("github", "Sign in to GitHub and configure Git", [
|
|
3060
|
+
[
|
|
3061
|
+
"gh",
|
|
3062
|
+
"auth",
|
|
3063
|
+
"login",
|
|
3064
|
+
"--hostname",
|
|
3065
|
+
"github.com",
|
|
3066
|
+
"--web",
|
|
3067
|
+
"--git-protocol",
|
|
3068
|
+
"https"
|
|
3069
|
+
],
|
|
3070
|
+
["gh", "auth", "setup-git"]
|
|
3071
|
+
], "Approve GitHub in the browser on the intended account, then run gh auth status --active. Environment tokens can override stored login; inspect their source locally."),
|
|
3072
|
+
login("vault", "Authorize this machine for the shared Vault", [], "Run vault login -method=userpass username=YOUR_USERNAME in your own terminal. The native CLI prompts privately for the password. Use the existing permitted project/config; never copy VAULT_TOKEN between computers."),
|
|
3073
|
+
login("railway", "Sign in to Railway", [["railway", "login", "--browserless"]], "Complete the browserless native flow, then run railway whoami --json."),
|
|
3074
|
+
login("vercel", "Sign in to Vercel", [["vercel", "login"]], "Complete native login, then run vercel whoami. Select the intended team/project separately; do not create resources as a login repair."),
|
|
3075
|
+
login("cloudflare", "Sign in to Cloudflare", [["wrangler", "login", "--browser=false"]], "Complete native OAuth login, then run wrangler whoami --json. For headless SSH, prefer an existing scoped API token injected by the approved Vault config. --browser=false suppresses browser launch; it does not make a remote loopback callback reachable. Any OAuth callback tunnel needs explicit operator setup. Resource access still depends on account permissions."),
|
|
3076
|
+
login("aws", "Refresh the selected AWS SSO session", [["aws", "sso", "login"]], "Configure the intended profile with aws configure sso first if needed. Login and the health check use this shell’s current profile; no access keys are cloned."),
|
|
3077
|
+
login("npm", "Sign in to npm on this machine", [
|
|
3078
|
+
[
|
|
3079
|
+
"npm",
|
|
3080
|
+
"login",
|
|
3081
|
+
"--auth-type=web",
|
|
3082
|
+
"--registry=https://registry.npmjs.org/"
|
|
3083
|
+
]
|
|
3084
|
+
], "Complete the specific CLI approval and run npm whoami. Prefer the existing trusted-publisher CI workflow for releases."),
|
|
3085
|
+
login("opencode", "Authorize an OpenCode provider", [["opencode", "auth", "login"]], "Prefer the existing Vault-backed providers repair for managed API keys. Native auth login handles an operator-owned OAuth provider. Credential listing alone does not prove live provider access."),
|
|
3086
|
+
login("copilot", "Sign in to Copilot", [["copilot", "login"]], "Complete native login; provider authorization still needs supported operator diagnostics. No model prompt is used for health checking."),
|
|
3087
|
+
login("gemini", "Sign in to Gemini", [], "Open Gemini interactively and choose its supported sign-in flow. Provider authorization remains unverified without a supported read-only status adapter."),
|
|
3088
|
+
{
|
|
3089
|
+
id: "t3",
|
|
3090
|
+
title: "Enable and authorize T3 Connect",
|
|
3091
|
+
owner: "T3",
|
|
3092
|
+
automatic: false,
|
|
3093
|
+
action: "Run t3 up --connect in this machine’s terminal and approve the intended connection. Treat pairing material as private. Then check t3 status --json and perform an operator peer test.",
|
|
3094
|
+
commands: [["t3", "up", "--connect"]]
|
|
3095
|
+
},
|
|
3096
|
+
{
|
|
3097
|
+
id: "docker",
|
|
3098
|
+
title: "Start the intended Docker runtime",
|
|
3099
|
+
owner: "OS / Docker",
|
|
3100
|
+
automatic: false,
|
|
3101
|
+
action: "Start Docker Desktop on desktop systems, or the already-installed Docker service using the OS service manager on Linux. Confirm the intended context with docker context ls. This repair never installs a daemon, invokes sudo, changes contexts or exposes a TCP socket.",
|
|
3102
|
+
commands: [
|
|
3103
|
+
["docker", "context", "ls"],
|
|
3104
|
+
["docker", "info"]
|
|
3105
|
+
]
|
|
3106
|
+
},
|
|
3107
|
+
{
|
|
3108
|
+
id: "password-manager",
|
|
3109
|
+
title: "Review shared human login onboarding",
|
|
3110
|
+
owner: "password-manager / Bitwarden",
|
|
3111
|
+
automatic: false,
|
|
3112
|
+
action: "Use the existing password-manager doctor and actions list on this computer. Its setup guides native consent and Bitwarden enrollment. Keep its private state and OS credential store local. Password vault access does not authorize native CLI OAuth sessions; imports and rotations remain separate reviewed workflows.",
|
|
3113
|
+
commands: [
|
|
3114
|
+
["bun", "run", "password-manager", "doctor"],
|
|
3115
|
+
["bun", "run", "password-manager", "actions", "list"]
|
|
3116
|
+
]
|
|
3117
|
+
},
|
|
3118
|
+
{
|
|
3119
|
+
id: "operator",
|
|
3120
|
+
title: "Resolve the check with its owning tool",
|
|
3121
|
+
owner: "operator / existing owner",
|
|
3122
|
+
automatic: false,
|
|
3123
|
+
action: "Follow the check’s remedy on the affected machine. Verify host fingerprints, register real SSH aliases/checkouts, reconcile Git changes, or perform the named end-to-end test. Infrastructure changes remain OpenTofu-owned.",
|
|
3124
|
+
commands: []
|
|
3125
|
+
}
|
|
3126
|
+
];
|
|
3127
|
+
var AUTOMATED_REPAIRS = [
|
|
3128
|
+
"workspace",
|
|
3129
|
+
"workstation",
|
|
3130
|
+
"providers"
|
|
3131
|
+
];
|
|
3132
|
+
function recipe(id) {
|
|
3133
|
+
return REPAIR_RECIPES.find((entry) => entry.id === id) ?? REPAIR_RECIPES.find((entry) => entry.id === "operator");
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
// packages/fleet/src/repair-plan.ts
|
|
3137
|
+
function repairId(check) {
|
|
3138
|
+
if (check.id.startsWith("tool:docker-"))
|
|
3139
|
+
return "docker";
|
|
3140
|
+
if (check.id.startsWith("tool:github-auth"))
|
|
3141
|
+
return "login:github";
|
|
3142
|
+
if (["dependencies", "auth:toolchain"].includes(check.id))
|
|
3143
|
+
return "workspace";
|
|
3144
|
+
if (check.id.startsWith("tool:"))
|
|
3145
|
+
return "workspace";
|
|
3146
|
+
if (check.id.startsWith("workstation:"))
|
|
3147
|
+
return "workstation";
|
|
3148
|
+
if (check.id.startsWith("auth:") && !["auth:vscode", "auth:workstation"].includes(check.id))
|
|
3149
|
+
return `login:${check.id.slice(5)}`;
|
|
3150
|
+
if (check.id === "t3:settings" || check.id === "t3:api")
|
|
3151
|
+
return "t3";
|
|
3152
|
+
return "operator";
|
|
3153
|
+
}
|
|
3154
|
+
function repairPlan(reports, targets) {
|
|
3155
|
+
const steps = [];
|
|
3156
|
+
for (const report of reports) {
|
|
3157
|
+
const groups = new Map;
|
|
3158
|
+
for (const check of report.checks.filter((entry) => entry.status !== "pass")) {
|
|
3159
|
+
const repair = recipe(repairId(check)).id;
|
|
3160
|
+
groups.set(repair, [...groups.get(repair) ?? [], check]);
|
|
3161
|
+
}
|
|
3162
|
+
for (const [id, checks] of groups)
|
|
3163
|
+
steps.push({
|
|
3164
|
+
...recipe(id),
|
|
3165
|
+
node: report.node,
|
|
3166
|
+
eligible: targets.some((target) => target.name === report.node) && report.snapshot !== undefined,
|
|
3167
|
+
checks: checks.map((check) => check.id),
|
|
3168
|
+
next: [
|
|
3169
|
+
...new Set(checks.flatMap((check) => check.remedy ? [check.remedy.action] : [check.summary]))
|
|
3170
|
+
]
|
|
3171
|
+
});
|
|
3172
|
+
}
|
|
3173
|
+
return steps;
|
|
3174
|
+
}
|
|
3175
|
+
function automaticRepairs(steps, node) {
|
|
3176
|
+
return AUTOMATED_REPAIRS.flatMap((id) => steps.filter((step) => step.node === node && step.id === id && step.automatic && step.eligible));
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
// packages/personal-cli/src/repair-inspect.ts
|
|
3180
|
+
async function inspectRepair(options, settings, services) {
|
|
3181
|
+
if (options.local)
|
|
3182
|
+
return inspectLocal(settings, services);
|
|
3183
|
+
const roster = await readRoster(settings.config);
|
|
3184
|
+
if (options.node?.length)
|
|
3185
|
+
return inspectSelected(options, roster, services);
|
|
3186
|
+
return inspectTailnet(options, settings, roster, services);
|
|
3187
|
+
}
|
|
3188
|
+
async function inspectLocal(settings, services) {
|
|
3189
|
+
const snapshot = await probe(settings.workspace, HEALTH_CAPABILITIES, services.run);
|
|
3190
|
+
return {
|
|
3191
|
+
reports: [{ node: "local", snapshot, checks: snapshot.checks }],
|
|
3192
|
+
targets: [
|
|
3193
|
+
{
|
|
3194
|
+
name: "local",
|
|
3195
|
+
root: settings.workspace,
|
|
3196
|
+
fingerprint: snapshot.fingerprint
|
|
3197
|
+
}
|
|
3198
|
+
],
|
|
3199
|
+
coverage: {
|
|
3200
|
+
scope: "local",
|
|
3201
|
+
limitation: "Only this process context was tested."
|
|
3202
|
+
}
|
|
3203
|
+
};
|
|
3204
|
+
}
|
|
3205
|
+
async function inspectSelected(options, roster, services) {
|
|
3206
|
+
if (!roster)
|
|
3207
|
+
throw new FleetUsageError("Register real SSH aliases and checkout paths before selecting remote repairs.");
|
|
3208
|
+
const nodes = selectNodes(roster, options.node ?? []);
|
|
3209
|
+
const source = await services.source();
|
|
3210
|
+
const reports = await mapBounded(nodes, (node) => inspectRemote({
|
|
3211
|
+
...node,
|
|
3212
|
+
required: [...new Set([...node.required, ...HEALTH_CAPABILITIES])]
|
|
3213
|
+
}, { action: "probe", source, timeoutMs: timeout(options) }, services.run));
|
|
3214
|
+
return {
|
|
3215
|
+
reports,
|
|
3216
|
+
targets: nodes.map((node) => attachFingerprint({ name: node.id, root: node.workspace, node }, reports)),
|
|
3217
|
+
coverage: {
|
|
3218
|
+
scope: "selected",
|
|
3219
|
+
limitation: "Only selected roster computers were tested; other devices are unverified."
|
|
3220
|
+
}
|
|
3221
|
+
};
|
|
3222
|
+
}
|
|
3223
|
+
async function inspectTailnet(options, settings, roster, services) {
|
|
3224
|
+
const tailnet = await discoverTailnet(services.run);
|
|
3225
|
+
const reports = await tailnetHealth(tailnet, roster?.nodes ?? [], {
|
|
3226
|
+
root: settings.workspace,
|
|
3227
|
+
timeoutMs: timeout(options),
|
|
3228
|
+
source: services.source
|
|
3229
|
+
}, services.run);
|
|
3230
|
+
const matches = await matchComputers(roster?.nodes ?? [], tailnet.computers, services.run);
|
|
3231
|
+
const targets = [];
|
|
3232
|
+
for (const peer of tailnet.computers) {
|
|
3233
|
+
if (peer.self)
|
|
3234
|
+
targets.push({ name: peer.dns, root: settings.workspace });
|
|
3235
|
+
else if (peer.online && uniqueMapping(matches.get(peer.id), matches)) {
|
|
3236
|
+
const node = matches.get(peer.id)[0];
|
|
3237
|
+
targets.push({ name: peer.dns, root: node.workspace, node });
|
|
3238
|
+
}
|
|
3239
|
+
}
|
|
3240
|
+
if (!tailnet.computers.some((peer) => peer.self))
|
|
3241
|
+
targets.push({ name: "controller", root: settings.workspace });
|
|
3242
|
+
return {
|
|
3243
|
+
reports,
|
|
3244
|
+
targets: targets.map((target) => attachFingerprint(target, reports)),
|
|
3245
|
+
coverage: {
|
|
3246
|
+
scope: "tailscale-visible-computers",
|
|
3247
|
+
limitation: "Only devices visible to this Tailscale account/ACLs. Offline, unregistered or ambiguous computers cannot be repaired. No credentials, host keys or resources are cloned."
|
|
3248
|
+
}
|
|
3249
|
+
};
|
|
3250
|
+
}
|
|
3251
|
+
function attachFingerprint(target, reports) {
|
|
3252
|
+
const snapshot = reports.find((report) => report.node === target.name)?.snapshot;
|
|
3253
|
+
return {
|
|
3254
|
+
...target,
|
|
3255
|
+
...snapshot ? { fingerprint: snapshot.fingerprint } : {}
|
|
3256
|
+
};
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
// packages/fleet/src/repair-runner.ts
|
|
3260
|
+
function validRepairs(value) {
|
|
3261
|
+
return Array.isArray(value) && value.length > 0 && value.length <= 3 && new Set(value).size === value.length && value.every((id) => AUTOMATED_REPAIRS.includes(id));
|
|
3262
|
+
}
|
|
3263
|
+
async function repairWorkspace(root, repairs, run, expected) {
|
|
3264
|
+
if (!validRepairs(repairs))
|
|
3265
|
+
throw new Error("Unsupported repairs");
|
|
3266
|
+
const before = await fingerprint(root, run);
|
|
3267
|
+
if (!before.head || !Object.values(before.files).every(Boolean))
|
|
3268
|
+
return [
|
|
3269
|
+
{
|
|
3270
|
+
id: "repair:workspace",
|
|
3271
|
+
status: "fail",
|
|
3272
|
+
summary: "Repairs require a complete existing checkout. Register/bootstrap it first."
|
|
3273
|
+
}
|
|
3274
|
+
];
|
|
3275
|
+
if (expected && JSON.stringify(before) !== JSON.stringify(expected))
|
|
3276
|
+
return [
|
|
3277
|
+
{
|
|
3278
|
+
id: "repair:inputs",
|
|
3279
|
+
status: "fail",
|
|
3280
|
+
summary: "Checkout inputs changed since diagnostics. Generate a fresh repair plan before applying."
|
|
3281
|
+
}
|
|
3282
|
+
];
|
|
3283
|
+
const checks = [];
|
|
3284
|
+
for (const id of AUTOMATED_REPAIRS.filter((entry) => repairs.includes(entry))) {
|
|
3285
|
+
const result = id === "workspace" ? await syncWorkspace(root, false, run) : await ownerCommand(root, id, run);
|
|
3286
|
+
checks.push(...result);
|
|
3287
|
+
if (result.some((check) => check.status !== "pass"))
|
|
3288
|
+
break;
|
|
3289
|
+
}
|
|
3290
|
+
const after = await fingerprint(root, run);
|
|
3291
|
+
if (JSON.stringify(before) !== JSON.stringify(after))
|
|
3292
|
+
checks.push({
|
|
3293
|
+
id: "repair:inputs",
|
|
3294
|
+
status: "fail",
|
|
3295
|
+
summary: "Checkout inputs changed during repair. Stop and review the checkout before retrying."
|
|
3296
|
+
});
|
|
3297
|
+
return checks;
|
|
3298
|
+
}
|
|
3299
|
+
async function ownerCommand(root, id, run) {
|
|
3300
|
+
const result = await run(recipe(id).commands[0], {
|
|
3301
|
+
cwd: root,
|
|
3302
|
+
timeoutMs: 1200000
|
|
3303
|
+
});
|
|
3304
|
+
return [
|
|
3305
|
+
{
|
|
3306
|
+
id: `repair:${id}`,
|
|
3307
|
+
status: result.ok ? "pass" : "fail",
|
|
3308
|
+
summary: result.ok ? "The existing owner command completed. Fresh diagnostics determine whether configuration and authorization now pass." : `The owner command failed (${result.reason}); partial changes are possible. Raw output and credentials are withheld. Later repairs on this computer were stopped.`
|
|
3309
|
+
}
|
|
3310
|
+
];
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
// packages/personal-cli/src/repair-apply.ts
|
|
3314
|
+
async function applyRepairs(targets, steps, options, services) {
|
|
3315
|
+
let source;
|
|
3316
|
+
return mapBounded(targets, async (target) => {
|
|
3317
|
+
const actions = automaticRepairs(steps, target.name);
|
|
3318
|
+
const checks = [];
|
|
3319
|
+
for (const step of actions) {
|
|
3320
|
+
try {
|
|
3321
|
+
if (!target.fingerprint)
|
|
3322
|
+
throw new Error("No verified checkout fingerprint");
|
|
3323
|
+
const repairs = [step.id];
|
|
3324
|
+
const result = target.node ? (await inspectRemote(target.node, {
|
|
3325
|
+
action: "repair",
|
|
3326
|
+
repairs,
|
|
3327
|
+
expected: target.fingerprint,
|
|
3328
|
+
source: await (source ??= Promise.resolve().then(services.source)),
|
|
3329
|
+
timeoutMs: timeout(options)
|
|
3330
|
+
}, services.run)).checks : await repairWorkspace(target.root, repairs, services.run, target.fingerprint);
|
|
3331
|
+
checks.push(...result);
|
|
3332
|
+
if (result.some((check) => check.status !== "pass"))
|
|
3333
|
+
break;
|
|
3334
|
+
} catch {
|
|
3335
|
+
checks.push({
|
|
3336
|
+
id: "repair:operation",
|
|
3337
|
+
status: "fail",
|
|
3338
|
+
summary: "Repair could not be prepared or completed. Later actions on this computer were stopped; inspect partial changes locally. Raw errors are withheld."
|
|
3339
|
+
});
|
|
3340
|
+
break;
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
return { node: target.name, checks };
|
|
3344
|
+
}, 1);
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3347
|
+
// packages/personal-cli/src/repair-script.ts
|
|
3348
|
+
function targetCommand(target, commands) {
|
|
3349
|
+
const command = `cd -- ${shellQuote(target.root)} && ${commands.map((args) => args.map(shellQuote).join(" ")).join(" && ")}`;
|
|
3350
|
+
if (!target.node)
|
|
3351
|
+
return command;
|
|
3352
|
+
return [
|
|
3353
|
+
...sshArgs(target.node).map((arg) => arg === "-T" ? "-t" : arg).map(shellQuote),
|
|
3354
|
+
shellQuote(command)
|
|
3355
|
+
].join(" ");
|
|
3356
|
+
}
|
|
3357
|
+
function repairScript(steps, targets) {
|
|
3358
|
+
const lines = [
|
|
3359
|
+
"#!/bin/sh",
|
|
3360
|
+
"set -eu",
|
|
3361
|
+
"# Generated repair plan. Review before running in your own terminal.",
|
|
3362
|
+
"# Automated steps use existing owners. Native login commands below are commented and require operator approval.",
|
|
3363
|
+
"# POSIX shell required; Windows operators can use the personal commands directly."
|
|
3364
|
+
];
|
|
3365
|
+
for (const target of targets) {
|
|
3366
|
+
const actions = automaticRepairs(steps, target.name);
|
|
3367
|
+
if (!actions.length)
|
|
3368
|
+
continue;
|
|
3369
|
+
const commands = actions.flatMap((step) => step.id === "workspace" ? [
|
|
3370
|
+
[
|
|
3371
|
+
"bun",
|
|
3372
|
+
"packages/fleet/src/cli.ts",
|
|
3373
|
+
"sync",
|
|
3374
|
+
"--local",
|
|
3375
|
+
"--apply",
|
|
3376
|
+
"--yes",
|
|
3377
|
+
"--json"
|
|
3378
|
+
]
|
|
3379
|
+
] : recipe(step.id).commands);
|
|
3380
|
+
lines.push("", targetCommand(target, commands));
|
|
3381
|
+
}
|
|
3382
|
+
for (const step of steps.filter((entry) => !entry.automatic || !entry.eligible)) {
|
|
3383
|
+
lines.push("", `# ${comment(step.node)} / ${comment(step.title)}`, `# ${comment(step.action)}`);
|
|
3384
|
+
const target = targets.find((entry) => entry.name === step.node);
|
|
3385
|
+
if (target && step.eligible && step.commands.length)
|
|
3386
|
+
lines.push(`# ${targetCommand(target, step.commands)}`);
|
|
3387
|
+
else
|
|
3388
|
+
for (const next of step.next)
|
|
3389
|
+
lines.push(`# ${comment(next)}`);
|
|
3390
|
+
}
|
|
3391
|
+
lines.push("", "# Re-run personal fleet health --json for fresh whole-tailnet coverage.");
|
|
3392
|
+
return `${lines.join(`
|
|
3393
|
+
`)}
|
|
3394
|
+
`;
|
|
3395
|
+
}
|
|
3396
|
+
function comment(value) {
|
|
3397
|
+
return value.replace(/[\r\n\x00-\x1f\x7f]/g, " ");
|
|
3398
|
+
}
|
|
3399
|
+
|
|
3400
|
+
// packages/personal-cli/src/repair-guard.ts
|
|
3401
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
3402
|
+
function repairPlanHash(steps, targets) {
|
|
3403
|
+
return createHash3("sha256").update(JSON.stringify({ steps, targets })).digest("hex");
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
// packages/personal-cli/src/repair.ts
|
|
3407
|
+
function requestedRepairs(value) {
|
|
3408
|
+
if (value === undefined)
|
|
3409
|
+
return;
|
|
3410
|
+
const ids = value.split(",");
|
|
3411
|
+
if (!ids.length || ids.some((id) => !AUTOMATED_REPAIRS.includes(id)))
|
|
3412
|
+
throw new FleetUsageError("--repair expects workspace,workstation,providers. Native logins require operator approval; inspect fleet repairs.");
|
|
3413
|
+
return [...new Set(ids)];
|
|
3414
|
+
}
|
|
3415
|
+
function repairCatalog() {
|
|
3416
|
+
return {
|
|
3417
|
+
exitCode: 0,
|
|
3418
|
+
data: {
|
|
3419
|
+
version: 1,
|
|
3420
|
+
command: "repairs",
|
|
3421
|
+
ok: true,
|
|
3422
|
+
recipes: REPAIR_RECIPES,
|
|
3423
|
+
credentials: [
|
|
3424
|
+
"Use the existing password-manager / Bitwarden owner for human passwords and shared login items. Run bun run password-manager doctor and actions list on each machine; never synchronize its private state.",
|
|
3425
|
+
"Authenticate each computer independently with the intended identity. Never clone OAuth caches, browser cookies, keychains, SSH private keys or Vault sessions.",
|
|
3426
|
+
"Access shared permitted API keys through this machine’s existing Vault/secret-store session. Prefer vault run --config dev -- COMMAND for process-scoped injection.",
|
|
3427
|
+
"The workstation owner can render its supported provider configuration from Vault with 0600 permissions. Missing provider keys and live provider access still require checks.",
|
|
3428
|
+
"Service roles, shared resources, Vault policies and infrastructure remain OpenTofu-owned. A login does not grant every account/team/resource permission."
|
|
3429
|
+
]
|
|
3430
|
+
}
|
|
3431
|
+
};
|
|
3432
|
+
}
|
|
3433
|
+
async function executeRepairCommand(command, options, settings, services) {
|
|
3434
|
+
return command === "repairs" ? repairCatalog() : executeRepair(options, settings, services);
|
|
3435
|
+
}
|
|
3436
|
+
async function executeRepair(options, settings, services) {
|
|
3437
|
+
const selected = requestedRepairs(options.repair);
|
|
3438
|
+
const before = await inspectRepair(options, settings, services);
|
|
3439
|
+
const steps = selectRepairs(repairPlan(before.reports, before.targets), before, selected);
|
|
3440
|
+
const planHash = repairPlanHash(steps, before.targets);
|
|
3441
|
+
if (options.planHash && options.planHash !== planHash)
|
|
3442
|
+
throw new FleetUsageError("Repair plan changed since review. Generate and review a fresh preview; nothing was applied.");
|
|
3443
|
+
const initial = makeReport("repair", before.reports);
|
|
3444
|
+
const healthOk = reportExitCode(initial, true) === 0;
|
|
3445
|
+
const script = options.script ? repairScript(steps, before.targets) : undefined;
|
|
3446
|
+
if (!options.apply)
|
|
3447
|
+
return {
|
|
3448
|
+
exitCode: 0,
|
|
3449
|
+
data: {
|
|
3450
|
+
...initial,
|
|
3451
|
+
ok: true,
|
|
3452
|
+
applied: false,
|
|
3453
|
+
planHash,
|
|
3454
|
+
healthOk,
|
|
3455
|
+
coverage: before.coverage,
|
|
3456
|
+
steps,
|
|
3457
|
+
...script ? { script } : {},
|
|
3458
|
+
authorize: "Preview only. Review effects, then repeat with --apply --yes. Only eligible automatic steps execute. Native logins and manual steps remain operator work."
|
|
3459
|
+
},
|
|
3460
|
+
text: script ?? renderSteps(steps)
|
|
3461
|
+
};
|
|
3462
|
+
return appliedOutcome(steps, before, { options, settings, services });
|
|
3463
|
+
}
|
|
3464
|
+
async function appliedOutcome(steps, before, context) {
|
|
3465
|
+
const { options, settings, services } = context;
|
|
3466
|
+
const executions = await applyRepairs(before.targets, steps, options, services);
|
|
3467
|
+
const after = await recheckRepair(before, options, settings, services);
|
|
3468
|
+
const checked = makeReport("repair", after.reports);
|
|
3469
|
+
const executionOk = executions.every((node) => node.checks.every((check) => check.status === "pass"));
|
|
3470
|
+
const verified = reportExitCode(checked, true) === 0;
|
|
3471
|
+
const ok = executionOk && verified;
|
|
3472
|
+
return {
|
|
3473
|
+
exitCode: ok ? 0 : 1,
|
|
3474
|
+
data: {
|
|
3475
|
+
...checked,
|
|
3476
|
+
ok,
|
|
3477
|
+
applied: true,
|
|
3478
|
+
executionOk,
|
|
3479
|
+
healthOk: verified,
|
|
3480
|
+
coverage: after.coverage,
|
|
3481
|
+
steps,
|
|
3482
|
+
executions,
|
|
3483
|
+
remaining: repairPlan(after.reports, after.targets),
|
|
3484
|
+
completed: executions.flatMap((node) => node.checks).filter((check) => check.status === "pass").length
|
|
3485
|
+
},
|
|
3486
|
+
text: `Repair commands ${executionOk ? "completed" : "need attention"}; fresh health ${verified ? "verified" : "still has failing or unverified checks"}.
|
|
3487
|
+
${renderSteps(repairPlan(after.reports, after.targets))}`
|
|
3488
|
+
};
|
|
3489
|
+
}
|
|
3490
|
+
function selectRepairs(steps, inspection, selected) {
|
|
3491
|
+
if (!selected)
|
|
3492
|
+
return steps;
|
|
3493
|
+
const filtered = steps.filter((step) => !step.automatic || selected.includes(step.id));
|
|
3494
|
+
for (const target of inspection.targets) {
|
|
3495
|
+
const report = inspection.reports.find((entry) => entry.node === target.name);
|
|
3496
|
+
if (!report?.snapshot)
|
|
3497
|
+
continue;
|
|
3498
|
+
for (const id of selected)
|
|
3499
|
+
if (!filtered.some((step) => step.node === target.name && step.id === id))
|
|
3500
|
+
filtered.push({
|
|
3501
|
+
...recipe(id),
|
|
3502
|
+
node: target.name,
|
|
3503
|
+
eligible: true,
|
|
3504
|
+
checks: ["explicit:request"],
|
|
3505
|
+
next: [
|
|
3506
|
+
"Explicitly requested owner action; fresh diagnostics still follow."
|
|
3507
|
+
]
|
|
3508
|
+
});
|
|
3509
|
+
}
|
|
3510
|
+
return filtered;
|
|
3511
|
+
}
|
|
3512
|
+
function renderSteps(steps) {
|
|
3513
|
+
return steps.map((step) => `${step.node} / ${step.id}: ${step.automatic && step.eligible ? "automatic after --apply --yes" : "operator action"}
|
|
3514
|
+
${step.action}`).join(`
|
|
3515
|
+
`);
|
|
3516
|
+
}
|
|
3517
|
+
async function recheckRepair(before, options, settings, services) {
|
|
3518
|
+
try {
|
|
3519
|
+
return await inspectRepair(options, settings, services);
|
|
3520
|
+
} catch {
|
|
3521
|
+
return {
|
|
3522
|
+
reports: before.reports.map((report) => ({
|
|
3523
|
+
node: report.node,
|
|
3524
|
+
checks: [
|
|
3525
|
+
{
|
|
3526
|
+
id: "repair:recheck",
|
|
3527
|
+
status: "fail",
|
|
3528
|
+
summary: "Fresh diagnostics could not complete after repair. Earlier diagnostics are not being treated as fresh; inspect this computer and rerun health."
|
|
3529
|
+
}
|
|
3530
|
+
]
|
|
3531
|
+
})),
|
|
3532
|
+
targets: [],
|
|
3533
|
+
coverage: {
|
|
3534
|
+
...before.coverage,
|
|
3535
|
+
limitation: "Post-repair diagnostics failed. Execution results are retained; machine health remains unverified."
|
|
3536
|
+
}
|
|
3537
|
+
};
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3541
|
+
// packages/personal-cli/src/fleet.ts
|
|
3542
|
+
var SERVICES = { run: runCommand, source: probeSource };
|
|
3543
|
+
async function executeFleet(command, options, settings, services = SERVICES) {
|
|
3544
|
+
validateOptions(command, options);
|
|
3545
|
+
if (["repair", "repairs"].includes(command))
|
|
3546
|
+
return executeRepairCommand(command, options, settings, services);
|
|
3547
|
+
if (["discover", "health"].includes(command))
|
|
3548
|
+
return executeTailnet(command, options, settings, services);
|
|
3549
|
+
if (command === "schema")
|
|
3550
|
+
return result(schema_default);
|
|
3551
|
+
if (command === "init")
|
|
3552
|
+
return initialize(settings, options);
|
|
3553
|
+
if (command === "add")
|
|
3554
|
+
return register(settings, options);
|
|
3555
|
+
const roster = options.local ? null : await readRoster(settings.config);
|
|
3556
|
+
const nodes = resolveNodes(command, options, roster);
|
|
3557
|
+
if (["nodes", "validate", "onboard"].includes(command))
|
|
3558
|
+
return rosterResult(command, nodes, roster !== null);
|
|
3559
|
+
if (command === "sync")
|
|
3560
|
+
return synchronize(nodes, options, settings, services);
|
|
3561
|
+
return diagnose(nodes, options, { ...settings, missingRoster: !roster }, services);
|
|
3562
|
+
}
|
|
3563
|
+
function rosterResult(command, nodes, configured) {
|
|
3564
|
+
return result({
|
|
3565
|
+
version: 1,
|
|
3566
|
+
command,
|
|
3567
|
+
ok: true,
|
|
3568
|
+
...command === "nodes" ? { configured } : {},
|
|
3569
|
+
nodes: command === "onboard" ? nodes.map((node) => onboarding(node, "personal fleet")) : nodes
|
|
3570
|
+
});
|
|
3571
|
+
}
|
|
3572
|
+
function resolveNodes(command, options, roster) {
|
|
3573
|
+
if (!roster && !options.local && !["doctor", "nodes"].includes(command))
|
|
3574
|
+
throw new FleetUsageError("No fleet roster. Use fleet add or the interactive Register action; use fleet doctor --local for this machine.");
|
|
3575
|
+
if (options.node?.length && !roster)
|
|
3576
|
+
throw new FleetUsageError("Cannot select nodes without a roster. Use fleet add first.");
|
|
3577
|
+
return roster ? selectNodes(roster, options.node ?? []) : [];
|
|
3578
|
+
}
|
|
3579
|
+
function result(data, text) {
|
|
3580
|
+
return { exitCode: 0, data, ...text ? { text } : {} };
|
|
3581
|
+
}
|
|
3582
|
+
async function register(settings, options) {
|
|
3583
|
+
const node = nodeFromOptions(options);
|
|
3584
|
+
const config = await addNode(settings.config, node, options.apply === true);
|
|
3585
|
+
return result({
|
|
3586
|
+
version: 1,
|
|
3587
|
+
command: "add",
|
|
3588
|
+
ok: true,
|
|
3589
|
+
applied: options.apply === true,
|
|
3590
|
+
path: settings.config,
|
|
3591
|
+
node,
|
|
3592
|
+
nodes: config.nodes,
|
|
3593
|
+
next: options.apply ? "Run fleet onboard, then fleet doctor --all --json." : "Review this preview, then repeat with --apply --yes."
|
|
3594
|
+
});
|
|
2029
3595
|
}
|
|
2030
3596
|
async function initialize(settings, options) {
|
|
2031
3597
|
const config = {
|
|
@@ -2033,9 +3599,9 @@ async function initialize(settings, options) {
|
|
|
2033
3599
|
$schema: new URL("./schema.json", import.meta.url).href
|
|
2034
3600
|
};
|
|
2035
3601
|
if (options.apply) {
|
|
2036
|
-
await
|
|
3602
|
+
await mkdir5(dirname5(settings.config), { recursive: true, mode: 448 });
|
|
2037
3603
|
try {
|
|
2038
|
-
await
|
|
3604
|
+
await writeFile3(settings.config, `${JSON.stringify(config, null, 2)}
|
|
2039
3605
|
`, {
|
|
2040
3606
|
flag: "wx",
|
|
2041
3607
|
mode: 384
|
|
@@ -2130,6 +3696,301 @@ async function syncRemotes(nodes, options, services) {
|
|
|
2130
3696
|
}, services.run), 1);
|
|
2131
3697
|
}
|
|
2132
3698
|
|
|
3699
|
+
// packages/personal-cli/src/node-sync.ts
|
|
3700
|
+
import { join as join10 } from "node:path";
|
|
3701
|
+
|
|
3702
|
+
// packages/fleet/src/configure.ts
|
|
3703
|
+
import { join as join9 } from "node:path";
|
|
3704
|
+
import { mkdir as mkdir6, writeFile as writeFile4, rename as rename3 } from "node:fs/promises";
|
|
3705
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
3706
|
+
function validConfiguration(value) {
|
|
3707
|
+
return record(value) && typeof value["revision"] === "string" && /^[a-f0-9]{40}$/.test(value["revision"]) && ["codex", "claude", "opencode", "gemini"].includes(String(value["tool"])) && ["direct", "proxy"].includes(String(value["profile"])) && typeof value["node"] === "string" && /^[a-z0-9][a-z0-9-]{0,62}$/.test(value["node"]);
|
|
3708
|
+
}
|
|
3709
|
+
async function configureWorkspace(root, request, run) {
|
|
3710
|
+
if (!validConfiguration(request))
|
|
3711
|
+
return failure("Invalid reviewed revision or configuration selection.");
|
|
3712
|
+
const directory = join9(root, "node_modules", ".fleet-revisions", request.revision);
|
|
3713
|
+
const head = await run(["git", "rev-parse", "--verify", `${request.revision}^{commit}`], { cwd: root });
|
|
3714
|
+
if (!head.ok || head.stdout.trim() !== request.revision)
|
|
3715
|
+
return failure("Reviewed commit is unavailable locally. Fetch it explicitly and retry.");
|
|
3716
|
+
const prepared = await prepareRevision(root, directory, request.revision, run);
|
|
3717
|
+
if (prepared.length)
|
|
3718
|
+
return prepared;
|
|
3719
|
+
const setup = await run([
|
|
3720
|
+
"mise",
|
|
3721
|
+
"exec",
|
|
3722
|
+
"--locked",
|
|
3723
|
+
"bun",
|
|
3724
|
+
"--",
|
|
3725
|
+
"bun",
|
|
3726
|
+
"scripts/dev/setup.ts",
|
|
3727
|
+
"--non-interactive"
|
|
3728
|
+
], { cwd: directory, timeoutMs: 1200000 });
|
|
3729
|
+
if (!setup.ok)
|
|
3730
|
+
return failure("Existing setup owner failed. Partial tool/dependency installation is possible; no configuration receipt was advanced.");
|
|
3731
|
+
const applied = await applyOwner(directory, request, run);
|
|
3732
|
+
if (!applied.ok)
|
|
3733
|
+
return failure("personal workstation configuration failed. Inspect preview on this node; no rollout receipt was advanced.");
|
|
3734
|
+
const receipt = decodeReceipt(applied.stdout, request.revision);
|
|
3735
|
+
if (!receipt)
|
|
3736
|
+
return failure("personal workstation returned an invalid receipt; rollout remains unverified.");
|
|
3737
|
+
await recordConfiguration(root, request, receipt);
|
|
3738
|
+
return [
|
|
3739
|
+
{
|
|
3740
|
+
id: "configuration:apply",
|
|
3741
|
+
status: "pass",
|
|
3742
|
+
summary: `personal workstation applied revision ${request.revision}; configuration fingerprint ${receipt}. Authentication and T3 end-to-end remain separate gates.`
|
|
3743
|
+
}
|
|
3744
|
+
];
|
|
3745
|
+
}
|
|
3746
|
+
function decodeReceipt(output, revision) {
|
|
3747
|
+
try {
|
|
3748
|
+
const data = JSON.parse(output);
|
|
3749
|
+
return record(data) && data["ok"] === true && data["revision"] === revision && typeof data["fingerprint"] === "string" && /^[a-f0-9]{64}$/.test(data["fingerprint"]) ? data["fingerprint"] : null;
|
|
3750
|
+
} catch {
|
|
3751
|
+
return null;
|
|
3752
|
+
}
|
|
3753
|
+
}
|
|
3754
|
+
function failure(summary) {
|
|
3755
|
+
return [{ id: "configuration:apply", status: "fail", summary }];
|
|
3756
|
+
}
|
|
3757
|
+
async function prepareRevision(root, directory, revision, run) {
|
|
3758
|
+
await mkdir6(join9(root, "node_modules", ".fleet-revisions"), {
|
|
3759
|
+
recursive: true
|
|
3760
|
+
});
|
|
3761
|
+
let deployed = await run(["git", "rev-parse", "HEAD"], { cwd: directory });
|
|
3762
|
+
if (!deployed.ok) {
|
|
3763
|
+
const created = await run(["git", "worktree", "add", "--detach", directory, revision], { cwd: root, timeoutMs: 30000 });
|
|
3764
|
+
if (!created.ok)
|
|
3765
|
+
return failure("Cannot create pinned deployment worktree. Existing data was preserved.");
|
|
3766
|
+
deployed = await run(["git", "rev-parse", "HEAD"], { cwd: directory });
|
|
3767
|
+
}
|
|
3768
|
+
const clean = await run(["git", "status", "--porcelain"], { cwd: directory });
|
|
3769
|
+
if (!deployed.ok || deployed.stdout.trim() !== revision || !clean.ok || clean.stdout.trim())
|
|
3770
|
+
return failure("Pinned deployment checkout differs or is dirty. Inspect it; fleet never resets it.");
|
|
3771
|
+
const top = await run(["git", "rev-parse", "--show-toplevel"], {
|
|
3772
|
+
cwd: directory
|
|
3773
|
+
});
|
|
3774
|
+
if (!top.ok || top.stdout.trim() !== directory)
|
|
3775
|
+
return failure("Deployment path is not its own Git worktree.");
|
|
3776
|
+
return [];
|
|
3777
|
+
}
|
|
3778
|
+
async function recordConfiguration(root, request, receipt) {
|
|
3779
|
+
const receipts = join9(root, "node_modules", ".fleet-configuration");
|
|
3780
|
+
await mkdir6(receipts, { recursive: true, mode: 448 });
|
|
3781
|
+
const path = join9(receipts, `${request.node}-${request.tool}-${request.profile}.json`);
|
|
3782
|
+
const temp = `${path}.${randomUUID3()}.tmp`;
|
|
3783
|
+
await writeFile4(temp, JSON.stringify({ version: 1, ...request, fingerprint: receipt }) + `
|
|
3784
|
+
`, { flag: "wx", mode: 384 });
|
|
3785
|
+
await rename3(temp, path);
|
|
3786
|
+
}
|
|
3787
|
+
async function applyOwner(directory, request, run) {
|
|
3788
|
+
return run([
|
|
3789
|
+
"mise",
|
|
3790
|
+
"exec",
|
|
3791
|
+
"--locked",
|
|
3792
|
+
"bun",
|
|
3793
|
+
"--",
|
|
3794
|
+
"bun",
|
|
3795
|
+
"packages/workstation/cli/internal.ts",
|
|
3796
|
+
"--json",
|
|
3797
|
+
"--yes",
|
|
3798
|
+
"--non-interactive",
|
|
3799
|
+
"agents",
|
|
3800
|
+
"apply",
|
|
3801
|
+
"--node",
|
|
3802
|
+
request.node,
|
|
3803
|
+
"--tool",
|
|
3804
|
+
request.tool,
|
|
3805
|
+
"--profile",
|
|
3806
|
+
request.profile
|
|
3807
|
+
], { cwd: directory, timeoutMs: 120000 });
|
|
3808
|
+
}
|
|
3809
|
+
|
|
3810
|
+
// packages/personal-cli/src/node-t3.ts
|
|
3811
|
+
async function syncT3(options, settings) {
|
|
3812
|
+
try {
|
|
3813
|
+
return await t3Command("t3-apply", options, settings);
|
|
3814
|
+
} catch (error) {
|
|
3815
|
+
return {
|
|
3816
|
+
exitCode: 1,
|
|
3817
|
+
data: {
|
|
3818
|
+
version: 1,
|
|
3819
|
+
command: "fleet-node-sync",
|
|
3820
|
+
ok: false,
|
|
3821
|
+
applied: true,
|
|
3822
|
+
stage: "t3",
|
|
3823
|
+
error: error instanceof FleetUsageError ? error.message : "T3 reconciliation failed; inspect local configuration and ownership state.",
|
|
3824
|
+
progress: "Tools, launcher and profile steps completed. Node receipt was not advanced. Fix the T3 conflict and repeat fleet-node-sync."
|
|
3825
|
+
}
|
|
3826
|
+
};
|
|
3827
|
+
}
|
|
3828
|
+
}
|
|
3829
|
+
|
|
3830
|
+
// packages/personal-cli/src/node-sync.ts
|
|
3831
|
+
async function syncNode(options, settings, run = runCommand) {
|
|
3832
|
+
if (process.platform === "win32" && !options.dryRun)
|
|
3833
|
+
throw new FleetUsageError("Codex proxy node sync requires macOS/Linux/WSL. Use personal workstation commands for native Windows configuration.");
|
|
3834
|
+
const node = nodeId(options.id);
|
|
3835
|
+
const head = await run(["git", "rev-parse", "--verify", "HEAD"], {
|
|
3836
|
+
cwd: settings.workspace
|
|
3837
|
+
});
|
|
3838
|
+
const revision = options.revision ?? head.stdout.trim();
|
|
3839
|
+
if (!head.ok || !/^[a-f0-9]{40}$/.test(revision))
|
|
3840
|
+
throw new FleetUsageError("An existing personal checkout is required; set --workspace PATH.");
|
|
3841
|
+
const request = { node, revision, tool: "codex", profile: "proxy" };
|
|
3842
|
+
if (options.dryRun)
|
|
3843
|
+
return {
|
|
3844
|
+
exitCode: 0,
|
|
3845
|
+
data: {
|
|
3846
|
+
version: 1,
|
|
3847
|
+
command: "fleet-node-sync",
|
|
3848
|
+
ok: true,
|
|
3849
|
+
applied: false,
|
|
3850
|
+
...request,
|
|
3851
|
+
steps: [
|
|
3852
|
+
"Reconcile locked tools",
|
|
3853
|
+
"Install personal and retire owned ws launcher",
|
|
3854
|
+
"Upsert Codex proxy profile",
|
|
3855
|
+
"Reconcile declared T3 instances and remove stale owned entries"
|
|
3856
|
+
]
|
|
3857
|
+
}
|
|
3858
|
+
};
|
|
3859
|
+
const checks = await configureWorkspace(settings.workspace, request, run);
|
|
3860
|
+
if (checks.some((check) => check.status !== "pass"))
|
|
3861
|
+
return {
|
|
3862
|
+
exitCode: 1,
|
|
3863
|
+
data: { version: 1, command: "fleet-node-sync", ok: false, checks }
|
|
3864
|
+
};
|
|
3865
|
+
return finishSync(options, settings, { request, checks }, run);
|
|
3866
|
+
}
|
|
3867
|
+
async function finishSync(options, settings, input, run) {
|
|
3868
|
+
const { request, checks } = input;
|
|
3869
|
+
const { node, revision } = request;
|
|
3870
|
+
const workspace = join10(settings.workspace, "node_modules/.fleet-revisions", revision);
|
|
3871
|
+
const installed = await run([
|
|
3872
|
+
"bun",
|
|
3873
|
+
"packages/workstation/cli/internal.ts",
|
|
3874
|
+
"--json",
|
|
3875
|
+
"--yes",
|
|
3876
|
+
"--non-interactive",
|
|
3877
|
+
"install"
|
|
3878
|
+
], { cwd: workspace, timeoutMs: 1200000 });
|
|
3879
|
+
if (!installed.ok)
|
|
3880
|
+
return {
|
|
3881
|
+
exitCode: 1,
|
|
3882
|
+
data: {
|
|
3883
|
+
version: 1,
|
|
3884
|
+
ok: false,
|
|
3885
|
+
checks,
|
|
3886
|
+
error: "Launcher/workstation convergence failed; profile changes may already have applied."
|
|
3887
|
+
}
|
|
3888
|
+
};
|
|
3889
|
+
const t3 = await syncT3({ ...options, id: node }, { ...settings, workspace });
|
|
3890
|
+
if (t3.exitCode !== 0)
|
|
3891
|
+
return t3;
|
|
3892
|
+
await recordDeployment(settings.workspace, node, revision);
|
|
3893
|
+
return {
|
|
3894
|
+
exitCode: 0,
|
|
3895
|
+
data: {
|
|
3896
|
+
version: 1,
|
|
3897
|
+
command: "fleet-node-sync",
|
|
3898
|
+
ok: true,
|
|
3899
|
+
applied: true,
|
|
3900
|
+
...request,
|
|
3901
|
+
checks,
|
|
3902
|
+
t3: t3.data,
|
|
3903
|
+
readiness: {
|
|
3904
|
+
authentication: "unverified",
|
|
3905
|
+
inference: "unverified",
|
|
3906
|
+
pairing: "unverified"
|
|
3907
|
+
},
|
|
3908
|
+
next: "Use personal t3-status for configuration drift and personal fleet-status for machine health."
|
|
3909
|
+
}
|
|
3910
|
+
};
|
|
3911
|
+
}
|
|
3912
|
+
|
|
3913
|
+
// packages/personal-cli/src/flat-commands.ts
|
|
3914
|
+
function registerFlatCommands(program, defaults, complete) {
|
|
3915
|
+
program.command("fleet-node-sync").description("Reconcile this node’s locked tools and Codex proxy profile; applies by default").option("--id <name>", "Stable node name; defaults to this machine hostname").option("--revision <sha>", "Reviewed commit; defaults to checkout HEAD").option("--dry-run", "Preview without changing this node").option("--t3-home <path>", "T3 base directory").action(async (_options, command) => {
|
|
3916
|
+
const options = command.optsWithGlobals();
|
|
3917
|
+
const settings = await resolveSettings({ ...defaults, ...options });
|
|
3918
|
+
complete(await syncNode(options, settings));
|
|
3919
|
+
});
|
|
3920
|
+
for (const [name, target, description] of [
|
|
3921
|
+
[
|
|
3922
|
+
"fleet-status",
|
|
3923
|
+
"doctor",
|
|
3924
|
+
"Check this computer and all registered fleet nodes"
|
|
3925
|
+
],
|
|
3926
|
+
["fleet-discover", "discover", "Find computers visible through Tailscale"]
|
|
3927
|
+
]) {
|
|
3928
|
+
program.command(name).description(description).action(async (_options, command) => {
|
|
3929
|
+
const options = command.optsWithGlobals();
|
|
3930
|
+
const settings = await resolveSettings({ ...defaults, ...options });
|
|
3931
|
+
complete(await executeFleet(target, {}, settings));
|
|
3932
|
+
});
|
|
3933
|
+
}
|
|
3934
|
+
}
|
|
3935
|
+
|
|
3936
|
+
// packages/personal-cli/src/agent-guide.ts
|
|
3937
|
+
var AGENT_GUIDE = {
|
|
3938
|
+
version: 1,
|
|
3939
|
+
name: "@crvouga/personal",
|
|
3940
|
+
invocation: "personal <command> --json --non-interactive",
|
|
3941
|
+
discovery: "personal commands --json",
|
|
3942
|
+
configuration: {
|
|
3943
|
+
workspace: "--workspace PATH, PERSONAL_WORKSPACE, closest ancestor mise.toml, then current directory",
|
|
3944
|
+
roster: "--config PATH, PERSONAL_FLEET_CONFIG, closest ancestor .fleet.local.json, then ~/.config/personal/fleet.json"
|
|
3945
|
+
},
|
|
3946
|
+
output: {
|
|
3947
|
+
stdout: "One versioned JSON document with --json. Prompts and progress never contaminate stdout.",
|
|
3948
|
+
exitCodes: {
|
|
3949
|
+
"0": "Success or preview",
|
|
3950
|
+
"1": "Failed check or operation; strict also rejects warnings/skips",
|
|
3951
|
+
"2": "Invalid usage or configuration",
|
|
3952
|
+
"130": "Cancelled"
|
|
3953
|
+
},
|
|
3954
|
+
errors: "{version:1,ok:false,code,error}; raw subprocess output and exception details are withheld",
|
|
3955
|
+
reports: "{version:1,command,ok,generatedAt,nodes:[{node,checks:[{id,status,summary,remedy?}]}],summary,coverage}",
|
|
3956
|
+
statuses: ["pass", "warn", "fail", "skip"],
|
|
3957
|
+
coverage: "Default doctor tests the controller and all configured nodes. --local tests only this controller. A missing roster fails explicitly; configured nodes are not every computer that exists. T3/VS Code end-to-end checks remain manual."
|
|
3958
|
+
},
|
|
3959
|
+
safety: [
|
|
3960
|
+
"No arguments opens a menu only on an interactive terminal. --json and --non-interactive never prompt.",
|
|
3961
|
+
"Diagnostics, onboarding instructions, schema, catalogs, scripts and sync/repair previews are read-only.",
|
|
3962
|
+
"fleet-node-sync authorizes local reconciliation by invocation; --dry-run previews. Legacy roster writes, sync and repair require --apply --yes.",
|
|
3963
|
+
"Do not copy credentials, forward SSH agents, bypass host keys, provision infrastructure or execute unreviewed remedies.",
|
|
3964
|
+
"A healthy controller does not prove another machine can connect to it. Run doctor from each intended controller.",
|
|
3965
|
+
"Repair executes only allowlisted owner actions, then reruns diagnostics. Native logins, host trust and resource permissions remain explicit operator steps. Pass the reviewed planHash as --plan-hash to reject a changed plan.",
|
|
3966
|
+
"Shared provider keys are fetched through each machine’s existing Vault authorization by workstation tooling. Never copy machine-local login caches.",
|
|
3967
|
+
"Use exact npm versions for automation. The application never self-updates."
|
|
3968
|
+
],
|
|
3969
|
+
reconciliation: {
|
|
3970
|
+
plan: "personal t3-plan --json",
|
|
3971
|
+
apply: "personal t3-apply --plan-hash HASH",
|
|
3972
|
+
cleanup: "personal t3-cleanup --dry-run",
|
|
3973
|
+
import: "personal t3-import --instance ID",
|
|
3974
|
+
ownership: "Only declared or explicitly imported T3 instances. Pairing, credentials, threads and unrelated settings are preserved.",
|
|
3975
|
+
concurrency: "Exclusive personal lock with optimistic external T3 file updates; avoid concurrent Settings edits."
|
|
3976
|
+
},
|
|
3977
|
+
examples: [
|
|
3978
|
+
"personal fleet-node-sync",
|
|
3979
|
+
"personal fleet-status",
|
|
3980
|
+
"personal t3-status",
|
|
3981
|
+
"personal fleet doctor --local --workspace /path/to/personal --json",
|
|
3982
|
+
"personal fleet doctor --all --json --strict --timeout 120000",
|
|
3983
|
+
"personal fleet add --id dev-linux --ssh dev-linux --path /home/dev/workspace --require github,t3,toolchain --json",
|
|
3984
|
+
"personal fleet add --id dev-linux --ssh dev-linux --path /home/dev/workspace --apply --yes --json",
|
|
3985
|
+
"personal fleet doctor --node dev-linux --json",
|
|
3986
|
+
"personal fleet sync --node dev-linux --json",
|
|
3987
|
+
"personal fleet repairs --json",
|
|
3988
|
+
"personal fleet repair --json",
|
|
3989
|
+
"personal fleet repair --local --repair workspace --json",
|
|
3990
|
+
"personal fleet repair --local --repair workspace --plan-hash <reviewed-sha256> --apply --yes --json"
|
|
3991
|
+
]
|
|
3992
|
+
};
|
|
3993
|
+
|
|
2133
3994
|
// packages/personal-cli/src/program.ts
|
|
2134
3995
|
var GLOBAL_OPTIONS = [
|
|
2135
3996
|
{
|
|
@@ -2144,7 +4005,7 @@ var GLOBAL_OPTIONS = [
|
|
|
2144
4005
|
{ flag: "--config <path>", description: "Local, secret-free fleet roster" }
|
|
2145
4006
|
];
|
|
2146
4007
|
function common(command) {
|
|
2147
|
-
return command.option("--node <id>", "Select an existing node; repeat for multiple nodes", collect, []).option("--local", "Controller only; no roster or SSH required").option("--all", "All
|
|
4008
|
+
return command.option("--node <id>", "Select an existing node; repeat for multiple nodes", collect, []).option("--local", "Controller only; no roster or SSH required").option("--all", "All targets: doctor uses the roster; repair uses the visible tailnet").option("--require <capabilities>", "Controller capabilities, comma separated, or none").option("--timeout <ms>", "Per-node deadline, 1000–1800000 milliseconds").option("--strict", "Warnings and skipped checks also produce exit 1").option("--repair <ids>", "Repair only: workspace,workstation,providers; comma separated").option("--plan-hash <sha256>", "Repair only: require the reviewed plan to match current diagnostics").option("--script", "Repair only: generate a reviewable POSIX script without executing it").option("--pull", "Sync only: explicitly request a clean, fast-forward pull").option("--apply", "Authorize the displayed roster/sync/repair mutation; requires --yes").option("--yes", "Confirm an explicitly requested --apply operation");
|
|
2148
4009
|
}
|
|
2149
4010
|
function collect(value, previous) {
|
|
2150
4011
|
return [...previous, value];
|
|
@@ -2159,7 +4020,7 @@ function createProgram(version, output) {
|
|
|
2159
4020
|
}
|
|
2160
4021
|
function fleetCommands(program) {
|
|
2161
4022
|
const fleet = program.command("fleet").description("Diagnose, explore and onboard developer computers");
|
|
2162
|
-
const definitions = commandCatalog().filter((entry) => entry.group === "Fleet" && entry.id !== "fleet doctor local");
|
|
4023
|
+
const definitions = commandCatalog().filter((entry) => entry.group === "Fleet" && entry.args[0] === "fleet" && entry.id !== "fleet doctor local");
|
|
2163
4024
|
return definitions.map((entry) => {
|
|
2164
4025
|
const command = common(fleet.command(entry.args[1]).description(entry.description));
|
|
2165
4026
|
if (entry.args[1] === "add")
|
|
@@ -2201,6 +4062,20 @@ async function execute(args, defaults, version) {
|
|
|
2201
4062
|
const program = createProgram(version, (text) => {
|
|
2202
4063
|
output += text;
|
|
2203
4064
|
});
|
|
4065
|
+
registerT3(program, defaults, (result) => {
|
|
4066
|
+
outcome = result;
|
|
4067
|
+
});
|
|
4068
|
+
registerFlatCommands(program, defaults, (result) => {
|
|
4069
|
+
outcome = result;
|
|
4070
|
+
});
|
|
4071
|
+
program.command("workstation [args...]").description("Local configuration, tools, agent profiles and launcher lifecycle").allowUnknownOption().action(async (args, command) => {
|
|
4072
|
+
const options = command.optsWithGlobals();
|
|
4073
|
+
const settings = await resolveSettings({
|
|
4074
|
+
...defaults,
|
|
4075
|
+
...settingsOverrides(options)
|
|
4076
|
+
});
|
|
4077
|
+
outcome = await workstationCommand(args, settings);
|
|
4078
|
+
});
|
|
2204
4079
|
const commands = fleetCommands(program);
|
|
2205
4080
|
for (const command of commands)
|
|
2206
4081
|
command.action(async () => {
|
|
@@ -2258,7 +4133,7 @@ function commandError(error, output) {
|
|
|
2258
4133
|
|
|
2259
4134
|
// packages/personal-cli/src/app.ts
|
|
2260
4135
|
import { parseArgs } from "node:util";
|
|
2261
|
-
var VERSION = "0.1.
|
|
4136
|
+
var VERSION = "0.1.1";
|
|
2262
4137
|
function wantsInteractive(args, terminal, ci) {
|
|
2263
4138
|
if (args.includes("--json") || args.includes("--non-interactive"))
|
|
2264
4139
|
return false;
|
|
@@ -2288,7 +4163,9 @@ async function main(args = process.argv.slice(2)) {
|
|
|
2288
4163
|
...typeof values.workspace === "string" ? { workspace: values.workspace } : {},
|
|
2289
4164
|
...typeof values.config === "string" ? { config: values.config } : {}
|
|
2290
4165
|
});
|
|
2291
|
-
|
|
4166
|
+
if (startup(args).positionals[0] === "workstation")
|
|
4167
|
+
return forwardWorkstation(args, settings);
|
|
4168
|
+
const terminal = interactiveTerminal();
|
|
2292
4169
|
if (args.includes("--interactive") && !terminal) {
|
|
2293
4170
|
process.stdout.write(`${JSON.stringify({ version: 1, ok: false, code: "tty_required", error: "Interactive mode needs a terminal. Use personal commands --json or fleet doctor --local --json." })}
|
|
2294
4171
|
`);
|
|
@@ -2310,6 +4187,9 @@ async function main(args = process.argv.slice(2)) {
|
|
|
2310
4187
|
`);
|
|
2311
4188
|
return outcome.exitCode;
|
|
2312
4189
|
}
|
|
4190
|
+
function interactiveTerminal() {
|
|
4191
|
+
return process.stdin.isTTY === true && process.stderr.isTTY === true;
|
|
4192
|
+
}
|
|
2313
4193
|
|
|
2314
4194
|
// packages/personal-cli/src/cli.ts
|
|
2315
4195
|
try {
|