@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/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: `[${clean(command.group)}] ${clean(command.title)}`,
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) || !Array.isArray(data["nodes"]))
81
+ if (!record2(data))
82
82
  return [];
83
- return data["nodes"].flatMap(nodeChecks);
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 = successful(outcome) ? incomplete ? "WARNING" : "SUCCESS" : "FAILED";
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: "All roster nodes",
426
+ name: allTargetsName(action),
347
427
  value: "all",
348
- description: action === "doctor" ? "Controller + every configured node; read-only." : "Every configured remote checkout."
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 = [...args, "--apply", "--yes"];
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: "Fleet setup / add node…",
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 groups = term.trim() ? [] : [
618
- ...new Set(session.options.commands.map((command) => command.group))
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(join(current, name)))
815
- return join(current, name);
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") ?? join(cwd, "mise.toml")));
824
- const config = resolve(overrides.config ?? env["PERSONAL_FLEET_CONFIG"] ?? await findUp(cwd, ".fleet.local.json") ?? join(homedir(), ".config/personal/fleet.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 writeFile2, mkdir as mkdir3 } from "node:fs/promises";
941
- import { dirname as dirname3 } from "node:path";
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 readFile2, mkdir, writeFile } from "node:fs/promises";
1024
- import { join as join3 } from "node:path";
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 join2 } from "node:path";
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 readFile(path));
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(join2(root, "packages"), {
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(join2(root, "packages", entry.name, "package.json"))}`);
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(join2(root, ".mise/locks"), {
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) => join2(entry.parentPath, entry.name)).sort();
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(join2(root, ".mise/locks").length).replaceAll("\\", "/");
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 createHash("sha256").update(value).digest("hex");
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(join3(root, name));
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 readFile2(join3(root, RECEIPT), "utf8"));
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 mkdir(join3(root, "node_modules"), { recursive: true });
1128
- await writeFile(join3(root, RECEIPT), JSON.stringify({ version: 1, files: current.files }), { mode: 384 });
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: capability === "toolchain" ? 90000 : 15000
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 === "sync",
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 === "sync")
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 join5 } from "node:path";
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(join5(root, "node_modules/.fleet-sync.json")).catch(() => {
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: ["github", "vault", "t3", "vscode", "toolchain"]
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("--require expects github,vault,t3,vscode,toolchain or none.");
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 sync.");
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 (options.local && !["doctor", "sync"].includes(command))
1889
- throw new FleetUsageError("--local is only valid for fleet doctor or sync.");
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 && command !== "doctor")
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 mkdir2, open, readFile as readFile3, rename, unlink as unlink2 } from "node:fs/promises";
1906
- import { dirname as dirname2 } from "node:path";
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 lstat(path);
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 readFile3(path, "utf8")));
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 mkdir2(dirname2(path), { recursive: true, mode: 448 });
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}.${randomUUID()}.tmp`;
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 rename(temporary, path);
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/personal-cli/src/probe-source.ts
1976
- import { readFile as readFile4 } from "node:fs/promises";
1977
- async function probeSource() {
1978
- if (typeof Bun !== "undefined")
1979
- return bundleProbe();
1980
- return readFile4(new URL("./probe.mjs", import.meta.url), "utf8");
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
- // packages/personal-cli/src/fleet.ts
1984
- var SERVICES = { run: runCommand, source: probeSource };
1985
- async function executeFleet(command, options, settings, services = SERVICES) {
1986
- validateOptions(command, options);
1987
- if (command === "schema")
1988
- return result(schema_default);
1989
- if (command === "init")
1990
- return initialize(settings, options);
1991
- if (command === "add")
1992
- return register(settings, options);
1993
- const roster = options.local ? null : await readRoster(settings.config);
1994
- const nodes = resolveNodes(command, options, roster);
1995
- if (["nodes", "validate", "onboard"].includes(command))
1996
- return result({
1997
- version: 1,
1998
- command,
1999
- ok: true,
2000
- nodes: command === "onboard" ? nodes.map((node) => onboarding(node, "personal fleet")) : nodes
2001
- });
2002
- if (command === "sync")
2003
- return synchronize(nodes, options, settings, services);
2004
- return diagnose(nodes, options, { ...settings, missingRoster: !roster }, services);
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 resolveNodes(command, options, roster) {
2007
- if (!roster && !options.local && command !== "doctor")
2008
- throw new FleetUsageError("No fleet roster. Use fleet add or the interactive Register action; use fleet doctor --local for this machine.");
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 result(data, text) {
2014
- return { exitCode: 0, data, ...text ? { text } : {} };
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
- async function register(settings, options) {
2017
- const node = nodeFromOptions(options);
2018
- const config = await addNode(settings.config, node, options.apply === true);
2019
- return result({
2020
- version: 1,
2021
- command: "add",
2022
- ok: true,
2023
- applied: options.apply === true,
2024
- path: settings.config,
2025
- node,
2026
- nodes: config.nodes,
2027
- next: options.apply ? "Run fleet onboard, then fleet doctor --all --json." : "Review this preview, then repeat with --apply --yes."
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 mkdir3(dirname3(settings.config), { recursive: true, mode: 448 });
3602
+ await mkdir5(dirname5(settings.config), { recursive: true, mode: 448 });
2037
3603
  try {
2038
- await writeFile2(settings.config, `${JSON.stringify(config, null, 2)}
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 configured nodes and the controller (doctor default)").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("--pull", "Sync only: explicitly request a clean, fast-forward pull").option("--apply", "Authorize the displayed roster/sync mutation; requires --yes").option("--yes", "Confirm an explicitly requested --apply operation");
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.0";
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
- const terminal = process.stdin.isTTY === true && process.stderr.isTTY === true;
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 {