@tablation/crew 0.95.0 → 0.97.0

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.
Files changed (2) hide show
  1. package/dist/cli.js +1390 -949
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2602,6 +2602,22 @@ function parseCloneScheme(v) {
2602
2602
  if (v === "ssh" || v === "https") return v;
2603
2603
  throw new ConfigError(`ship.cloneScheme must be "ssh" or "https", got ${JSON.stringify(v)}`);
2604
2604
  }
2605
+ function parseShipEnv(v) {
2606
+ if (v === void 0 || v === null) return void 0;
2607
+ if (typeof v !== "object" || Array.isArray(v)) throw new ConfigError("ship.env must be a map of variable name to value");
2608
+ const out = {};
2609
+ for (const [k, val] of Object.entries(v)) {
2610
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) throw new ConfigError(`ship.env: "${k}" is not a valid variable name`);
2611
+ if (k === "PATH") throw new ConfigError("ship.env: PATH is set from ship.extraPath, not here");
2612
+ if (typeof val !== "string" && typeof val !== "number" && typeof val !== "boolean") {
2613
+ throw new ConfigError(`ship.env.${k} must be a string, number or boolean`);
2614
+ }
2615
+ const text = String(val);
2616
+ if (/[\r\n\0]/.test(text)) throw new ConfigError(`ship.env.${k} must be a single line`);
2617
+ out[k] = text;
2618
+ }
2619
+ return Object.keys(out).length ? out : void 0;
2620
+ }
2605
2621
  function parseShipForges(v) {
2606
2622
  try {
2607
2623
  return parseForges(v);
@@ -2641,7 +2657,8 @@ var SHIP_KEYS = /* @__PURE__ */ new Set([
2641
2657
  "previewHost",
2642
2658
  "maxPeriscopes",
2643
2659
  "periscopeIdleMinutes",
2644
- "secretStore"
2660
+ "secretStore",
2661
+ "env"
2645
2662
  ]);
2646
2663
  var ROUTE_KEYS = /* @__PURE__ */ new Set([
2647
2664
  "route",
@@ -3065,7 +3082,8 @@ Create the first of those:
3065
3082
  previewHost: shipRaw.previewHost ? String(shipRaw.previewHost).trim() || void 0 : void 0,
3066
3083
  maxPeriscopes,
3067
3084
  periscopeIdleMinutes,
3068
- secretStore
3085
+ secretStore,
3086
+ env: parseShipEnv(shipRaw.env)
3069
3087
  },
3070
3088
  routes,
3071
3089
  crewHome,
@@ -3717,7 +3735,7 @@ function recoveredFailureHeadlines(o, route2) {
3717
3735
 
3718
3736
  // src/install.ts
3719
3737
  import { dirname as dirname5, join as join13, extname } from "path";
3720
- import { homedir as homedir7 } from "os";
3738
+ import { homedir as homedir7, userInfo } from "os";
3721
3739
  import { accessSync as accessSync2, constants as constants2, existsSync as existsSync11, mkdirSync as mkdirSync7, readdirSync as readdirSync3, readFileSync as readFileSync12, statSync as statSync4, writeFileSync as writeFileSync8, unlinkSync, realpathSync } from "fs";
3722
3740
  import { spawnSync as spawnSync4 } from "child_process";
3723
3741
  import { createHash as createHash3 } from "crypto";
@@ -3785,6 +3803,30 @@ function dockerPathProblem(ship, shellPath = process.env.PATH ?? "") {
3785
3803
  const inShell = findOnPath("docker", shellPath);
3786
3804
  return inShell ? `\`docker\` is not on the scheduler PATH (${schedulerPath}) but is at ${inShell} in your shell; add ${dirname5(inShell)} to ship.extraPath in crew.yaml and run \`crew install\`` : `\`docker\` is not on the scheduler PATH (${schedulerPath}) or your shell PATH; install Docker, then add its directory to ship.extraPath in crew.yaml and run \`crew install\``;
3787
3805
  }
3806
+ var USER_INSTALL = { root: false, user: "" };
3807
+ function installContext(env = process.env) {
3808
+ const root = typeof process.getuid === "function" && process.getuid() === 0;
3809
+ const user = root && env.SUDO_USER || userInfo().username;
3810
+ return { root, user };
3811
+ }
3812
+ var SYSTEM_UNIT_DIR = "/etc/systemd/system";
3813
+ function systemUnitPathFor(label, suffix = "service") {
3814
+ return join13(SYSTEM_UNIT_DIR, `${label}.${suffix}`);
3815
+ }
3816
+ function unitEnvironment(ship) {
3817
+ return [["PATH", pathFor(ship)], ...Object.entries(ship.env ?? {})];
3818
+ }
3819
+ function systemdEnvLines(ship) {
3820
+ return unitEnvironment(ship).map(([k, v]) => {
3821
+ if (k === "PATH") return `Environment=PATH=${v}`;
3822
+ const escaped = `${k}=${v}`.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%");
3823
+ return `Environment="${escaped}"`;
3824
+ }).join("\n");
3825
+ }
3826
+ function shQuote(v) {
3827
+ if (/^[A-Za-z0-9_/.:,@%+=-]+$/.test(v)) return v;
3828
+ return `'${v.replace(/'/g, `'\\''`)}'`;
3829
+ }
3788
3830
  function xmlEscape(s) {
3789
3831
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
3790
3832
  }
@@ -3817,8 +3859,8 @@ function planLaunchd(ship, crewHome, job) {
3817
3859
  </array>
3818
3860
  <key>EnvironmentVariables</key>
3819
3861
  <dict>
3820
- <key>PATH</key>
3821
- <string>${xmlEscape(pathFor(ship))}</string>
3862
+ ${unitEnvironment(ship).map(([k, v]) => ` <key>${xmlEscape(k)}</key>
3863
+ <string>${xmlEscape(v)}</string>`).join("\n")}
3822
3864
  </dict>
3823
3865
  <key>WorkingDirectory</key>
3824
3866
  <string>${xmlEscape(crewHome)}</string>
@@ -3842,9 +3884,17 @@ ${scheduleKeys}
3842
3884
  schedulerLog
3843
3885
  };
3844
3886
  }
3845
- function planSystemd(ship, crewHome, job) {
3887
+ function planSystemd(ship, crewHome, job, ctx) {
3846
3888
  const label = labelFor(crewHome, job);
3847
- const unitDir = join13(homedir7(), ".config", "systemd", "user");
3889
+ const unitDir = ctx.root ? SYSTEM_UNIT_DIR : join13(homedir7(), ".config", "systemd", "user");
3890
+ const ctl = ctx.root ? ["systemctl"] : ["systemctl", "--user"];
3891
+ const userLine = ctx.root && ctx.user && ctx.user !== "root" ? `User=${ctx.user}
3892
+ ` : "";
3893
+ const wantedBy = ctx.root ? "multi-user.target" : "default.target";
3894
+ const linger = ctx.root || !ctx.user ? {} : {
3895
+ lingerCommand: ["loginctl", "enable-linger", ctx.user],
3896
+ lingerHint: `the daemon will stop when ${ctx.user} logs out; run \`sudo loginctl enable-linger ${ctx.user}\` once, or run \`crew install\` as root for a system unit`
3897
+ };
3848
3898
  const servicePath = join13(unitDir, `${label}.service`);
3849
3899
  const crewLog = ship.logFile;
3850
3900
  const schedulerLog = schedulerLogFor(crewLog, job);
@@ -3856,9 +3906,9 @@ Description=${description}
3856
3906
 
3857
3907
  [Service]
3858
3908
  Type=simple
3859
- ExecStart=${[...crewInvocation(crewHome), ...subcommandFor(job, "systemd")].join(" ")}
3909
+ ${userLine}ExecStart=${[...crewInvocation(crewHome), ...subcommandFor(job, "systemd")].join(" ")}
3860
3910
  WorkingDirectory=${crewHome}
3861
- Environment=PATH=${pathFor(ship)}
3911
+ ${systemdEnvLines(ship)}
3862
3912
  StandardOutput=append:${schedulerLog}
3863
3913
  StandardError=append:${schedulerLog}
3864
3914
  Restart=on-failure
@@ -3868,7 +3918,7 @@ RestartSec=5
3868
3918
  KillMode=process
3869
3919
 
3870
3920
  [Install]
3871
- WantedBy=default.target
3921
+ WantedBy=${wantedBy}
3872
3922
  `;
3873
3923
  return {
3874
3924
  mechanism: "systemd",
@@ -3878,11 +3928,12 @@ WantedBy=default.target
3878
3928
  cronMarker: null,
3879
3929
  preLoadCommands: [],
3880
3930
  loadCommands: [
3881
- ["systemctl", "--user", "daemon-reload"],
3882
- ["systemctl", "--user", "enable", "--now", `${label}.service`]
3931
+ [...ctl, "daemon-reload"],
3932
+ [...ctl, "enable", "--now", `${label}.service`]
3883
3933
  ],
3884
3934
  crewLog,
3885
- schedulerLog
3935
+ schedulerLog,
3936
+ ...linger
3886
3937
  };
3887
3938
  }
3888
3939
  const timerPath = join13(unitDir, `${label}.timer`);
@@ -3892,9 +3943,9 @@ Description=${description}
3892
3943
 
3893
3944
  [Service]
3894
3945
  Type=oneshot
3895
- ExecStart=${[...crewInvocation(crewHome), ...subcommandFor(job, "systemd")].join(" ")}
3946
+ ${userLine}ExecStart=${[...crewInvocation(crewHome), ...subcommandFor(job, "systemd")].join(" ")}
3896
3947
  WorkingDirectory=${crewHome}
3897
- Environment=PATH=${pathFor(ship)}
3948
+ ${systemdEnvLines(ship)}
3898
3949
  StandardOutput=append:${schedulerLog}
3899
3950
  StandardError=append:${schedulerLog}
3900
3951
  `;
@@ -3918,11 +3969,12 @@ WantedBy=timers.target
3918
3969
  cronMarker: null,
3919
3970
  preLoadCommands: [],
3920
3971
  loadCommands: [
3921
- ["systemctl", "--user", "daemon-reload"],
3922
- ["systemctl", "--user", "enable", "--now", `${label}.timer`]
3972
+ [...ctl, "daemon-reload"],
3973
+ [...ctl, "enable", "--now", `${label}.timer`]
3923
3974
  ],
3924
3975
  crewLog,
3925
- schedulerLog
3976
+ schedulerLog,
3977
+ ...linger
3926
3978
  };
3927
3979
  }
3928
3980
  function planCron(ship, crewHome, job) {
@@ -3930,7 +3982,7 @@ function planCron(ship, crewHome, job) {
3930
3982
  const crewLog = ship.logFile;
3931
3983
  const schedulerLog = schedulerLogFor(crewLog, job);
3932
3984
  const minutes = Math.max(1, Math.round(INTERVAL_SECONDS / 60));
3933
- const line = `*/${minutes} * * * * cd ${crewHome} && PATH=${pathFor(ship)} ${[...crewInvocation(crewHome), ...subcommandFor(job, "cron")].join(" ")} >> ${schedulerLog} 2>&1`;
3985
+ const line = `*/${minutes} * * * * cd ${crewHome} && ${unitEnvironment(ship).map(([k, v]) => `${k}=${shQuote(v)}`).join(" ")} ${[...crewInvocation(crewHome), ...subcommandFor(job, "cron")].join(" ")} >> ${schedulerLog} 2>&1`;
3934
3986
  return {
3935
3987
  mechanism: "cron",
3936
3988
  unitPaths: [],
@@ -3950,9 +4002,9 @@ function unitAbandonsAgents(plan, read) {
3950
4002
  if (text === null) return null;
3951
4003
  return plan.mechanism === "launchd" ? /<key>AbandonProcessGroup<\/key>\s*<true\/>/.test(text) : /^KillMode=process\s*$/m.test(text);
3952
4004
  }
3953
- function planInstall(ship, crewHome, hostShip, hasSystemd, job = "run") {
4005
+ function planInstall(ship, crewHome, hostShip, hasSystemd, job = "run", ctx = USER_INSTALL) {
3954
4006
  if (hostShip === "macos") return planLaunchd(ship, crewHome, job);
3955
- if (hostShip === "linux" && hasSystemd) return planSystemd(ship, crewHome, job);
4007
+ if (hostShip === "linux" && hasSystemd) return planSystemd(ship, crewHome, job, ctx);
3956
4008
  if (hostShip === "linux") return planCron(ship, crewHome, job);
3957
4009
  throw new Error(`crew install: no scheduler support yet for ${hostShip} (Windows is planned, not built)`);
3958
4010
  }
@@ -3969,20 +4021,22 @@ function planUninstall(ship, crewHome, hostShip, hasSystemd, job = "run") {
3969
4021
  };
3970
4022
  }
3971
4023
  if (hostShip === "linux" && hasSystemd) {
3972
- const unitDir = join13(homedir7(), ".config", "systemd", "user");
4024
+ const system = existsSync11(systemUnitPathFor(label));
4025
+ const unitDir = system ? SYSTEM_UNIT_DIR : join13(homedir7(), ".config", "systemd", "user");
4026
+ const ctl = system ? ["systemctl"] : ["systemctl", "--user"];
3973
4027
  if (job === "run") {
3974
4028
  return {
3975
4029
  mechanism: "systemd",
3976
4030
  unitPaths: [join13(unitDir, `${label}.service`)],
3977
4031
  crontabLine: null,
3978
- unloadCommands: [["systemctl", "--user", "disable", "--now", `${label}.service`]]
4032
+ unloadCommands: [[...ctl, "disable", "--now", `${label}.service`]]
3979
4033
  };
3980
4034
  }
3981
4035
  return {
3982
4036
  mechanism: "systemd",
3983
4037
  unitPaths: [join13(unitDir, `${label}.service`), join13(unitDir, `${label}.timer`)],
3984
4038
  crontabLine: null,
3985
- unloadCommands: [["systemctl", "--user", "disable", "--now", `${label}.timer`]]
4039
+ unloadCommands: [[...ctl, "disable", "--now", `${label}.timer`]]
3986
4040
  };
3987
4041
  }
3988
4042
  if (hostShip === "linux") {
@@ -4008,11 +4062,12 @@ function planDaemonControl(crewHome, hostShip, hasSystemd) {
4008
4062
  }
4009
4063
  if (hostShip === "linux" && hasSystemd) {
4010
4064
  const unit = `${label}.service`;
4065
+ const ctl = existsSync11(systemUnitPathFor(label)) ? ["systemctl"] : ["systemctl", "--user"];
4011
4066
  return {
4012
4067
  mechanism: "systemd",
4013
- startCommand: ["systemctl", "--user", "start", unit],
4014
- stopCommand: ["systemctl", "--user", "stop", unit],
4015
- statusCommand: ["systemctl", "--user", "status", unit]
4068
+ startCommand: [...ctl, "start", unit],
4069
+ stopCommand: [...ctl, "stop", unit],
4070
+ statusCommand: [...ctl, "status", unit]
4016
4071
  };
4017
4072
  }
4018
4073
  if (hostShip === "linux") {
@@ -4033,11 +4088,14 @@ function runAll(commands, cwd, log) {
4033
4088
  return true;
4034
4089
  }
4035
4090
  async function applyInstall(plan, crewHome, dryRun, log) {
4091
+ let changed2 = plan.crontabLine !== null;
4036
4092
  for (const path of plan.unitPaths) {
4037
4093
  if (dryRun) {
4038
4094
  log.emit(`would write ${path}`);
4039
4095
  continue;
4040
4096
  }
4097
+ const before = existsSync11(path) ? readFileSync12(path, "utf8") : null;
4098
+ if (before !== plan.unitContent[path]) changed2 = true;
4041
4099
  mkdirSync7(dirname5(path), { recursive: true });
4042
4100
  writeFileSync8(path, plan.unitContent[path]);
4043
4101
  log.emit(`wrote ${path}`);
@@ -4067,8 +4125,18 @@ async function applyInstall(plan, crewHome, dryRun, log) {
4067
4125
  log.emit(`loaded (${plan.mechanism}), firing every ${INTERVAL_SECONDS}s starting ${INTERVAL_SECONDS}s from now`);
4068
4126
  }
4069
4127
  }
4128
+ if (plan.lingerCommand) {
4129
+ if (dryRun) {
4130
+ log.emit(`would run: ${plan.lingerCommand.join(" ")}`);
4131
+ } else if (runAll([plan.lingerCommand], crewHome, { emit: log.emit.bind(log), warn: () => void 0 })) {
4132
+ log.emit(`lingering enabled for ${plan.lingerCommand[2]}: the unit survives logout`);
4133
+ } else {
4134
+ log.warn(`could not enable lingering \u2014 ${plan.lingerHint ?? "run loginctl enable-linger by hand"}`);
4135
+ }
4136
+ }
4070
4137
  log.emit(`crew's own log: ${plan.crewLog}`);
4071
4138
  log.emit(`scheduler log (only what escapes the crew, e.g. a crash before it starts): ${plan.schedulerLog}`);
4139
+ return { changed: changed2 };
4072
4140
  }
4073
4141
  async function applyUninstall(plan, crewHome, dryRun, log) {
4074
4142
  if (plan.mechanism === "none") {
@@ -7210,6 +7278,18 @@ var Tracker = class {
7210
7278
  if (!row) return;
7211
7279
  await this.client.records.update(this.route.resolved.shipsModelId, row.id, patch2);
7212
7280
  }
7281
+ /**
7282
+ * Writes (or, with `null`, clears) this ship's `relay_url` (CREW-2803). A
7283
+ * no-op when there is no row for this ship or the Ships table has not taken
7284
+ * the column (probed by the key's absence from the row), so the tunnel
7285
+ * still runs on an install that predates it.
7286
+ */
7287
+ async updateRelayUrl(shipName2, url) {
7288
+ const row = await this.myShipRow(shipName2);
7289
+ if (!row || !("relay_url" in row)) return;
7290
+ if ((row.relay_url ?? null) === url) return;
7291
+ await this.client.records.update(this.route.resolved.shipsModelId, row.id, { relay_url: url });
7292
+ }
7213
7293
  /** The other half of `beatEngaged` — clears engagement back to idle. */
7214
7294
  async beatIdle(shipName2) {
7215
7295
  const row = await this.myShipRow(shipName2);
@@ -10465,8 +10545,8 @@ function doctorSelftestLines(stored, toolchains) {
10465
10545
  // src/cli.ts
10466
10546
  import { resolve as resolve5, dirname as dirname22, join as join44, basename as basename13 } from "path";
10467
10547
  import { fileURLToPath } from "url";
10468
- import { hostname as hostname2, homedir as homedir10 } from "os";
10469
- import { spawnSync as spawnSync7 } from "child_process";
10548
+ import { hostname as hostname3, homedir as homedir10 } from "os";
10549
+ import { spawnSync as spawnSync8 } from "child_process";
10470
10550
  import { parse as parseYaml2 } from "yaml";
10471
10551
 
10472
10552
  // src/argv.ts
@@ -10478,6 +10558,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
10478
10558
  "clear",
10479
10559
  "dir",
10480
10560
  "extra-path",
10561
+ "forge-host",
10481
10562
  "harness",
10482
10563
  "hook",
10483
10564
  "host",
@@ -12718,191 +12799,776 @@ function flagWizardAnswers(f, env) {
12718
12799
  };
12719
12800
  }
12720
12801
 
12721
- // src/release-run.ts
12722
- import { readFileSync as readFileSync23, writeFileSync as writeFileSync19, existsSync as existsSync25, copyFileSync, mkdirSync as mkdirSync17, mkdtempSync as mkdtempSync2, rmSync as rmSync14 } from "fs";
12723
- import { dirname as dirname13, join as join29 } from "path";
12724
- import { tmpdir as tmpdir3 } from "os";
12802
+ // src/provision.ts
12803
+ import { spawnSync as spawnSync6 } from "child_process";
12804
+ import { existsSync as existsSync25, readFileSync as readFileSync23, writeFileSync as writeFileSync19 } from "fs";
12805
+ import { hostname as hostname2 } from "os";
12806
+ import { parseDocument as parseDocument3 } from "yaml";
12725
12807
 
12726
- // src/release.ts
12727
- function describeUnplaceable(unplaceable, verifiedStatus) {
12728
- const stranded = unplaceable.filter((u) => u.ticket.status === verifiedStatus);
12729
- if (stranded.length === 0) return [];
12730
- const notes = [];
12731
- const keysFor = (r) => stranded.filter((u) => u.reason === r).map((u) => u.ticket.issue_id);
12732
- const noRepo = keysFor("no-repo");
12733
- if (noRepo.length) {
12734
- notes.push({
12735
- level: "warn",
12736
- message: `${noRepo.length} verified ticket(s) name no repository and cannot be released: ` + noRepo.join(", ")
12737
- });
12738
- }
12739
- const unknown = keysFor("unknown-repo");
12740
- if (unknown.length) {
12741
- notes.push({
12742
- level: "warn",
12743
- message: `${unknown.length} verified ticket(s) name a repository this ship does not recognise \u2014 run \`crew connect\` to refresh its repo list: ${unknown.join(", ")}`
12744
- });
12745
- }
12746
- const elsewhere = stranded.filter((u) => u.reason === "not-served-here");
12747
- if (elsewhere.length) {
12748
- const byRepoName = /* @__PURE__ */ new Map();
12749
- for (const u of elsewhere) {
12750
- const name = u.repo ?? "?";
12751
- byRepoName.set(name, [...byRepoName.get(name) ?? [], u.ticket.issue_id]);
12752
- }
12753
- notes.push({
12754
- level: "info",
12755
- message: `${elsewhere.length} verified ticket(s) belong to a repository this ship has no checkout for \u2014 another ship releases them: ` + [...byRepoName].map(([n, ids]) => `${n} (${ids.join(", ")})`).join("; ")
12756
- });
12757
- }
12758
- return notes;
12808
+ // src/doctor-fix.ts
12809
+ import { parseDocument as parseDocument2 } from "yaml";
12810
+
12811
+ // src/repos-cmd.ts
12812
+ import { homedir as homedir9 } from "os";
12813
+ import { basename as basename8, join as join29, resolve as resolve3 } from "path";
12814
+ import { isMap, isSeq, parseDocument, stringify as stringify2 } from "yaml";
12815
+ var ReposError = class extends Error {
12816
+ };
12817
+ function normalizeRemote(url) {
12818
+ if (!url) return void 0;
12819
+ let s = url.trim();
12820
+ if (!s) return void 0;
12821
+ const scp = /^[^\s@/]+@[^\s:/]+:(.+)$/.exec(s);
12822
+ if (scp) s = scp[1];
12823
+ else s = s.replace(/^[a-z][a-z0-9+.-]*:\/\/(?:[^/@]+@)?[^/]+\//i, "");
12824
+ s = s.replace(/\/+$/, "").replace(/\.git$/i, "");
12825
+ const parts = s.split("/").filter(Boolean);
12826
+ if (parts.length < 2) return void 0;
12827
+ return parts.slice(-2).join("/").toLowerCase();
12759
12828
  }
12760
- var unplaceableReported = /* @__PURE__ */ new Set();
12761
- function reportUnplaceable(routeName, unplaceable, verified, emit) {
12762
- if (unplaceableReported.has(routeName)) return;
12763
- unplaceableReported.add(routeName);
12764
- for (const note of describeUnplaceable(unplaceable, verified)) {
12765
- if (note.level === "warn") emit.warn(note.message, { step: "release" });
12766
- else emit.emit(note.message, { step: "release" });
12829
+ function matchRepoName(origin, resolved) {
12830
+ const want = normalizeRemote(origin);
12831
+ if (!want || !resolved?.repoRemotes) return void 0;
12832
+ for (const [id, remote] of Object.entries(resolved.repoRemotes)) {
12833
+ if (normalizeRemote(remote) === want) return resolved.repoNames?.[id];
12767
12834
  }
12835
+ return void 0;
12768
12836
  }
12769
- function checkGuards(cwd, base = "main") {
12770
- const branch = currentBranch(cwd);
12771
- if (branch !== base) {
12772
- return { kind: "branch", detail: `primary checkout is on '${branch}', not ${base}` };
12837
+ function routeNamesOf(text) {
12838
+ const routes = parseDocument(text).get("routes", true);
12839
+ if (!isSeq(routes)) return [];
12840
+ return routes.items.flatMap((i) => isMap(i) && typeof i.get("route") === "string" ? [i.get("route")] : []);
12841
+ }
12842
+ function parseAddArgs(args, routes, isDir) {
12843
+ const rest = [...args];
12844
+ let route2;
12845
+ if (rest.length > 0 && routes.includes(rest[0])) route2 = rest.shift();
12846
+ else if (rest.length > 0 && /^[^/\s]+\/[^/\s]+$/.test(rest[0]) && !isDir(rest[0])) {
12847
+ throw new ReposError(`no route "${rest[0]}" in the config (have: ${routes.join(", ") || "none"})`);
12773
12848
  }
12774
- const changes = status(cwd);
12775
- if (changes.length === 0) return null;
12776
- const untracked = changes.filter((c) => c.untracked).length;
12777
- const names = changes.slice(0, 3).map((c) => c.path).join(", ");
12778
- const more = changes.length > 3 ? `, +${changes.length - 3} more` : "";
12779
- const untrackedNote = untracked > 0 ? ` (${untracked} untracked)` : "";
12780
- return {
12781
- kind: "dirty",
12782
- detail: `working tree has ${changes.length} change(s)${untrackedNote}: ${names}${more}`
12783
- };
12849
+ if (!route2) {
12850
+ if (routes.length === 1) route2 = routes[0];
12851
+ else throw new ReposError(routes.length === 0 ? "the config has no routes \u2014 run `crew connect` first" : `several routes are configured; name one: ${routes.join(", ")}`);
12852
+ }
12853
+ if (rest.length > 2) throw new ReposError("too many arguments: expected [route] [name] [path]");
12854
+ if (rest.length === 2) return { route: route2, name: rest[0], path: rest[1] };
12855
+ if (rest.length === 1) return isDir(rest[0]) ? { route: route2, path: rest[0] } : { route: route2, name: rest[0], path: "." };
12856
+ return { route: route2, path: "." };
12784
12857
  }
12785
- function heldByBlockers(tickets, verifiedStatus, info) {
12786
- if (!info) return [];
12787
- return tickets.filter((t) => t.status === verifiedStatus && unresolvedBlockers(t, info, { mergeGated: false }).length > 0).map((ticket) => ({ ticket, blockers: describeUnresolved(ticket, info, { mergeGated: false }) }));
12858
+ function expandDir(p, base) {
12859
+ const home = p === "~" ? homedir9() : p.startsWith("~/") ? `${homedir9()}/${p.slice(2)}` : p;
12860
+ return resolve3(base, home);
12788
12861
  }
12789
- var describeHeld = (held) => held.map((h) => `${h.ticket.issue_tag ?? h.ticket.issue_id} held: blocked by ${h.blockers}`);
12790
- var CHANGELOG_LINE = /^Changelog:[ \t]*(.*)$/gm;
12791
- var TICKET_REF_AT_START = /^([A-Z][A-Z0-9]*)-(\d+)\b/;
12792
- function dedupeChangelogEntries(entries, ticket) {
12793
- const own = (ticket.issue_tag ?? ticket.issue_id).match(/^([A-Z][A-Z0-9]*)-(\d+)$/);
12794
- const seen = /* @__PURE__ */ new Set();
12795
- const out = [];
12796
- for (const raw of entries) {
12797
- let line = raw.trim();
12798
- const ref = line.match(TICKET_REF_AT_START);
12799
- let key = line;
12800
- if (ref) {
12801
- const prefix = ref[1];
12802
- const num = ref[2];
12803
- const canonical = own && own[2] === num ? own[1] : prefix;
12804
- line = `${canonical}-${num}${line.slice(ref[0].length)}`;
12805
- key = `${canonical}-${num}`;
12862
+ function routeMapOf(doc, route2) {
12863
+ const routes = doc.get("routes", true);
12864
+ if (isSeq(routes)) {
12865
+ for (const item of routes.items) {
12866
+ if (isMap(item) && item.get("route") === route2) return item;
12806
12867
  }
12807
- if (seen.has(key)) continue;
12808
- seen.add(key);
12809
- out.push(line);
12810
12868
  }
12811
- return out;
12812
- }
12813
- var BUMP_LINE = /^Bump:[ \t]*(major|minor|patch)\b/gim;
12814
- function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => branchForIssue(cwd, t.issue_id), base = "main", blockerInfo, ref = "HEAD") {
12815
- const held = new Set(heldByBlockers(tickets, contract.statuses.verified, blockerInfo).map((h) => h.ticket.id));
12816
- const verified = tickets.filter((t) => t.status === contract.statuses.verified && !held.has(t.id)).sort((a, b) => Number.parseInt(a.issue_id.replace(/^\D+/, ""), 10) - Number.parseInt(b.issue_id.replace(/^\D+/, ""), 10));
12817
- return verified.map((ticket) => {
12818
- const branch = branchFor(ticket);
12819
- if (!branch) {
12820
- const keys = referenceKeys(ticket);
12821
- const unreleased = lastReleased ? findKeyInRange(cwd, keys, `${lastReleased}..${ref}`) : null;
12822
- if (unreleased) {
12823
- return {
12824
- ticket,
12825
- branch: null,
12826
- entries: [],
12827
- usedFallback: false,
12828
- bump: "patch",
12829
- majorRequested: false,
12830
- skipReason: "already-merged"
12831
- };
12832
- }
12833
- const anywhere = findKeyInRange(cwd, keys, ref);
12834
- if (anywhere) {
12835
- return {
12836
- ticket,
12837
- branch: null,
12838
- entries: [],
12839
- usedFallback: false,
12840
- bump: "patch",
12841
- majorRequested: false,
12842
- skipReason: lastReleased ? "already-released" : "already-merged",
12843
- mergedSha: anywhere
12844
- };
12845
- }
12846
- return {
12847
- ticket,
12848
- branch: null,
12849
- entries: [],
12850
- usedFallback: false,
12851
- bump: "patch",
12852
- majorRequested: false,
12853
- skipReason: "never-built"
12854
- };
12855
- }
12856
- const squash = findSquashOfBranch(cwd, referenceKeys(ticket), branch, base, base);
12857
- if (squash) {
12858
- const released = !!lastReleased && isAncestor(cwd, squash, lastReleased);
12859
- return {
12860
- ticket,
12861
- branch,
12862
- entries: [],
12863
- usedFallback: false,
12864
- bump: "patch",
12865
- majorRequested: false,
12866
- skipReason: released ? "already-released" : "already-merged",
12867
- mergedSha: squash
12868
- };
12869
- }
12870
- const bodies = commitBodies(cwd, `${base}..${branch}`);
12871
- const entries = dedupeChangelogEntries(
12872
- [...bodies.matchAll(CHANGELOG_LINE)].map((m) => m[1].trim()).filter(Boolean),
12873
- ticket
12874
- );
12875
- const usedFallback = entries.length === 0;
12876
- const { size, majorRequested } = requestedBump(bodies);
12877
- return {
12878
- ticket,
12879
- branch,
12880
- entries: usedFallback ? [`${ticket.title || ticket.issue_id} (${ticket.issue_id})`] : entries,
12881
- usedFallback,
12882
- bump: size,
12883
- majorRequested
12884
- };
12885
- });
12869
+ const have = isSeq(routes) ? routes.items.map((i) => isMap(i) ? String(i.get("route")) : "?").join(", ") : "none";
12870
+ throw new ReposError(`no route "${route2}" in the config (have: ${have})`);
12886
12871
  }
12887
- function planReleasedBranches(cwd, tickets, contract, branchFor, base = "main", tagPattern = "v*") {
12888
- const eligible = /* @__PURE__ */ new Set([contract.statuses.building, contract.statuses.needsHuman]);
12889
- const out = [];
12890
- for (const ticket of tickets) {
12891
- if (!eligible.has(ticket.status)) continue;
12892
- const branch = branchFor(ticket);
12893
- if (!branch) continue;
12894
- const sha = findSquashByBranchTip(cwd, referenceKeys(ticket), branch, base);
12895
- if (!sha) continue;
12896
- const tag = firstReleaseTagContaining(cwd, sha, tagPattern);
12897
- if (tag) out.push({ ticket, sha, tag });
12872
+ function configuredRepos(text, route2, base) {
12873
+ const doc = parseDocument(text);
12874
+ const m = routeMapOf(doc, route2);
12875
+ const repos = m.get("repos");
12876
+ if (isMap(repos)) {
12877
+ return repos.items.map((p) => {
12878
+ const name = String(p.key.value ?? p.key);
12879
+ const v = p.value;
12880
+ const dir2 = isMap(v) ? String(v.get("dir") ?? "") : String(v?.value ?? "");
12881
+ return { name, dir: expandDir(dir2, base) };
12882
+ });
12898
12883
  }
12899
- return out;
12884
+ const dir = m.get("dir");
12885
+ return dir ? [{ name: basename8(String(dir)), dir: expandDir(String(dir), base) }] : [];
12900
12886
  }
12901
- function requestedBump(bodies) {
12902
- const asks = [...bodies.matchAll(BUMP_LINE)].map((m) => m[1].toLowerCase());
12903
- const majorRequested = asks.includes("major");
12904
- const size = asks.includes("minor") || majorRequested ? "minor" : "patch";
12905
- return { size, majorRequested };
12887
+ function cloneBaseOf(text, route2, base, isCheckout) {
12888
+ const m = routeMapOf(parseDocument(text), route2);
12889
+ const repos = m.get("repos", true);
12890
+ if (isMap(repos) && repos.items.length > 0) return null;
12891
+ const dir = m.get("dir");
12892
+ if (!dir) return null;
12893
+ const abs = expandDir(String(dir), base);
12894
+ return isCheckout(abs) ? null : abs;
12895
+ }
12896
+ function routeSetsHooks(text, route2, name) {
12897
+ const m = routeMapOf(parseDocument(text), route2);
12898
+ const repos = m.get("repos", true);
12899
+ let target = m;
12900
+ if (isMap(repos)) target = repos.get(name, true);
12901
+ if (!isMap(target)) return false;
12902
+ const hooks = target.get("hooks", true);
12903
+ return isMap(hooks) && hooks.items.length > 0;
12904
+ }
12905
+ function listRepoRows(i) {
12906
+ const cloneBase = i.isCheckout ? cloneBaseOf(i.text, i.route, i.base, i.isCheckout) : null;
12907
+ const checkouts = configuredRepos(i.text, i.route, i.base).filter((c) => c.dir !== cloneBase);
12908
+ const claimed = /* @__PURE__ */ new Set();
12909
+ const hooksSourceOf = (c) => i.exists(c.dir) && i.repoHasHooks(c.dir) ? "repo" : routeSetsHooks(i.text, i.route, c.name) ? "route" : "none";
12910
+ const originFor = (c, remote) => {
12911
+ const o = i.originOf(c.dir) ?? null;
12912
+ if (!o) return null;
12913
+ return remote !== null && normalizeRemote(o) === normalizeRemote(remote) ? null : o;
12914
+ };
12915
+ const rows = [];
12916
+ const names = i.resolved?.repoNames ?? {};
12917
+ const remotes = i.resolved?.repoRemotes ?? {};
12918
+ for (const [id, name] of Object.entries(names)) {
12919
+ const remote = remotes[id] ?? null;
12920
+ const want = normalizeRemote(remote);
12921
+ let idx = checkouts.findIndex((c2, n) => !claimed.has(n) && want !== void 0 && normalizeRemote(i.originOf(c2.dir)) === want);
12922
+ if (idx < 0) idx = checkouts.findIndex((c2, n) => !claimed.has(n) && c2.name === name);
12923
+ const c = idx >= 0 ? checkouts[idx] : void 0;
12924
+ if (idx >= 0) claimed.add(idx);
12925
+ rows.push({
12926
+ name,
12927
+ remote,
12928
+ repoRowId: id,
12929
+ attached: c !== void 0,
12930
+ dir: c?.dir ?? null,
12931
+ exists: c ? i.exists(c.dir) : false,
12932
+ origin: c ? originFor(c, remote) : null,
12933
+ matchedRow: c ? name : null,
12934
+ hooksSource: c ? hooksSourceOf(c) : "none"
12935
+ });
12936
+ }
12937
+ checkouts.forEach((c, n) => {
12938
+ if (claimed.has(n)) return;
12939
+ rows.push({
12940
+ name: c.name,
12941
+ remote: null,
12942
+ repoRowId: null,
12943
+ attached: true,
12944
+ dir: c.dir,
12945
+ exists: i.exists(c.dir),
12946
+ origin: originFor(c, null),
12947
+ matchedRow: null,
12948
+ hooksSource: hooksSourceOf(c)
12949
+ });
12950
+ });
12951
+ return rows;
12952
+ }
12953
+ function planRepoAdd(i) {
12954
+ const doc = parseDocument(i.text);
12955
+ if (doc.errors.length > 0) throw new ReposError(`config is not valid YAML \u2014 ${doc.errors[0].message}`);
12956
+ const m = routeMapOf(doc, i.route);
12957
+ let existing = configuredRepos(i.text, i.route, i.base);
12958
+ const target = expandDir(i.path, i.base);
12959
+ let droppedDir;
12960
+ const placeholder = m.get("dir");
12961
+ const reposNow = m.get("repos", true);
12962
+ if (i.isCheckout && placeholder && !(isMap(reposNow) && reposNow.items.length > 0)) {
12963
+ const placeholderDir = expandDir(String(placeholder), i.base);
12964
+ if (placeholderDir !== target && !i.isCheckout(placeholderDir)) {
12965
+ droppedDir = placeholderDir;
12966
+ m.delete("dir");
12967
+ existing = [];
12968
+ }
12969
+ }
12970
+ if (existing.some((e) => e.dir === target)) {
12971
+ throw new ReposError(`${target} is already configured on route ${i.route}`);
12972
+ }
12973
+ const matched = matchRepoName(i.origin, i.resolved);
12974
+ const name = matched ?? i.name ?? normalizeRemote(i.origin)?.split("/").pop() ?? basename8(target);
12975
+ let warning;
12976
+ if (!matched) {
12977
+ const known = Object.values(i.resolved?.repoNames ?? {});
12978
+ warning = i.origin ? `the tracker has no Repos row for remote ${normalizeRemote(i.origin) ?? i.origin}${known.length ? ` (known: ${known.join(", ")})` : ""} \u2014 added anyway; create the row so ticket routing can find it` : "this checkout has no origin remote, so it cannot be matched to a Repos row \u2014 added anyway";
12979
+ }
12980
+ const repos = m.get("repos", true);
12981
+ let mode;
12982
+ if (isMap(repos)) {
12983
+ if (repos.has(name)) throw new ReposError(`route ${i.route} already has a repo named "${name}" (${String(repos.get(name))})`);
12984
+ repos.set(name, target);
12985
+ mode = "repos";
12986
+ } else if (m.has("dir") && existing.length > 0) {
12987
+ const old = existing[0];
12988
+ const oldName = i.nameForExisting?.(old.dir) ?? old.name;
12989
+ if (oldName === name) throw new ReposError(`route ${i.route} already has a repo named "${name}" (${old.dir})`);
12990
+ const oldDir = String(m.get("dir"));
12991
+ const pair = m.items.find((p) => p.key.value === "dir");
12992
+ pair.key = doc.createNode("repos");
12993
+ pair.value = doc.createNode({ [oldName]: oldDir, [name]: target });
12994
+ mode = "repos";
12995
+ } else if (droppedDir) {
12996
+ m.set("repos", doc.createNode({ [name]: target }));
12997
+ mode = "repos";
12998
+ } else {
12999
+ m.set("dir", target);
13000
+ mode = "dir";
13001
+ }
13002
+ return { text: String(doc), mode, name, block: renderBlock(m), warning, unmatched: !matched, droppedDir };
13003
+ }
13004
+ function planRepoRemove(text, base, route2, path) {
13005
+ const doc = parseDocument(text);
13006
+ if (doc.errors.length > 0) throw new ReposError(`config is not valid YAML \u2014 ${doc.errors[0].message}`);
13007
+ const m = routeMapOf(doc, route2);
13008
+ const target = expandDir(path, base);
13009
+ const existing = configuredRepos(text, route2, base);
13010
+ const hit = existing.find((e) => e.dir === target);
13011
+ if (!hit) throw new ReposError(`${target} is not configured on route ${route2}`);
13012
+ const repos = m.get("repos", true);
13013
+ if (isMap(repos)) {
13014
+ repos.delete(hit.name);
13015
+ const left = existing.filter((e) => e !== hit);
13016
+ if (left.length === 1) {
13017
+ const pair = m.items.find((p) => p.key.value === "repos");
13018
+ pair.key = doc.createNode("dir");
13019
+ pair.value = doc.createNode(String(repos.get(left[0].name)));
13020
+ } else if (left.length === 0) m.delete("repos");
13021
+ } else {
13022
+ m.delete("dir");
13023
+ }
13024
+ return { text: String(doc), name: hit.name };
13025
+ }
13026
+ function renderBlock(m) {
13027
+ return stringify2([m.toJSON()]).trimEnd();
13028
+ }
13029
+ var LOCAL_RELEASE_MODES = /* @__PURE__ */ new Set(["integrate", "external"]);
13030
+ function planRepoRow(i) {
13031
+ const remote = normalizeRemote(i.origin);
13032
+ if (!remote) throw new ReposError(`cannot derive an owner/repo from origin "${i.origin}"`);
13033
+ const mode = i.spec?.releaseMode;
13034
+ return {
13035
+ name: i.name,
13036
+ remote,
13037
+ platform: i.spec?.platform ?? "unix",
13038
+ release_mode: mode ? LOCAL_RELEASE_MODES.has(mode) ? "local" : mode : "local",
13039
+ ci_provider: i.spec?.ciProvider ?? "none",
13040
+ enabled: true,
13041
+ project_id: i.projectId
13042
+ };
13043
+ }
13044
+ function resolveRepoProject(projects, opt, routeAreaId) {
13045
+ if (opt) {
13046
+ const want = opt.toLowerCase();
13047
+ const hit = projects.find((p) => p.id === opt || p.name?.toLowerCase() === want);
13048
+ if (!hit) throw new ReposError(`no Projects row "${opt}" (have: ${projects.map((p) => p.name ?? p.id).join(", ") || "none"})`);
13049
+ return hit.id;
13050
+ }
13051
+ if (routeAreaId) return routeAreaId;
13052
+ if (projects.length === 1) return projects[0].id;
13053
+ throw new ReposError(
13054
+ `cannot tell which project the new Repos row belongs to \u2014 pass --project <name> (have: ${projects.map((p) => p.name ?? p.id).join(", ") || "none"}), or --no-create to skip creating it`
13055
+ );
13056
+ }
13057
+ function parseCloneArgs(args, routes) {
13058
+ const rest = [...args];
13059
+ let route2;
13060
+ if (rest.length > 1 && routes.includes(rest[0])) route2 = rest.shift();
13061
+ else if (rest.length > 1) throw new ReposError(`no route "${rest[0]}" in the config (have: ${routes.join(", ") || "none"})`);
13062
+ if (!route2) {
13063
+ if (routes.length === 1) route2 = routes[0];
13064
+ else throw new ReposError(routes.length === 0 ? "the config has no routes \u2014 run `crew connect` first" : `several routes are configured; name one: ${routes.join(", ")}`);
13065
+ }
13066
+ if (rest.length !== 1) throw new ReposError("expected [route] <name> \u2014 the Repos row to clone (see `crew repos list`)");
13067
+ return { route: route2, name: rest[0] };
13068
+ }
13069
+ function planRepoClone(i) {
13070
+ const names = i.resolved?.repoNames ?? {};
13071
+ const id = Object.keys(names).find((k) => names[k].toLowerCase() === i.name.toLowerCase());
13072
+ if (!id) {
13073
+ const known = Object.values(names);
13074
+ throw new ReposError(`the tracker has no Repos row named "${i.name}" for route ${i.route}${known.length ? ` (known: ${known.join(", ")})` : " \u2014 run `crew connect` to refresh them"}`);
13075
+ }
13076
+ const name = names[id];
13077
+ const remote = normalizeRemote(i.resolved?.repoRemotes?.[id]);
13078
+ if (!remote) {
13079
+ throw new ReposError(
13080
+ `the Repos row "${name}" has no remote to clone from \u2014 attach an existing checkout with \`crew repos add ${i.route} ${name} <path>\`, or set the row's remote in the tracker and re-run \`crew connect\``
13081
+ );
13082
+ }
13083
+ const doc = parseDocument(i.text);
13084
+ const m = routeMapOf(doc, i.route);
13085
+ const existing = configuredRepos(i.text, i.route, i.base);
13086
+ if (existing.some((e) => e.name.toLowerCase() === name.toLowerCase())) {
13087
+ throw new ReposError(`route ${i.route} already has a checkout named "${name}" \u2014 nothing to clone`);
13088
+ }
13089
+ const ship = doc.get("ship", true);
13090
+ const basePath = String(m.get("reposBasePath") ?? (isMap(ship) ? ship.get("reposBasePath") : void 0) ?? "~/Crew");
13091
+ const target = i.dir ? expandDir(i.dir, i.base) : join29(expandDir(basePath, i.base), i.route.split("/")[0], name);
13092
+ const add = planRepoAdd({ text: i.text, base: i.base, route: i.route, path: target, origin: remote, resolved: i.resolved, name, isCheckout: i.isCheckout });
13093
+ return { ...add, remote, target };
13094
+ }
13095
+
13096
+ // src/doctor-fix.ts
13097
+ var sound = (r) => r.hasRepos && r.problems.length === 0;
13098
+ function planFix(f) {
13099
+ const actions = [];
13100
+ const notes = [];
13101
+ for (const r of f.routes) {
13102
+ if (!r.enabled && sound(r)) {
13103
+ actions.push({ kind: "enable", route: r.route });
13104
+ const gaps = r.readiness ?? [];
13105
+ if (gaps.length) {
13106
+ notes.push(`${r.route}: ${gaps.length} release-readiness gap${gaps.length === 1 ? "" : "s"} (${gaps[0]}${gaps.length > 1 ? ", ..." : ""}) \u2014 the route will run, but releases stop at the first missing hook until it is added`);
13107
+ }
13108
+ } else if (!sound(r) && !r.enabled) {
13109
+ notes.push(`${r.route}: not enabled \u2014 ${r.hasRepos ? r.problems[0] : "no checkout is configured"}`);
13110
+ }
13111
+ }
13112
+ if (f.schedulerAbsent && f.routes.some(sound)) {
13113
+ if (f.host === "windows") notes.push("scheduler support is not built for Windows yet \u2014 nothing to install; run `crew run` by hand");
13114
+ else actions.push({ kind: "install" });
13115
+ }
13116
+ return { actions, notes };
13117
+ }
13118
+ async function gatherHealth(c, check) {
13119
+ let problems;
13120
+ let readiness = [];
13121
+ try {
13122
+ const r = await check();
13123
+ if (Array.isArray(r)) problems = r;
13124
+ else ({ problems, readiness } = r);
13125
+ } catch (e) {
13126
+ problems = [`checks could not run: ${e instanceof Error ? e.message : String(e)}`];
13127
+ }
13128
+ return { route: c.route, enabled: c.enabled, hasRepos: c.hasRepos, problems, readiness };
13129
+ }
13130
+ function routesInScope(routes, named) {
13131
+ return named ? routes.filter((r) => r.route === named) : routes;
13132
+ }
13133
+ function planEnable(text, route2) {
13134
+ const doc = parseDocument2(text);
13135
+ routeMapOf(doc, route2).set("enabled", true);
13136
+ return String(doc);
13137
+ }
13138
+ function schedulerInstalled(plan, exists, crontab) {
13139
+ if (plan.mechanism === "cron") return plan.cronMarker !== null && crontab.includes(plan.cronMarker);
13140
+ return plan.unitPaths.length > 0 && plan.unitPaths.every(exists);
13141
+ }
13142
+
13143
+ // src/provision.ts
13144
+ var PROVISION_EXIT = { ok: 0, failed: 1, problem: 2, choice: 3 };
13145
+ function parseEvents(stdout) {
13146
+ const out = [];
13147
+ for (const line of stdout.split("\n")) {
13148
+ const t = line.trim();
13149
+ if (!t.startsWith("{")) continue;
13150
+ try {
13151
+ out.push(JSON.parse(t));
13152
+ } catch {
13153
+ }
13154
+ }
13155
+ return out;
13156
+ }
13157
+ async function runProvision(o, deps, write, human) {
13158
+ const summary = { ok: false, state: "failed", changed: false, routes: [], forges: [] };
13159
+ const emit = (event, message, data = {}) => {
13160
+ if (o.json) write(`${JSON.stringify({ ...data, event, message })}
13161
+ `);
13162
+ else human(`${message}
13163
+ `);
13164
+ };
13165
+ const fail = (code, message, data = {}) => {
13166
+ emit("problem", message, data);
13167
+ return { code, summary: { ...summary, ok: false, state: "failed" } };
13168
+ };
13169
+ if (!o.key) return fail(PROVISION_EXIT.problem, "no connect key: pass --key or set CREW_CONNECT_KEY", { stage: "input" });
13170
+ if (o.routes.length === 0) {
13171
+ return fail(PROVISION_EXIT.problem, "no routes to serve: pass --route WORKSPACE[/PROJECT] (repeatable) or set CREW_PROVISION_ROUTES", { stage: "input" });
13172
+ }
13173
+ for (const host of o.forgeHosts) {
13174
+ if (!o.tokens[host]) return fail(PROVISION_EXIT.problem, `no token for forge ${host}: set CREW_FORGE_TOKEN or CREW_FORGE_TOKEN_${envKey(host)}`, { stage: "input" });
13175
+ }
13176
+ const shipName2 = o.shipName ?? deps.defaultShipName();
13177
+ summary.ship = shipName2;
13178
+ const routeNames = [];
13179
+ for (const route2 of o.routes) {
13180
+ const args = [
13181
+ "connect",
13182
+ route2,
13183
+ "--json",
13184
+ "--no-wizard",
13185
+ "--ship-name",
13186
+ shipName2,
13187
+ ...o.agentBin ? ["--agent-bin", o.agentBin] : [],
13188
+ ...o.extraPath ? ["--extra-path", o.extraPath] : [],
13189
+ ...o.baseUrl ? ["--base-url", o.baseUrl] : [],
13190
+ ...o.dryRun ? ["--dry-run"] : [],
13191
+ ...o.choices.flatMap((c) => ["--choose", c])
13192
+ ];
13193
+ const res = deps.crew(args, { env: { CREW_CONNECT_KEY: o.key } });
13194
+ const events = parseEvents(res.stdout);
13195
+ for (const e of events) if (o.json) write(`${JSON.stringify({ ...e, phase: "connect" })}
13196
+ `);
13197
+ if (res.status !== 0) {
13198
+ const problem = events.find((e) => e.event === "problem");
13199
+ const msg = String(problem?.message ?? (res.stderr.trim().split("\n").pop() || `crew connect ${route2} exited ${res.status}`));
13200
+ if (!o.json) human(`crew provision: connect ${route2}: ${msg}
13201
+ `);
13202
+ return { code: res.status === PROVISION_EXIT.choice ? PROVISION_EXIT.choice : res.status, summary: { ...summary, ok: false, state: "failed" } };
13203
+ }
13204
+ const done = events.find((e) => e.event === "done");
13205
+ const name = String(done?.route ?? route2);
13206
+ routeNames.push(name);
13207
+ const shipRow = events.find((e) => e.event === "ship_row");
13208
+ if (shipRow?.action === "created") summary.changed = true;
13209
+ if (done?.wroteConfig === true) summary.changed = true;
13210
+ emit("connect", `connected ${name}${shipRow ? ` (ship row ${String(shipRow.action)})` : ""}`, { route: name });
13211
+ }
13212
+ summary.routes = routeNames;
13213
+ if (o.dryRun) {
13214
+ summary.ok = true;
13215
+ summary.state = "dry-run";
13216
+ emit("done", "dry run: connected nothing past discovery; no forge, route, unit or daemon was touched", { summary });
13217
+ return { code: PROVISION_EXIT.ok, summary };
13218
+ }
13219
+ const configPath = deps.configFile();
13220
+ if (!configPath) return fail(PROVISION_EXIT.failed, "connect finished but no crew.yaml exists", { stage: "connect" });
13221
+ summary.configPath = configPath;
13222
+ for (const host of o.forgeHosts) {
13223
+ const token = o.tokens[host];
13224
+ const stored = deps.storedToken(host);
13225
+ if (stored === token) {
13226
+ summary.forges.push({ host, action: "unchanged" });
13227
+ emit("forge", `forge ${host}: token already stored`, { host, action: "unchanged" });
13228
+ continue;
13229
+ }
13230
+ const sub = stored === null ? "set" : "rotate";
13231
+ const res = deps.crew(["forge", sub, host, "--token-stdin"], { input: `${token}
13232
+ ` });
13233
+ if (res.status !== 0) return fail(PROVISION_EXIT.failed, `forge ${host}: ${res.stderr.trim() || `crew forge ${sub} exited ${res.status}`}`, { stage: "forge", host });
13234
+ const action = sub === "set" ? "stored" : "rotated";
13235
+ summary.forges.push({ host, action });
13236
+ summary.changed = true;
13237
+ emit("forge", `forge ${host}: token ${action}`, { host, action });
13238
+ }
13239
+ for (const route2 of routeNames) {
13240
+ if (deps.enableRoute(route2)) {
13241
+ summary.changed = true;
13242
+ emit("route_enabled", `enabled route ${route2}`, { route: route2 });
13243
+ }
13244
+ }
13245
+ const before = deps.installState();
13246
+ if (before.unitsStale || !before.running || !before.lingering) {
13247
+ const res = deps.crew(["install"]);
13248
+ if (res.status !== 0) {
13249
+ return fail(PROVISION_EXIT.failed, `crew install exited ${res.status}: ${(res.stderr || res.stdout).trim().split("\n").pop() ?? ""}`, { stage: "install" });
13250
+ }
13251
+ summary.changed = true;
13252
+ emit("install", `installed ${before.mechanism} units`, { mechanism: before.mechanism });
13253
+ }
13254
+ const after = deps.installState();
13255
+ summary.daemon = { mechanism: after.mechanism, running: after.running, lingering: after.lingering };
13256
+ if (!after.running) {
13257
+ return fail(PROVISION_EXIT.failed, `the ${after.mechanism} daemon unit is not running after install`, { stage: "daemon" });
13258
+ }
13259
+ emit("daemon", `daemon running (${after.mechanism}${after.lingering ? ", survives logout" : ", will stop at logout: lingering is off"})`, { ...summary.daemon });
13260
+ summary.ok = true;
13261
+ summary.state = summary.changed ? "provisioned" : "already provisioned";
13262
+ emit("done", summary.state, { summary });
13263
+ return { code: PROVISION_EXIT.ok, summary };
13264
+ }
13265
+ function envKey(host) {
13266
+ return host.toUpperCase().replace(/[^A-Z0-9]/g, "_");
13267
+ }
13268
+ function tokensFromEnv(hosts, env) {
13269
+ const out = {};
13270
+ for (const h of hosts) {
13271
+ const t = env[`CREW_FORGE_TOKEN_${envKey(h)}`] ?? env.CREW_FORGE_TOKEN;
13272
+ if (t?.trim()) out[h] = t.trim();
13273
+ }
13274
+ return out;
13275
+ }
13276
+ function repeated(argv2, name) {
13277
+ const out = [];
13278
+ for (let i = 0; i < argv2.length; i++) {
13279
+ const a = argv2[i];
13280
+ if (a === `--${name}`) {
13281
+ const v = argv2[++i];
13282
+ if (v !== void 0) out.push(v);
13283
+ } else if (a.startsWith(`--${name}=`)) out.push(a.slice(name.length + 3));
13284
+ }
13285
+ return out;
13286
+ }
13287
+ function splitList(v) {
13288
+ return (v ?? "").split(/[\s,]+/).filter(Boolean);
13289
+ }
13290
+ function provisionOptionsFrom(argv2, env) {
13291
+ const one = (name) => repeated(argv2, name).at(-1);
13292
+ const routes = repeated(argv2, "route");
13293
+ const forgeFlags = repeated(argv2, "forge-host");
13294
+ const tokenAvailable = !!env.CREW_FORGE_TOKEN?.trim() || Object.keys(env).some((k) => k.startsWith("CREW_FORGE_TOKEN_") && env[k]?.trim());
13295
+ const forgeHosts = (forgeFlags.length ? forgeFlags : tokenAvailable ? splitList(env.CREW_FORGE_HOSTS ?? "github.com") : []).map((h) => h.toLowerCase());
13296
+ return {
13297
+ key: one("key") ?? env.CREW_CONNECT_KEY,
13298
+ routes: routes.length ? routes : splitList(env.CREW_PROVISION_ROUTES),
13299
+ shipName: one("ship-name") ?? env.CREW_SHIP_NAME,
13300
+ agentBin: one("agent-bin"),
13301
+ extraPath: one("extra-path"),
13302
+ baseUrl: one("base-url"),
13303
+ forgeHosts,
13304
+ tokens: tokensFromEnv(forgeHosts, env),
13305
+ json: argv2.includes("--json"),
13306
+ dryRun: argv2.includes("--dry-run"),
13307
+ choices: repeated(argv2, "choose")
13308
+ };
13309
+ }
13310
+ function realProvisionDeps(crewHome) {
13311
+ const spawnCrew = (args, o = {}) => {
13312
+ const [bin, ...pre] = crewInvocation(crewHome);
13313
+ const env = { ...process.env, ...o.env };
13314
+ delete env.CREW_FORGE_TOKEN;
13315
+ const res = spawnSync6(bin, [...pre, ...args], { cwd: crewHome, encoding: "utf8", env, input: o.input, maxBuffer: 64 * 1024 * 1024 });
13316
+ return { status: res.status ?? 1, stdout: res.stdout ?? "", stderr: `${res.stderr ?? ""}${res.error ? String(res.error) : ""}` };
13317
+ };
13318
+ const configFile = () => {
13319
+ const f = findConfigFile(crewHome);
13320
+ return existsSync25(f) ? f : null;
13321
+ };
13322
+ return {
13323
+ crew: spawnCrew,
13324
+ configFile,
13325
+ enableRoute(route2) {
13326
+ const file = configFile();
13327
+ if (!file) return false;
13328
+ const text = readFileSync23(file, "utf8");
13329
+ const doc = parseDocument3(text);
13330
+ const routes = doc.get("routes");
13331
+ const entry = routes?.items?.find((r) => r.get("route") === route2);
13332
+ if (!entry) throw new Error(`route ${route2} is not in ${file}`);
13333
+ if (entry.get("enabled") === true) return false;
13334
+ writeFileSync19(file, planEnable(text, route2));
13335
+ return true;
13336
+ },
13337
+ installState() {
13338
+ const file = configFile();
13339
+ const cfg = loadConfig(crewHome, file ?? void 0, { allowNoRoutes: true });
13340
+ const host = hostPlatform();
13341
+ const ctx = installContext();
13342
+ const systemd = detectSystemd();
13343
+ const jobs = ["run", "release", "passengers"];
13344
+ let unitsStale = false;
13345
+ let mechanism = "none";
13346
+ for (const job of jobs) {
13347
+ const plan = planInstall(cfg.ship, crewHome, host, systemd, job, ctx);
13348
+ mechanism = plan.mechanism;
13349
+ if (plan.mechanism === "cron") {
13350
+ unitsStale = true;
13351
+ continue;
13352
+ }
13353
+ for (const p of plan.unitPaths) {
13354
+ if (!existsSync25(p) || readFileSync23(p, "utf8") !== plan.unitContent[p]) unitsStale = true;
13355
+ }
13356
+ }
13357
+ const label = labelFor(crewHome, "run");
13358
+ let running = false;
13359
+ let lingering = true;
13360
+ if (mechanism === "systemd") {
13361
+ const system = ctx.root || existsSync25(systemUnitPathFor(label));
13362
+ const ctl = system ? ["systemctl"] : ["systemctl", "--user"];
13363
+ running = spawnSync6(ctl[0], [...ctl.slice(1), "is-active", "--quiet", `${label}.service`]).status === 0;
13364
+ if (!system) {
13365
+ const out = spawnSync6("loginctl", ["show-user", ctx.user, "-p", "Linger"], { encoding: "utf8" });
13366
+ lingering = /Linger=yes/.test(out.stdout ?? "");
13367
+ }
13368
+ } else if (mechanism === "cron") {
13369
+ running = true;
13370
+ } else if (mechanism === "launchd") {
13371
+ running = spawnSync6("launchctl", ["list", label]).status === 0;
13372
+ }
13373
+ return { mechanism, unitsStale, running, lingering };
13374
+ },
13375
+ storedToken(host) {
13376
+ const res = spawnCrew(["credential", "get"], { input: `protocol=https
13377
+ host=${host}
13378
+
13379
+ ` });
13380
+ const m = /^password=(.*)$/m.exec(res.stdout);
13381
+ return m ? m[1] : null;
13382
+ },
13383
+ defaultShipName: () => hostname2()
13384
+ };
13385
+ }
13386
+
13387
+ // src/release-run.ts
13388
+ import { readFileSync as readFileSync24, writeFileSync as writeFileSync20, existsSync as existsSync26, copyFileSync, mkdirSync as mkdirSync17, mkdtempSync as mkdtempSync2, rmSync as rmSync14 } from "fs";
13389
+ import { dirname as dirname14, join as join30 } from "path";
13390
+ import { tmpdir as tmpdir3 } from "os";
13391
+
13392
+ // src/release.ts
13393
+ function describeUnplaceable(unplaceable, verifiedStatus) {
13394
+ const stranded = unplaceable.filter((u) => u.ticket.status === verifiedStatus);
13395
+ if (stranded.length === 0) return [];
13396
+ const notes = [];
13397
+ const keysFor = (r) => stranded.filter((u) => u.reason === r).map((u) => u.ticket.issue_id);
13398
+ const noRepo = keysFor("no-repo");
13399
+ if (noRepo.length) {
13400
+ notes.push({
13401
+ level: "warn",
13402
+ message: `${noRepo.length} verified ticket(s) name no repository and cannot be released: ` + noRepo.join(", ")
13403
+ });
13404
+ }
13405
+ const unknown = keysFor("unknown-repo");
13406
+ if (unknown.length) {
13407
+ notes.push({
13408
+ level: "warn",
13409
+ message: `${unknown.length} verified ticket(s) name a repository this ship does not recognise \u2014 run \`crew connect\` to refresh its repo list: ${unknown.join(", ")}`
13410
+ });
13411
+ }
13412
+ const elsewhere = stranded.filter((u) => u.reason === "not-served-here");
13413
+ if (elsewhere.length) {
13414
+ const byRepoName = /* @__PURE__ */ new Map();
13415
+ for (const u of elsewhere) {
13416
+ const name = u.repo ?? "?";
13417
+ byRepoName.set(name, [...byRepoName.get(name) ?? [], u.ticket.issue_id]);
13418
+ }
13419
+ notes.push({
13420
+ level: "info",
13421
+ message: `${elsewhere.length} verified ticket(s) belong to a repository this ship has no checkout for \u2014 another ship releases them: ` + [...byRepoName].map(([n, ids]) => `${n} (${ids.join(", ")})`).join("; ")
13422
+ });
13423
+ }
13424
+ return notes;
13425
+ }
13426
+ var unplaceableReported = /* @__PURE__ */ new Set();
13427
+ function reportUnplaceable(routeName, unplaceable, verified, emit) {
13428
+ if (unplaceableReported.has(routeName)) return;
13429
+ unplaceableReported.add(routeName);
13430
+ for (const note of describeUnplaceable(unplaceable, verified)) {
13431
+ if (note.level === "warn") emit.warn(note.message, { step: "release" });
13432
+ else emit.emit(note.message, { step: "release" });
13433
+ }
13434
+ }
13435
+ function checkGuards(cwd, base = "main") {
13436
+ const branch = currentBranch(cwd);
13437
+ if (branch !== base) {
13438
+ return { kind: "branch", detail: `primary checkout is on '${branch}', not ${base}` };
13439
+ }
13440
+ const changes = status(cwd);
13441
+ if (changes.length === 0) return null;
13442
+ const untracked = changes.filter((c) => c.untracked).length;
13443
+ const names = changes.slice(0, 3).map((c) => c.path).join(", ");
13444
+ const more = changes.length > 3 ? `, +${changes.length - 3} more` : "";
13445
+ const untrackedNote = untracked > 0 ? ` (${untracked} untracked)` : "";
13446
+ return {
13447
+ kind: "dirty",
13448
+ detail: `working tree has ${changes.length} change(s)${untrackedNote}: ${names}${more}`
13449
+ };
13450
+ }
13451
+ function heldByBlockers(tickets, verifiedStatus, info) {
13452
+ if (!info) return [];
13453
+ return tickets.filter((t) => t.status === verifiedStatus && unresolvedBlockers(t, info, { mergeGated: false }).length > 0).map((ticket) => ({ ticket, blockers: describeUnresolved(ticket, info, { mergeGated: false }) }));
13454
+ }
13455
+ var describeHeld = (held) => held.map((h) => `${h.ticket.issue_tag ?? h.ticket.issue_id} held: blocked by ${h.blockers}`);
13456
+ var CHANGELOG_LINE = /^Changelog:[ \t]*(.*)$/gm;
13457
+ var TICKET_REF_AT_START = /^([A-Z][A-Z0-9]*)-(\d+)\b/;
13458
+ function dedupeChangelogEntries(entries, ticket) {
13459
+ const own = (ticket.issue_tag ?? ticket.issue_id).match(/^([A-Z][A-Z0-9]*)-(\d+)$/);
13460
+ const seen = /* @__PURE__ */ new Set();
13461
+ const out = [];
13462
+ for (const raw of entries) {
13463
+ let line = raw.trim();
13464
+ const ref = line.match(TICKET_REF_AT_START);
13465
+ let key = line;
13466
+ if (ref) {
13467
+ const prefix = ref[1];
13468
+ const num = ref[2];
13469
+ const canonical = own && own[2] === num ? own[1] : prefix;
13470
+ line = `${canonical}-${num}${line.slice(ref[0].length)}`;
13471
+ key = `${canonical}-${num}`;
13472
+ }
13473
+ if (seen.has(key)) continue;
13474
+ seen.add(key);
13475
+ out.push(line);
13476
+ }
13477
+ return out;
13478
+ }
13479
+ var BUMP_LINE = /^Bump:[ \t]*(major|minor|patch)\b/gim;
13480
+ function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => branchForIssue(cwd, t.issue_id), base = "main", blockerInfo, ref = "HEAD") {
13481
+ const held = new Set(heldByBlockers(tickets, contract.statuses.verified, blockerInfo).map((h) => h.ticket.id));
13482
+ const verified = tickets.filter((t) => t.status === contract.statuses.verified && !held.has(t.id)).sort((a, b) => Number.parseInt(a.issue_id.replace(/^\D+/, ""), 10) - Number.parseInt(b.issue_id.replace(/^\D+/, ""), 10));
13483
+ return verified.map((ticket) => {
13484
+ const branch = branchFor(ticket);
13485
+ if (!branch) {
13486
+ const keys = referenceKeys(ticket);
13487
+ const unreleased = lastReleased ? findKeyInRange(cwd, keys, `${lastReleased}..${ref}`) : null;
13488
+ if (unreleased) {
13489
+ return {
13490
+ ticket,
13491
+ branch: null,
13492
+ entries: [],
13493
+ usedFallback: false,
13494
+ bump: "patch",
13495
+ majorRequested: false,
13496
+ skipReason: "already-merged"
13497
+ };
13498
+ }
13499
+ const anywhere = findKeyInRange(cwd, keys, ref);
13500
+ if (anywhere) {
13501
+ return {
13502
+ ticket,
13503
+ branch: null,
13504
+ entries: [],
13505
+ usedFallback: false,
13506
+ bump: "patch",
13507
+ majorRequested: false,
13508
+ skipReason: lastReleased ? "already-released" : "already-merged",
13509
+ mergedSha: anywhere
13510
+ };
13511
+ }
13512
+ return {
13513
+ ticket,
13514
+ branch: null,
13515
+ entries: [],
13516
+ usedFallback: false,
13517
+ bump: "patch",
13518
+ majorRequested: false,
13519
+ skipReason: "never-built"
13520
+ };
13521
+ }
13522
+ const squash = findSquashOfBranch(cwd, referenceKeys(ticket), branch, base, base);
13523
+ if (squash) {
13524
+ const released = !!lastReleased && isAncestor(cwd, squash, lastReleased);
13525
+ return {
13526
+ ticket,
13527
+ branch,
13528
+ entries: [],
13529
+ usedFallback: false,
13530
+ bump: "patch",
13531
+ majorRequested: false,
13532
+ skipReason: released ? "already-released" : "already-merged",
13533
+ mergedSha: squash
13534
+ };
13535
+ }
13536
+ const bodies = commitBodies(cwd, `${base}..${branch}`);
13537
+ const entries = dedupeChangelogEntries(
13538
+ [...bodies.matchAll(CHANGELOG_LINE)].map((m) => m[1].trim()).filter(Boolean),
13539
+ ticket
13540
+ );
13541
+ const usedFallback = entries.length === 0;
13542
+ const { size, majorRequested } = requestedBump(bodies);
13543
+ return {
13544
+ ticket,
13545
+ branch,
13546
+ entries: usedFallback ? [`${ticket.title || ticket.issue_id} (${ticket.issue_id})`] : entries,
13547
+ usedFallback,
13548
+ bump: size,
13549
+ majorRequested
13550
+ };
13551
+ });
13552
+ }
13553
+ function planReleasedBranches(cwd, tickets, contract, branchFor, base = "main", tagPattern = "v*") {
13554
+ const eligible = /* @__PURE__ */ new Set([contract.statuses.building, contract.statuses.needsHuman]);
13555
+ const out = [];
13556
+ for (const ticket of tickets) {
13557
+ if (!eligible.has(ticket.status)) continue;
13558
+ const branch = branchFor(ticket);
13559
+ if (!branch) continue;
13560
+ const sha = findSquashByBranchTip(cwd, referenceKeys(ticket), branch, base);
13561
+ if (!sha) continue;
13562
+ const tag = firstReleaseTagContaining(cwd, sha, tagPattern);
13563
+ if (tag) out.push({ ticket, sha, tag });
13564
+ }
13565
+ return out;
13566
+ }
13567
+ function requestedBump(bodies) {
13568
+ const asks = [...bodies.matchAll(BUMP_LINE)].map((m) => m[1].toLowerCase());
13569
+ const majorRequested = asks.includes("major");
13570
+ const size = asks.includes("minor") || majorRequested ? "minor" : "patch";
13571
+ return { size, majorRequested };
12906
13572
  }
12907
13573
  function renderChangelogSection(version, date, entries) {
12908
13574
  const body = entries.map((e) => `- ${e}`).join("\n");
@@ -13461,13 +14127,13 @@ async function bump(o, size) {
13461
14127
  }
13462
14128
  return printed;
13463
14129
  }
13464
- const files = o.repo.release.versionFiles.map((f) => join29(o.cwd, f));
14130
+ const files = o.repo.release.versionFiles.map((f) => join30(o.cwd, f));
13465
14131
  const first = files[0];
13466
- if (!existsSync25(first)) {
14132
+ if (!existsSync26(first)) {
13467
14133
  o.emit.error(`no ${o.repo.release.versionFiles[0]} to read a version from`);
13468
14134
  return null;
13469
14135
  }
13470
- const current = JSON.parse(readFileSync23(first, "utf8")).version;
14136
+ const current = JSON.parse(readFileSync24(first, "utf8")).version;
13471
14137
  if (!current) {
13472
14138
  o.emit.error(`no version field in ${o.repo.release.versionFiles[0]}`);
13473
14139
  return null;
@@ -13480,27 +14146,27 @@ async function bump(o, size) {
13480
14146
  return version;
13481
14147
  }
13482
14148
  for (const f of files) {
13483
- const src = readFileSync23(f, "utf8");
14149
+ const src = readFileSync24(f, "utf8");
13484
14150
  const out = src.replace(/("version"\s*:\s*")[^"]*(")/, `$1${version}$2`);
13485
14151
  if (out === src) {
13486
14152
  o.emit.error(`no version field in ${f}`);
13487
14153
  return null;
13488
14154
  }
13489
- writeFileSync19(f, out);
14155
+ writeFileSync20(f, out);
13490
14156
  }
13491
14157
  return version;
13492
14158
  }
13493
14159
  function writeChangelog(o, version, entries) {
13494
14160
  const file = o.repo.release.changelog;
13495
14161
  if (!file || entries.length === 0) return;
13496
- const path = join29(o.cwd, file);
14162
+ const path = join30(o.cwd, file);
13497
14163
  const section = renderChangelogSection(version, (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), entries);
13498
14164
  if (o.dryRun) {
13499
14165
  o.emit.emit(`would prepend ${entries.length} entr(ies) to ${file}`);
13500
14166
  return;
13501
14167
  }
13502
- const existing = existsSync25(path) ? readFileSync23(path, "utf8") : "# Changelog\n";
13503
- writeFileSync19(path, insertChangelogSection(existing, section));
14168
+ const existing = existsSync26(path) ? readFileSync24(path, "utf8") : "# Changelog\n";
14169
+ writeFileSync20(path, insertChangelogSection(existing, section));
13504
14170
  }
13505
14171
  async function confirm(o, expected) {
13506
14172
  if (!o.repo.hooks.released) return void 0;
@@ -13593,8 +14259,8 @@ function closeReleaseCheckout(cwd, root, dir, branch, run2 = gitOk) {
13593
14259
  function openReleaseCheckout(o) {
13594
14260
  const { remote, base } = o.repo.branch;
13595
14261
  const branch = `crew/release-${Date.now()}`;
13596
- const root = mkdtempSync2(join29(tmpdir3(), "crew-release-"));
13597
- const dir = join29(root, "checkout");
14262
+ const root = mkdtempSync2(join30(tmpdir3(), "crew-release-"));
14263
+ const dir = join30(root, "checkout");
13598
14264
  o.emit.emit(`release checkout: ${dir}`, { step: "release" });
13599
14265
  const close = () => closeReleaseCheckout(o.cwd, root, dir, branch);
13600
14266
  try {
@@ -13604,10 +14270,10 @@ function openReleaseCheckout(o) {
13604
14270
  throw e;
13605
14271
  }
13606
14272
  for (const f of o.repo.worktrees.copy) {
13607
- const src = join29(o.cwd, f);
13608
- if (!existsSync25(src)) continue;
13609
- mkdirSync17(dirname13(join29(dir, f)), { recursive: true });
13610
- copyFileSync(src, join29(dir, f));
14273
+ const src = join30(o.cwd, f);
14274
+ if (!existsSync26(src)) continue;
14275
+ mkdirSync17(dirname14(join30(dir, f)), { recursive: true });
14276
+ copyFileSync(src, join30(dir, f));
13611
14277
  }
13612
14278
  return { dir, branch, close };
13613
14279
  }
@@ -14308,12 +14974,12 @@ Passed: ${passed}.${willClose ? " Closing this alert." : ""}`,
14308
14974
  }
14309
14975
 
14310
14976
  // src/live-runs.ts
14311
- import { existsSync as existsSync27, readdirSync as readdirSync11 } from "fs";
14312
- import { join as join31 } from "path";
14977
+ import { existsSync as existsSync28, readdirSync as readdirSync11 } from "fs";
14978
+ import { join as join32 } from "path";
14313
14979
 
14314
14980
  // src/interrupted.ts
14315
- import { existsSync as existsSync26, readdirSync as readdirSync10, readFileSync as readFileSync24 } from "fs";
14316
- import { join as join30 } from "path";
14981
+ import { existsSync as existsSync27, readdirSync as readdirSync10, readFileSync as readFileSync25 } from "fs";
14982
+ import { join as join31 } from "path";
14317
14983
  var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
14318
14984
  function interruptedComment(b) {
14319
14985
  const tree = b.dirty === null ? "The worktree could not be checked." : b.dirty ? "The worktree has uncommitted changes \u2014 they are this seat's own, from that run." : "The worktree is clean.";
@@ -14332,21 +14998,21 @@ var pidAlive = (pid) => {
14332
14998
  };
14333
14999
  function runFinished(streamPath) {
14334
15000
  const path = `${streamPath}.meta.json`;
14335
- if (!existsSync26(path)) return false;
15001
+ if (!existsSync27(path)) return false;
14336
15002
  try {
14337
- return JSON.parse(readFileSync24(path, "utf8")).running !== true;
15003
+ return JSON.parse(readFileSync25(path, "utf8")).running !== true;
14338
15004
  } catch {
14339
15005
  return true;
14340
15006
  }
14341
15007
  }
14342
15008
  function orphanedStreams(o) {
14343
- const dir = join30(o.stateDir, "streams");
14344
- if (!existsSync26(dir)) return [];
15009
+ const dir = join31(o.stateDir, "streams");
15010
+ if (!existsSync27(dir)) return [];
14345
15011
  const ownerAlive = o.isOwnerAlive ?? pidAlive;
14346
15012
  const out = [];
14347
15013
  for (const name of readdirSync10(dir)) {
14348
15014
  if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
14349
- const streamPath = join30(dir, name);
15015
+ const streamPath = join31(dir, name);
14350
15016
  if (runFinished(streamPath)) continue;
14351
15017
  const sidecar = readRunSidecar(streamPath);
14352
15018
  if (!sidecar || sidecar.route !== o.routeLabel) continue;
@@ -14400,12 +15066,12 @@ async function reconcileRuns(o) {
14400
15066
 
14401
15067
  // src/live-runs.ts
14402
15068
  function liveRuns(stateDir, isAlive = runIsAlive) {
14403
- const dir = join31(stateDir, "streams");
14404
- if (!existsSync27(dir)) return [];
15069
+ const dir = join32(stateDir, "streams");
15070
+ if (!existsSync28(dir)) return [];
14405
15071
  const out = [];
14406
15072
  for (const name of readdirSync11(dir)) {
14407
15073
  if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
14408
- const streamPath = join31(dir, name);
15074
+ const streamPath = join32(dir, name);
14409
15075
  if (runFinished(streamPath)) continue;
14410
15076
  const sidecar = readRunSidecar(streamPath);
14411
15077
  if (!sidecar || !isAlive(sidecar)) continue;
@@ -14506,7 +15172,7 @@ function formatProjectsReport(report) {
14506
15172
  }
14507
15173
 
14508
15174
  // src/forge-cmd.ts
14509
- import { parseDocument } from "yaml";
15175
+ import { parseDocument as parseDocument4 } from "yaml";
14510
15176
  function runCredential(action, stdin, forges, stateDir, env = process.env) {
14511
15177
  try {
14512
15178
  return { code: 0, out: answerCredential(action ?? "", parseCredentialInput(stdin), forges, stateDir, env), err: "" };
@@ -14547,7 +15213,7 @@ function runForge(a, io) {
14547
15213
  if (!existing) {
14548
15214
  const tok = sourceFromArgs(a);
14549
15215
  if (typeof tok === "string") return fail(tok);
14550
- const doc = parseDocument(io.readFile(io.configFile));
15216
+ const doc = parseDocument4(io.readFile(io.configFile));
14551
15217
  doc.setIn(["ship", "forges", host, "token"], tok);
14552
15218
  io.writeFile(io.configFile, doc.toString());
14553
15219
  io.forges = { ...io.forges ?? {}, [host]: { username: "x-access-token", token: tok } };
@@ -14740,7 +15406,7 @@ function missingReleaserWarning(repo, release, ships, now = Date.now()) {
14740
15406
  }
14741
15407
 
14742
15408
  // src/watch.ts
14743
- import { watch as watchFile, existsSync as existsSync28, statSync as statSync9, openSync as openSync4, readSync as readSync2, closeSync as closeSync4 } from "fs";
15409
+ import { watch as watchFile, existsSync as existsSync29, statSync as statSync9, openSync as openSync4, readSync as readSync2, closeSync as closeSync4 } from "fs";
14744
15410
  var E = "\x1B[";
14745
15411
  var dim = (s) => `${E}2m${s}${E}0m`;
14746
15412
  var bold = (s) => `${E}1m${s}${E}0m`;
@@ -14841,7 +15507,7 @@ function renderHeaders(states, now = Date.now()) {
14841
15507
  return states.map((s) => renderHeader(s, now)).join("");
14842
15508
  }
14843
15509
  function readFrom(file, from) {
14844
- if (!existsSync28(file)) return { events: [], offset: from };
15510
+ if (!existsSync29(file)) return { events: [], offset: from };
14845
15511
  const size = statSync9(file).size;
14846
15512
  if (size < from) return readFrom(file, 0);
14847
15513
  if (size === from) return { events: [], offset: from };
@@ -15084,17 +15750,17 @@ function nextRoles(decision, ran) {
15084
15750
  }
15085
15751
 
15086
15752
  // src/ship-name.ts
15087
- import { existsSync as existsSync30, mkdirSync as mkdirSync19, readFileSync as readFileSync26, renameSync as renameSync3, writeFileSync as writeFileSync21 } from "fs";
15088
- import { dirname as dirname15 } from "path";
15089
- import { parseDocument as parseDocument2 } from "yaml";
15753
+ import { existsSync as existsSync31, mkdirSync as mkdirSync19, readFileSync as readFileSync27, renameSync as renameSync3, writeFileSync as writeFileSync22 } from "fs";
15754
+ import { dirname as dirname16 } from "path";
15755
+ import { parseDocument as parseDocument5 } from "yaml";
15090
15756
 
15091
15757
  // src/machine-id.ts
15092
15758
  import { execFileSync as execFileSync5 } from "child_process";
15093
15759
  import { createHash as createHash5 } from "crypto";
15094
- import { existsSync as existsSync29, mkdirSync as mkdirSync18, readFileSync as readFileSync25, writeFileSync as writeFileSync20 } from "fs";
15095
- import { dirname as dirname14, join as join32 } from "path";
15760
+ import { existsSync as existsSync30, mkdirSync as mkdirSync18, readFileSync as readFileSync26, writeFileSync as writeFileSync21 } from "fs";
15761
+ import { dirname as dirname15, join as join33 } from "path";
15096
15762
  function machineIdPathFor(stateDir) {
15097
- return join32(stateDir, "machine-id");
15763
+ return join33(stateDir, "machine-id");
15098
15764
  }
15099
15765
  var sha256 = (s) => createHash5("sha256").update(s).digest("hex");
15100
15766
  function readRawMachineId(platform2 = process.platform) {
@@ -15107,8 +15773,8 @@ function readRawMachineId(platform2 = process.platform) {
15107
15773
  return /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out)?.[1];
15108
15774
  }
15109
15775
  for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
15110
- if (existsSync29(p)) {
15111
- const id = readFileSync25(p, "utf8").trim();
15776
+ if (existsSync30(p)) {
15777
+ const id = readFileSync26(p, "utf8").trim();
15112
15778
  if (id) return id;
15113
15779
  }
15114
15780
  }
@@ -15119,15 +15785,15 @@ function readRawMachineId(platform2 = process.platform) {
15119
15785
  function ensureMachineId(stateDir, opts = {}) {
15120
15786
  if (opts.override?.trim()) return sha256(opts.override.trim());
15121
15787
  const path = machineIdPathFor(stateDir);
15122
- if (existsSync29(path)) {
15123
- const cached = readFileSync25(path, "utf8").trim();
15788
+ if (existsSync30(path)) {
15789
+ const cached = readFileSync26(path, "utf8").trim();
15124
15790
  if (/^[0-9a-f]{64}$/.test(cached)) return cached;
15125
15791
  }
15126
15792
  const raw = (opts.read ?? readRawMachineId)();
15127
15793
  if (!raw) return void 0;
15128
15794
  const hashed = sha256(raw);
15129
- mkdirSync18(dirname14(path), { recursive: true });
15130
- writeFileSync20(path, `${hashed}
15795
+ mkdirSync18(dirname15(path), { recursive: true });
15796
+ writeFileSync21(path, `${hashed}
15131
15797
  `, { mode: 384 });
15132
15798
  return hashed;
15133
15799
  }
@@ -15156,11 +15822,11 @@ function planNameSync(local, row, lastSynced) {
15156
15822
  return { drift: "local", action: "push" };
15157
15823
  }
15158
15824
  function shipNameOfYaml(text) {
15159
- const v = parseDocument2(text).getIn(["ship", "name"]);
15825
+ const v = parseDocument5(text).getIn(["ship", "name"]);
15160
15826
  return typeof v === "string" && v.trim() ? v.trim() : void 0;
15161
15827
  }
15162
15828
  function setShipNameInYaml(text, name) {
15163
- const doc = parseDocument2(text);
15829
+ const doc = parseDocument5(text);
15164
15830
  doc.setIn(["ship", "name"], name);
15165
15831
  return doc.toString();
15166
15832
  }
@@ -15198,34 +15864,34 @@ async function syncRouteName(i) {
15198
15864
  return { status: "ok", ...base, action, name: i.local };
15199
15865
  }
15200
15866
  function readSyncedName(resolvedFile) {
15201
- if (!existsSync30(resolvedFile)) return void 0;
15867
+ if (!existsSync31(resolvedFile)) return void 0;
15202
15868
  try {
15203
- const v = JSON.parse(readFileSync26(resolvedFile, "utf8")).shipName;
15869
+ const v = JSON.parse(readFileSync27(resolvedFile, "utf8")).shipName;
15204
15870
  return typeof v === "string" ? v : void 0;
15205
15871
  } catch {
15206
15872
  return void 0;
15207
15873
  }
15208
15874
  }
15209
15875
  function writeSyncedName(resolvedFile, name) {
15210
- if (!existsSync30(resolvedFile)) return;
15876
+ if (!existsSync31(resolvedFile)) return;
15211
15877
  let raw;
15212
15878
  try {
15213
- raw = JSON.parse(readFileSync26(resolvedFile, "utf8"));
15879
+ raw = JSON.parse(readFileSync27(resolvedFile, "utf8"));
15214
15880
  } catch {
15215
15881
  return;
15216
15882
  }
15217
15883
  if (raw.shipName === name) return;
15218
15884
  raw.shipName = name;
15219
- mkdirSync19(dirname15(resolvedFile), { recursive: true });
15885
+ mkdirSync19(dirname16(resolvedFile), { recursive: true });
15220
15886
  const tmp = `${resolvedFile}.tmp${process.pid}`;
15221
- writeFileSync21(tmp, `${JSON.stringify(raw, null, 2)}
15887
+ writeFileSync22(tmp, `${JSON.stringify(raw, null, 2)}
15222
15888
  `);
15223
15889
  renameSync3(tmp, resolvedFile);
15224
15890
  }
15225
15891
  async function reconcileShipName(i) {
15226
15892
  let local = i.ship.name;
15227
15893
  try {
15228
- local = shipNameOfYaml(readFileSync26(i.configFile, "utf8")) ?? local;
15894
+ local = shipNameOfYaml(readFileSync27(i.configFile, "utf8")) ?? local;
15229
15895
  } catch {
15230
15896
  }
15231
15897
  const resolvedFile = resolvedPathFor(i.stateDir, i.route);
@@ -15249,8 +15915,8 @@ async function reconcileShipName(i) {
15249
15915
  }
15250
15916
  if (result.status !== "ok") return result;
15251
15917
  if (result.action === "adopt" || result.action === "row-wins") {
15252
- const text = readFileSync26(i.configFile, "utf8");
15253
- writeFileSync21(i.configFile, setShipNameInYaml(text, result.name));
15918
+ const text = readFileSync27(i.configFile, "utf8");
15919
+ writeFileSync22(i.configFile, setShipNameInYaml(text, result.name));
15254
15920
  i.ship.name = result.name;
15255
15921
  if (result.action === "row-wins") {
15256
15922
  i.emit?.warn(
@@ -15902,8 +16568,8 @@ function describeCapability(i) {
15902
16568
  }
15903
16569
 
15904
16570
  // src/agents-sync-run.ts
15905
- import { existsSync as existsSync31, mkdirSync as mkdirSync20, readFileSync as readFileSync27, writeFileSync as writeFileSync22 } from "fs";
15906
- import { dirname as dirname16 } from "path";
16571
+ import { existsSync as existsSync32, mkdirSync as mkdirSync20, readFileSync as readFileSync28, writeFileSync as writeFileSync23 } from "fs";
16572
+ import { dirname as dirname17 } from "path";
15907
16573
  async function runAgentsSync(target, o) {
15908
16574
  const dryRun = o.dryRun ?? false;
15909
16575
  const force = o.force ?? false;
@@ -15914,519 +16580,232 @@ async function runAgentsSync(target, o) {
15914
16580
  try {
15915
16581
  personaResult = await runPersonas(readOnly);
15916
16582
  } catch (e) {
15917
- if (readOnly || !isForbidden(e)) throw e;
15918
- readOnly = true;
15919
- personaResult = await runPersonas(true);
15920
- }
15921
- o.say(`route: ${target.route}`);
15922
- for (const p of personaResult.outcomes) o.say(describeSyncOutcome(p));
15923
- for (const l of personaResult.crewLinks) o.say(describeCrewLink(l));
15924
- if (personaResult.releaseNote) o.say(personaResult.releaseNote);
15925
- const briefs = Object.fromEntries(ROLE_NAMES.map((r) => [r, personaDefaultPrompt(target.promptsDir, r)]));
15926
- for (const w of skillMarkerWarnings(target.promptsDir, briefs)) o.warn(`warning: ${w}`);
15927
- let skillResult;
15928
- if (!readOnly) {
15929
- try {
15930
- skillResult = await syncSkills(target, { userAgent: o.userAgent, dryRun, force });
15931
- } catch (e) {
15932
- if (isForbidden(e)) readOnly = true;
15933
- else throw e;
15934
- }
15935
- }
15936
- for (const s of skillResult?.outcomes ?? []) o.say(describeSkillSyncOutcome(s));
15937
- if (!dryRun) {
15938
- const resolvedPath = resolvedPathFor(o.stateDir, target.route);
15939
- const raw = existsSync31(resolvedPath) ? JSON.parse(readFileSync27(resolvedPath, "utf8")) : {};
15940
- raw.agentPersonas = personaResult.agentPersonas;
15941
- if (skillResult) raw.agentSkills = skillResult.agentSkills;
15942
- if (personaResult.releaseAgentId) raw.releaseAgentId = personaResult.releaseAgentId;
15943
- mkdirSync20(dirname16(resolvedPath), { recursive: true });
15944
- writeFileSync22(resolvedPath, `${JSON.stringify(raw, null, 2)}
15945
- `);
15946
- if (target.resolved) {
15947
- target.resolved.agentPersonas = personaResult.agentPersonas;
15948
- if (personaResult.releaseAgentId) target.resolved.releaseAgentId = personaResult.releaseAgentId;
15949
- }
15950
- }
15951
- if (readOnly) {
15952
- o.say("personas and skills: not synced (this key is not a workspace admin; the workspace's rows are used as they are)");
15953
- }
15954
- const diverged = personaResult.outcomes.some((p) => p.action === "diverged") || (skillResult?.outcomes.some((s) => s.action === "diverged") ?? false);
15955
- return { personaResult, skillResult, readOnly, diverged };
15956
- }
15957
-
15958
- // src/logbook.ts
15959
- import { createHash as createHash6 } from "crypto";
15960
- import { TablationClient as TablationClient4 } from "@tablation/client";
15961
- var LogbookError = class extends Error {
15962
- };
15963
- function client(route2, userAgent) {
15964
- return new TablationClient4({
15965
- baseUrl: `${route2.baseUrl}/api`,
15966
- apiKey: resolveApiKey(route2),
15967
- headers: { "User-Agent": userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
15968
- });
15969
- }
15970
- function requireWorkspace(route2) {
15971
- if (!route2.resolved) {
15972
- throw new LogbookError(`route "${route2.route}" has no resolved ids \u2014 run \`crew connect\` first`);
15973
- }
15974
- }
15975
- async function listLogEntries(route2, opts = {}) {
15976
- requireWorkspace(route2);
15977
- const c = client(route2, opts.userAgent);
15978
- const agentLogModel = await c.dataModels.get("agent_log", route2.resolved.workspaceId);
15979
- const filters = [];
15980
- if (opts.ticket) filters.push({ columnName: "ticket_reference", operator: "EQ", value: opts.ticket });
15981
- if (opts.role) {
15982
- const agentsModel = await c.dataModels.get("agents", route2.resolved.workspaceId);
15983
- const rows = await c.records.list(agentsModel.id, { limit: 200 });
15984
- const match = rows.find((r) => r.name.toLowerCase() === opts.role.toLowerCase());
15985
- if (!match) return [];
15986
- filters.push({ columnName: "agent_id", operator: "EQ", value: match.id });
15987
- }
15988
- const entries = await c.records.list(agentLogModel.id, {
15989
- limit: opts.limit ?? 20,
15990
- ...filters.length > 0 ? { filters: JSON.stringify(filters) } : {}
15991
- });
15992
- return [...entries].sort((a, b) => String(b.started_at ?? "").localeCompare(String(a.started_at ?? "")));
15993
- }
15994
- async function showLogEntry(route2, entryId, opts = {}) {
15995
- requireWorkspace(route2);
15996
- const c = client(route2, opts.userAgent);
15997
- const agentLogModel = await c.dataModels.get("agent_log", route2.resolved.workspaceId);
15998
- let entry;
15999
- try {
16000
- entry = await c.records.get(agentLogModel.id, entryId);
16001
- } catch (e) {
16002
- throw new LogbookError(`no Agent Log entry ${entryId} in this workspace (${e.message})`);
16003
- }
16004
- if (!opts.withPrompt) return { entry, reconstruction: { status: "not_recorded", reason: "not requested" } };
16005
- if (!entry.agent_id || !entry.prompt_version) {
16006
- return {
16007
- entry,
16008
- reconstruction: {
16009
- status: "not_recorded",
16010
- reason: !entry.agent_id ? "this entry has no linked Agents row (predates crew agents sync being run for its role)" : "this entry predates ISSUE-377/529 \u2014 no prompt_version was recorded"
16011
- }
16012
- };
16013
- }
16014
- const agentsModel = await c.dataModels.get("agents", route2.resolved.workspaceId);
16015
- let historyEntries;
16016
- try {
16017
- historyEntries = await fetchHistoryEntries(route2, agentsModel.id, entry.agent_id, opts.userAgent, 200);
16018
- } catch (e) {
16019
- return { entry, reconstruction: { status: "approximate", reason: `history lookup failed: ${e.message}` } };
16020
- }
16021
- const match = historyEntries.find((h) => h.history_id === entry.prompt_version);
16022
- if (!match) {
16023
- return {
16024
- entry,
16025
- reconstruction: {
16026
- status: "approximate",
16027
- reason: `history entry ${entry.prompt_version} no longer exists on the Agents row (purged, or the row itself was recreated)`
16028
- }
16029
- };
16030
- }
16031
- const prompt = appendAdditions(match.prompt ?? "", match.prompt_additions);
16032
- const actualSha = createHash6("sha256").update(prompt).digest("hex");
16033
- const exact = !!entry.prompt_sha && actualSha === entry.prompt_sha;
16034
- return {
16035
- entry,
16036
- reconstruction: {
16037
- status: exact ? "exact" : "approximate",
16038
- historyId: match.history_id,
16039
- changedAt: match.changed_at,
16040
- prompt,
16041
- ...exact ? {} : { reason: entry.prompt_sha ? "stored prompt_sha does not match this history entry's content" : "this entry recorded no prompt_sha to verify against" }
16042
- }
16043
- };
16044
- }
16045
-
16046
- // src/repos-check.ts
16047
- import { spawnSync as spawnSync6 } from "child_process";
16048
- import { readFileSync as readFileSync28 } from "fs";
16049
- import { basename as basename8 } from "path";
16050
- var POSIX_SHELL = /^(?:ba|z|da|k|a|mk)?sh$|^fish$/;
16051
- function checkHookSyntax(script, shell) {
16052
- const spec = resolveShell(shell);
16053
- const name = basename8(spec.bin).replace(/\.exe$/i, "").toLowerCase();
16054
- let run2;
16055
- if (POSIX_SHELL.test(name)) {
16056
- run2 = spawnSync6(spec.bin, ["-n"], { input: script, encoding: "utf8" });
16057
- } else if (/^(pwsh|powershell)$/.test(name)) {
16058
- const parse3 = "$e=$null;[void][System.Management.Automation.Language.Parser]::ParseInput($env:CREW_CHECK_SCRIPT,[ref]$null,[ref]$e);if($e){$e|ForEach-Object{[Console]::Error.WriteLine($_.Message)};exit 1}";
16059
- run2 = spawnSync6(spec.bin, ["-NoProfile", "-NonInteractive", "-Command", parse3], {
16060
- encoding: "utf8",
16061
- env: { ...process.env, CREW_CHECK_SCRIPT: script }
16062
- });
16063
- } else {
16064
- return { status: "unchecked", message: `no syntax check for ${spec.bin}` };
16065
- }
16066
- if (run2.error) return { status: "unchecked", message: `cannot run ${spec.bin}: ${run2.error.message}` };
16067
- if (run2.status === 0) return { status: "ok" };
16068
- const message = `${run2.stderr ?? ""}${run2.stdout ?? ""}`.trim() || `${spec.bin} exited ${run2.status}`;
16069
- return { status: "error", message };
16070
- }
16071
- function renderSamples(cfg, dir) {
16072
- const checkout = basename8(dir.replace(/[/\\]+$/, ""));
16073
- const tagged = { key: "ISSUE-123", title: "Fix the widget", role: "dev", tag: "TABL-123", prefix: "tabl", lowercase: cfg.branch.lowercase };
16074
- const untagged = { key: "ISSUE-123", title: "Fix the widget", role: "dev", lowercase: cfg.branch.lowercase };
16075
- return [["with a project tag", tagged], ["without a project tag", untagged]].map(([label, ctx]) => {
16076
- const branch = renderBranchName(effectiveBranchTemplate(cfg, ctx.prefix), ctx);
16077
- const worktree = effectiveWorktreeDirName(cfg, dir, branch, "123", (t) => renderWorktreeName(t, { ...ctx, dir: checkout }));
16078
- return { case: label, branch, worktree };
16079
- });
16080
- }
16081
- function checkRepoSpec(dir, opts = {}) {
16082
- const empty = { parsed: null, effective: null, readiness: [], hooks: [], shadowed: [], samples: [] };
16083
- const file = findRepoConfig(dir);
16084
- if (!file) {
16085
- return { ...empty, file: null, error: `no .crew.yaml, .crew.yml or .crew.json in ${dir}`, exitCode: 1 };
16086
- }
16087
- let parsed;
16088
- try {
16089
- parsed = parseRepoConfig(readFileSync28(file, "utf8"), file);
16090
- } catch (e) {
16091
- if (!(e instanceof RepoConfigError)) throw e;
16092
- return { ...empty, file, error: e.message, exitCode: 1 };
16093
- }
16094
- const effective = resolveRepoConfig(parsed, opts.ship, dir);
16095
- const readiness = validateEffective(effective);
16096
- const hooks = Object.keys(effective.hooks).map((name) => ({
16097
- name,
16098
- label: hookLabel(effective, name),
16099
- syntax: checkHookSyntax(effective.hooks[name], effective.shell)
16100
- }));
16101
- const failed = hooks.some((h) => h.syntax.status === "error");
16102
- return {
16103
- file,
16104
- parsed,
16105
- effective,
16106
- readiness,
16107
- hooks,
16108
- shadowed: effective.shadowed,
16109
- samples: renderSamples(effective, dir),
16110
- error: null,
16111
- exitCode: failed || opts.strict && readiness.length > 0 ? 1 : 0
16112
- };
16113
- }
16114
- function renderRepoCheck(r) {
16115
- if (r.error) return `${r.file ?? "crew repos check"}: ${r.error}
16116
- `;
16117
- const e = r.effective;
16118
- const lines = [
16119
- `${r.file}`,
16120
- ` platform ${e.platform}`,
16121
- ` release.mode ${e.release.mode}`,
16122
- ` release.versioning ${e.release.versioning}`,
16123
- ` branch.base ${e.branch.base}`,
16124
- ` branch.name ${e.branch.name}`,
16125
- ` branch.push ${e.branch.push}`
16126
- ];
16127
- for (const s of r.samples) lines.push(` ${s.case}: branch ${s.branch}, worktree ../${s.worktree}`);
16128
- lines.push(r.hooks.length ? " hooks" : " hooks none");
16129
- for (const h of r.hooks) {
16130
- const named = h.label !== e.hooks[h.name] ? ` (${h.label})` : "";
16131
- const syntax = h.syntax.status === "ok" ? "syntax ok" : h.syntax.status === "unchecked" ? `syntax not checked: ${h.syntax.message}` : "syntax ERROR";
16132
- lines.push(` ${h.name}${named}: ${syntax}`);
16133
- if (h.syntax.status === "error") lines.push(...(h.syntax.message ?? "").split("\n").map((l) => ` ${l}`));
16134
- }
16135
- if (r.shadowed.length) lines.push(` this ship declares ${r.shadowed.join(", ")}, and the file overrides it \u2014 the file wins`);
16136
- for (const p of r.readiness) lines.push(` - release readiness: ${p}`);
16137
- return `${lines.join("\n")}
16138
- `;
16139
- }
16140
-
16141
- // src/repos-hooks.ts
16142
- import { isMap as isMap2, parseDocument as parseDocument4, YAMLMap } from "yaml";
16143
-
16144
- // src/repos-cmd.ts
16145
- import { homedir as homedir9 } from "os";
16146
- import { basename as basename9, join as join33, resolve as resolve3 } from "path";
16147
- import { isMap, isSeq, parseDocument as parseDocument3, stringify as stringify2 } from "yaml";
16148
- var ReposError = class extends Error {
16149
- };
16150
- function normalizeRemote(url) {
16151
- if (!url) return void 0;
16152
- let s = url.trim();
16153
- if (!s) return void 0;
16154
- const scp = /^[^\s@/]+@[^\s:/]+:(.+)$/.exec(s);
16155
- if (scp) s = scp[1];
16156
- else s = s.replace(/^[a-z][a-z0-9+.-]*:\/\/(?:[^/@]+@)?[^/]+\//i, "");
16157
- s = s.replace(/\/+$/, "").replace(/\.git$/i, "");
16158
- const parts = s.split("/").filter(Boolean);
16159
- if (parts.length < 2) return void 0;
16160
- return parts.slice(-2).join("/").toLowerCase();
16161
- }
16162
- function matchRepoName(origin, resolved) {
16163
- const want = normalizeRemote(origin);
16164
- if (!want || !resolved?.repoRemotes) return void 0;
16165
- for (const [id, remote] of Object.entries(resolved.repoRemotes)) {
16166
- if (normalizeRemote(remote) === want) return resolved.repoNames?.[id];
16167
- }
16168
- return void 0;
16169
- }
16170
- function routeNamesOf(text) {
16171
- const routes = parseDocument3(text).get("routes", true);
16172
- if (!isSeq(routes)) return [];
16173
- return routes.items.flatMap((i) => isMap(i) && typeof i.get("route") === "string" ? [i.get("route")] : []);
16174
- }
16175
- function parseAddArgs(args, routes, isDir) {
16176
- const rest = [...args];
16177
- let route2;
16178
- if (rest.length > 0 && routes.includes(rest[0])) route2 = rest.shift();
16179
- else if (rest.length > 0 && /^[^/\s]+\/[^/\s]+$/.test(rest[0]) && !isDir(rest[0])) {
16180
- throw new ReposError(`no route "${rest[0]}" in the config (have: ${routes.join(", ") || "none"})`);
16583
+ if (readOnly || !isForbidden(e)) throw e;
16584
+ readOnly = true;
16585
+ personaResult = await runPersonas(true);
16181
16586
  }
16182
- if (!route2) {
16183
- if (routes.length === 1) route2 = routes[0];
16184
- else throw new ReposError(routes.length === 0 ? "the config has no routes \u2014 run `crew connect` first" : `several routes are configured; name one: ${routes.join(", ")}`);
16587
+ o.say(`route: ${target.route}`);
16588
+ for (const p of personaResult.outcomes) o.say(describeSyncOutcome(p));
16589
+ for (const l of personaResult.crewLinks) o.say(describeCrewLink(l));
16590
+ if (personaResult.releaseNote) o.say(personaResult.releaseNote);
16591
+ const briefs = Object.fromEntries(ROLE_NAMES.map((r) => [r, personaDefaultPrompt(target.promptsDir, r)]));
16592
+ for (const w of skillMarkerWarnings(target.promptsDir, briefs)) o.warn(`warning: ${w}`);
16593
+ let skillResult;
16594
+ if (!readOnly) {
16595
+ try {
16596
+ skillResult = await syncSkills(target, { userAgent: o.userAgent, dryRun, force });
16597
+ } catch (e) {
16598
+ if (isForbidden(e)) readOnly = true;
16599
+ else throw e;
16600
+ }
16185
16601
  }
16186
- if (rest.length > 2) throw new ReposError("too many arguments: expected [route] [name] [path]");
16187
- if (rest.length === 2) return { route: route2, name: rest[0], path: rest[1] };
16188
- if (rest.length === 1) return isDir(rest[0]) ? { route: route2, path: rest[0] } : { route: route2, name: rest[0], path: "." };
16189
- return { route: route2, path: "." };
16190
- }
16191
- function expandDir(p, base) {
16192
- const home = p === "~" ? homedir9() : p.startsWith("~/") ? `${homedir9()}/${p.slice(2)}` : p;
16193
- return resolve3(base, home);
16194
- }
16195
- function routeMapOf(doc, route2) {
16196
- const routes = doc.get("routes", true);
16197
- if (isSeq(routes)) {
16198
- for (const item of routes.items) {
16199
- if (isMap(item) && item.get("route") === route2) return item;
16602
+ for (const s of skillResult?.outcomes ?? []) o.say(describeSkillSyncOutcome(s));
16603
+ if (!dryRun) {
16604
+ const resolvedPath = resolvedPathFor(o.stateDir, target.route);
16605
+ const raw = existsSync32(resolvedPath) ? JSON.parse(readFileSync28(resolvedPath, "utf8")) : {};
16606
+ raw.agentPersonas = personaResult.agentPersonas;
16607
+ if (skillResult) raw.agentSkills = skillResult.agentSkills;
16608
+ if (personaResult.releaseAgentId) raw.releaseAgentId = personaResult.releaseAgentId;
16609
+ mkdirSync20(dirname17(resolvedPath), { recursive: true });
16610
+ writeFileSync23(resolvedPath, `${JSON.stringify(raw, null, 2)}
16611
+ `);
16612
+ if (target.resolved) {
16613
+ target.resolved.agentPersonas = personaResult.agentPersonas;
16614
+ if (personaResult.releaseAgentId) target.resolved.releaseAgentId = personaResult.releaseAgentId;
16200
16615
  }
16201
16616
  }
16202
- const have = isSeq(routes) ? routes.items.map((i) => isMap(i) ? String(i.get("route")) : "?").join(", ") : "none";
16203
- throw new ReposError(`no route "${route2}" in the config (have: ${have})`);
16204
- }
16205
- function configuredRepos(text, route2, base) {
16206
- const doc = parseDocument3(text);
16207
- const m = routeMapOf(doc, route2);
16208
- const repos = m.get("repos");
16209
- if (isMap(repos)) {
16210
- return repos.items.map((p) => {
16211
- const name = String(p.key.value ?? p.key);
16212
- const v = p.value;
16213
- const dir2 = isMap(v) ? String(v.get("dir") ?? "") : String(v?.value ?? "");
16214
- return { name, dir: expandDir(dir2, base) };
16215
- });
16617
+ if (readOnly) {
16618
+ o.say("personas and skills: not synced (this key is not a workspace admin; the workspace's rows are used as they are)");
16216
16619
  }
16217
- const dir = m.get("dir");
16218
- return dir ? [{ name: basename9(String(dir)), dir: expandDir(String(dir), base) }] : [];
16620
+ const diverged = personaResult.outcomes.some((p) => p.action === "diverged") || (skillResult?.outcomes.some((s) => s.action === "diverged") ?? false);
16621
+ return { personaResult, skillResult, readOnly, diverged };
16219
16622
  }
16220
- function cloneBaseOf(text, route2, base, isCheckout) {
16221
- const m = routeMapOf(parseDocument3(text), route2);
16222
- const repos = m.get("repos", true);
16223
- if (isMap(repos) && repos.items.length > 0) return null;
16224
- const dir = m.get("dir");
16225
- if (!dir) return null;
16226
- const abs = expandDir(String(dir), base);
16227
- return isCheckout(abs) ? null : abs;
16623
+
16624
+ // src/logbook.ts
16625
+ import { createHash as createHash6 } from "crypto";
16626
+ import { TablationClient as TablationClient4 } from "@tablation/client";
16627
+ var LogbookError = class extends Error {
16628
+ };
16629
+ function client(route2, userAgent) {
16630
+ return new TablationClient4({
16631
+ baseUrl: `${route2.baseUrl}/api`,
16632
+ apiKey: resolveApiKey(route2),
16633
+ headers: { "User-Agent": userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
16634
+ });
16228
16635
  }
16229
- function routeSetsHooks(text, route2, name) {
16230
- const m = routeMapOf(parseDocument3(text), route2);
16231
- const repos = m.get("repos", true);
16232
- let target = m;
16233
- if (isMap(repos)) target = repos.get(name, true);
16234
- if (!isMap(target)) return false;
16235
- const hooks = target.get("hooks", true);
16236
- return isMap(hooks) && hooks.items.length > 0;
16636
+ function requireWorkspace(route2) {
16637
+ if (!route2.resolved) {
16638
+ throw new LogbookError(`route "${route2.route}" has no resolved ids \u2014 run \`crew connect\` first`);
16639
+ }
16237
16640
  }
16238
- function listRepoRows(i) {
16239
- const cloneBase = i.isCheckout ? cloneBaseOf(i.text, i.route, i.base, i.isCheckout) : null;
16240
- const checkouts = configuredRepos(i.text, i.route, i.base).filter((c) => c.dir !== cloneBase);
16241
- const claimed = /* @__PURE__ */ new Set();
16242
- const hooksSourceOf = (c) => i.exists(c.dir) && i.repoHasHooks(c.dir) ? "repo" : routeSetsHooks(i.text, i.route, c.name) ? "route" : "none";
16243
- const originFor = (c, remote) => {
16244
- const o = i.originOf(c.dir) ?? null;
16245
- if (!o) return null;
16246
- return remote !== null && normalizeRemote(o) === normalizeRemote(remote) ? null : o;
16247
- };
16248
- const rows = [];
16249
- const names = i.resolved?.repoNames ?? {};
16250
- const remotes = i.resolved?.repoRemotes ?? {};
16251
- for (const [id, name] of Object.entries(names)) {
16252
- const remote = remotes[id] ?? null;
16253
- const want = normalizeRemote(remote);
16254
- let idx = checkouts.findIndex((c2, n) => !claimed.has(n) && want !== void 0 && normalizeRemote(i.originOf(c2.dir)) === want);
16255
- if (idx < 0) idx = checkouts.findIndex((c2, n) => !claimed.has(n) && c2.name === name);
16256
- const c = idx >= 0 ? checkouts[idx] : void 0;
16257
- if (idx >= 0) claimed.add(idx);
16258
- rows.push({
16259
- name,
16260
- remote,
16261
- repoRowId: id,
16262
- attached: c !== void 0,
16263
- dir: c?.dir ?? null,
16264
- exists: c ? i.exists(c.dir) : false,
16265
- origin: c ? originFor(c, remote) : null,
16266
- matchedRow: c ? name : null,
16267
- hooksSource: c ? hooksSourceOf(c) : "none"
16268
- });
16641
+ async function listLogEntries(route2, opts = {}) {
16642
+ requireWorkspace(route2);
16643
+ const c = client(route2, opts.userAgent);
16644
+ const agentLogModel = await c.dataModels.get("agent_log", route2.resolved.workspaceId);
16645
+ const filters = [];
16646
+ if (opts.ticket) filters.push({ columnName: "ticket_reference", operator: "EQ", value: opts.ticket });
16647
+ if (opts.role) {
16648
+ const agentsModel = await c.dataModels.get("agents", route2.resolved.workspaceId);
16649
+ const rows = await c.records.list(agentsModel.id, { limit: 200 });
16650
+ const match = rows.find((r) => r.name.toLowerCase() === opts.role.toLowerCase());
16651
+ if (!match) return [];
16652
+ filters.push({ columnName: "agent_id", operator: "EQ", value: match.id });
16269
16653
  }
16270
- checkouts.forEach((c, n) => {
16271
- if (claimed.has(n)) return;
16272
- rows.push({
16273
- name: c.name,
16274
- remote: null,
16275
- repoRowId: null,
16276
- attached: true,
16277
- dir: c.dir,
16278
- exists: i.exists(c.dir),
16279
- origin: originFor(c, null),
16280
- matchedRow: null,
16281
- hooksSource: hooksSourceOf(c)
16282
- });
16654
+ const entries = await c.records.list(agentLogModel.id, {
16655
+ limit: opts.limit ?? 20,
16656
+ ...filters.length > 0 ? { filters: JSON.stringify(filters) } : {}
16283
16657
  });
16284
- return rows;
16658
+ return [...entries].sort((a, b) => String(b.started_at ?? "").localeCompare(String(a.started_at ?? "")));
16285
16659
  }
16286
- function planRepoAdd(i) {
16287
- const doc = parseDocument3(i.text);
16288
- if (doc.errors.length > 0) throw new ReposError(`config is not valid YAML \u2014 ${doc.errors[0].message}`);
16289
- const m = routeMapOf(doc, i.route);
16290
- let existing = configuredRepos(i.text, i.route, i.base);
16291
- const target = expandDir(i.path, i.base);
16292
- let droppedDir;
16293
- const placeholder = m.get("dir");
16294
- const reposNow = m.get("repos", true);
16295
- if (i.isCheckout && placeholder && !(isMap(reposNow) && reposNow.items.length > 0)) {
16296
- const placeholderDir = expandDir(String(placeholder), i.base);
16297
- if (placeholderDir !== target && !i.isCheckout(placeholderDir)) {
16298
- droppedDir = placeholderDir;
16299
- m.delete("dir");
16300
- existing = [];
16301
- }
16302
- }
16303
- if (existing.some((e) => e.dir === target)) {
16304
- throw new ReposError(`${target} is already configured on route ${i.route}`);
16305
- }
16306
- const matched = matchRepoName(i.origin, i.resolved);
16307
- const name = matched ?? i.name ?? normalizeRemote(i.origin)?.split("/").pop() ?? basename9(target);
16308
- let warning;
16309
- if (!matched) {
16310
- const known = Object.values(i.resolved?.repoNames ?? {});
16311
- warning = i.origin ? `the tracker has no Repos row for remote ${normalizeRemote(i.origin) ?? i.origin}${known.length ? ` (known: ${known.join(", ")})` : ""} \u2014 added anyway; create the row so ticket routing can find it` : "this checkout has no origin remote, so it cannot be matched to a Repos row \u2014 added anyway";
16660
+ async function showLogEntry(route2, entryId, opts = {}) {
16661
+ requireWorkspace(route2);
16662
+ const c = client(route2, opts.userAgent);
16663
+ const agentLogModel = await c.dataModels.get("agent_log", route2.resolved.workspaceId);
16664
+ let entry;
16665
+ try {
16666
+ entry = await c.records.get(agentLogModel.id, entryId);
16667
+ } catch (e) {
16668
+ throw new LogbookError(`no Agent Log entry ${entryId} in this workspace (${e.message})`);
16312
16669
  }
16313
- const repos = m.get("repos", true);
16314
- let mode;
16315
- if (isMap(repos)) {
16316
- if (repos.has(name)) throw new ReposError(`route ${i.route} already has a repo named "${name}" (${String(repos.get(name))})`);
16317
- repos.set(name, target);
16318
- mode = "repos";
16319
- } else if (m.has("dir") && existing.length > 0) {
16320
- const old = existing[0];
16321
- const oldName = i.nameForExisting?.(old.dir) ?? old.name;
16322
- if (oldName === name) throw new ReposError(`route ${i.route} already has a repo named "${name}" (${old.dir})`);
16323
- const oldDir = String(m.get("dir"));
16324
- const pair = m.items.find((p) => p.key.value === "dir");
16325
- pair.key = doc.createNode("repos");
16326
- pair.value = doc.createNode({ [oldName]: oldDir, [name]: target });
16327
- mode = "repos";
16328
- } else if (droppedDir) {
16329
- m.set("repos", doc.createNode({ [name]: target }));
16330
- mode = "repos";
16331
- } else {
16332
- m.set("dir", target);
16333
- mode = "dir";
16670
+ if (!opts.withPrompt) return { entry, reconstruction: { status: "not_recorded", reason: "not requested" } };
16671
+ if (!entry.agent_id || !entry.prompt_version) {
16672
+ return {
16673
+ entry,
16674
+ reconstruction: {
16675
+ status: "not_recorded",
16676
+ reason: !entry.agent_id ? "this entry has no linked Agents row (predates crew agents sync being run for its role)" : "this entry predates ISSUE-377/529 \u2014 no prompt_version was recorded"
16677
+ }
16678
+ };
16334
16679
  }
16335
- return { text: String(doc), mode, name, block: renderBlock(m), warning, unmatched: !matched, droppedDir };
16336
- }
16337
- function planRepoRemove(text, base, route2, path) {
16338
- const doc = parseDocument3(text);
16339
- if (doc.errors.length > 0) throw new ReposError(`config is not valid YAML \u2014 ${doc.errors[0].message}`);
16340
- const m = routeMapOf(doc, route2);
16341
- const target = expandDir(path, base);
16342
- const existing = configuredRepos(text, route2, base);
16343
- const hit = existing.find((e) => e.dir === target);
16344
- if (!hit) throw new ReposError(`${target} is not configured on route ${route2}`);
16345
- const repos = m.get("repos", true);
16346
- if (isMap(repos)) {
16347
- repos.delete(hit.name);
16348
- const left = existing.filter((e) => e !== hit);
16349
- if (left.length === 1) {
16350
- const pair = m.items.find((p) => p.key.value === "repos");
16351
- pair.key = doc.createNode("dir");
16352
- pair.value = doc.createNode(String(repos.get(left[0].name)));
16353
- } else if (left.length === 0) m.delete("repos");
16354
- } else {
16355
- m.delete("dir");
16680
+ const agentsModel = await c.dataModels.get("agents", route2.resolved.workspaceId);
16681
+ let historyEntries;
16682
+ try {
16683
+ historyEntries = await fetchHistoryEntries(route2, agentsModel.id, entry.agent_id, opts.userAgent, 200);
16684
+ } catch (e) {
16685
+ return { entry, reconstruction: { status: "approximate", reason: `history lookup failed: ${e.message}` } };
16356
16686
  }
16357
- return { text: String(doc), name: hit.name };
16358
- }
16359
- function renderBlock(m) {
16360
- return stringify2([m.toJSON()]).trimEnd();
16361
- }
16362
- var LOCAL_RELEASE_MODES = /* @__PURE__ */ new Set(["integrate", "external"]);
16363
- function planRepoRow(i) {
16364
- const remote = normalizeRemote(i.origin);
16365
- if (!remote) throw new ReposError(`cannot derive an owner/repo from origin "${i.origin}"`);
16366
- const mode = i.spec?.releaseMode;
16687
+ const match = historyEntries.find((h) => h.history_id === entry.prompt_version);
16688
+ if (!match) {
16689
+ return {
16690
+ entry,
16691
+ reconstruction: {
16692
+ status: "approximate",
16693
+ reason: `history entry ${entry.prompt_version} no longer exists on the Agents row (purged, or the row itself was recreated)`
16694
+ }
16695
+ };
16696
+ }
16697
+ const prompt = appendAdditions(match.prompt ?? "", match.prompt_additions);
16698
+ const actualSha = createHash6("sha256").update(prompt).digest("hex");
16699
+ const exact = !!entry.prompt_sha && actualSha === entry.prompt_sha;
16367
16700
  return {
16368
- name: i.name,
16369
- remote,
16370
- platform: i.spec?.platform ?? "unix",
16371
- release_mode: mode ? LOCAL_RELEASE_MODES.has(mode) ? "local" : mode : "local",
16372
- ci_provider: i.spec?.ciProvider ?? "none",
16373
- enabled: true,
16374
- project_id: i.projectId
16701
+ entry,
16702
+ reconstruction: {
16703
+ status: exact ? "exact" : "approximate",
16704
+ historyId: match.history_id,
16705
+ changedAt: match.changed_at,
16706
+ prompt,
16707
+ ...exact ? {} : { reason: entry.prompt_sha ? "stored prompt_sha does not match this history entry's content" : "this entry recorded no prompt_sha to verify against" }
16708
+ }
16375
16709
  };
16376
16710
  }
16377
- function resolveRepoProject(projects, opt, routeAreaId) {
16378
- if (opt) {
16379
- const want = opt.toLowerCase();
16380
- const hit = projects.find((p) => p.id === opt || p.name?.toLowerCase() === want);
16381
- if (!hit) throw new ReposError(`no Projects row "${opt}" (have: ${projects.map((p) => p.name ?? p.id).join(", ") || "none"})`);
16382
- return hit.id;
16711
+
16712
+ // src/repos-check.ts
16713
+ import { spawnSync as spawnSync7 } from "child_process";
16714
+ import { readFileSync as readFileSync29 } from "fs";
16715
+ import { basename as basename9 } from "path";
16716
+ var POSIX_SHELL = /^(?:ba|z|da|k|a|mk)?sh$|^fish$/;
16717
+ function checkHookSyntax(script, shell) {
16718
+ const spec = resolveShell(shell);
16719
+ const name = basename9(spec.bin).replace(/\.exe$/i, "").toLowerCase();
16720
+ let run2;
16721
+ if (POSIX_SHELL.test(name)) {
16722
+ run2 = spawnSync7(spec.bin, ["-n"], { input: script, encoding: "utf8" });
16723
+ } else if (/^(pwsh|powershell)$/.test(name)) {
16724
+ const parse3 = "$e=$null;[void][System.Management.Automation.Language.Parser]::ParseInput($env:CREW_CHECK_SCRIPT,[ref]$null,[ref]$e);if($e){$e|ForEach-Object{[Console]::Error.WriteLine($_.Message)};exit 1}";
16725
+ run2 = spawnSync7(spec.bin, ["-NoProfile", "-NonInteractive", "-Command", parse3], {
16726
+ encoding: "utf8",
16727
+ env: { ...process.env, CREW_CHECK_SCRIPT: script }
16728
+ });
16729
+ } else {
16730
+ return { status: "unchecked", message: `no syntax check for ${spec.bin}` };
16383
16731
  }
16384
- if (routeAreaId) return routeAreaId;
16385
- if (projects.length === 1) return projects[0].id;
16386
- throw new ReposError(
16387
- `cannot tell which project the new Repos row belongs to \u2014 pass --project <name> (have: ${projects.map((p) => p.name ?? p.id).join(", ") || "none"}), or --no-create to skip creating it`
16388
- );
16732
+ if (run2.error) return { status: "unchecked", message: `cannot run ${spec.bin}: ${run2.error.message}` };
16733
+ if (run2.status === 0) return { status: "ok" };
16734
+ const message = `${run2.stderr ?? ""}${run2.stdout ?? ""}`.trim() || `${spec.bin} exited ${run2.status}`;
16735
+ return { status: "error", message };
16389
16736
  }
16390
- function parseCloneArgs(args, routes) {
16391
- const rest = [...args];
16392
- let route2;
16393
- if (rest.length > 1 && routes.includes(rest[0])) route2 = rest.shift();
16394
- else if (rest.length > 1) throw new ReposError(`no route "${rest[0]}" in the config (have: ${routes.join(", ") || "none"})`);
16395
- if (!route2) {
16396
- if (routes.length === 1) route2 = routes[0];
16397
- else throw new ReposError(routes.length === 0 ? "the config has no routes \u2014 run `crew connect` first" : `several routes are configured; name one: ${routes.join(", ")}`);
16398
- }
16399
- if (rest.length !== 1) throw new ReposError("expected [route] <name> \u2014 the Repos row to clone (see `crew repos list`)");
16400
- return { route: route2, name: rest[0] };
16737
+ function renderSamples(cfg, dir) {
16738
+ const checkout = basename9(dir.replace(/[/\\]+$/, ""));
16739
+ const tagged = { key: "ISSUE-123", title: "Fix the widget", role: "dev", tag: "TABL-123", prefix: "tabl", lowercase: cfg.branch.lowercase };
16740
+ const untagged = { key: "ISSUE-123", title: "Fix the widget", role: "dev", lowercase: cfg.branch.lowercase };
16741
+ return [["with a project tag", tagged], ["without a project tag", untagged]].map(([label, ctx]) => {
16742
+ const branch = renderBranchName(effectiveBranchTemplate(cfg, ctx.prefix), ctx);
16743
+ const worktree = effectiveWorktreeDirName(cfg, dir, branch, "123", (t) => renderWorktreeName(t, { ...ctx, dir: checkout }));
16744
+ return { case: label, branch, worktree };
16745
+ });
16401
16746
  }
16402
- function planRepoClone(i) {
16403
- const names = i.resolved?.repoNames ?? {};
16404
- const id = Object.keys(names).find((k) => names[k].toLowerCase() === i.name.toLowerCase());
16405
- if (!id) {
16406
- const known = Object.values(names);
16407
- throw new ReposError(`the tracker has no Repos row named "${i.name}" for route ${i.route}${known.length ? ` (known: ${known.join(", ")})` : " \u2014 run `crew connect` to refresh them"}`);
16747
+ function checkRepoSpec(dir, opts = {}) {
16748
+ const empty = { parsed: null, effective: null, readiness: [], hooks: [], shadowed: [], samples: [] };
16749
+ const file = findRepoConfig(dir);
16750
+ if (!file) {
16751
+ return { ...empty, file: null, error: `no .crew.yaml, .crew.yml or .crew.json in ${dir}`, exitCode: 1 };
16408
16752
  }
16409
- const name = names[id];
16410
- const remote = normalizeRemote(i.resolved?.repoRemotes?.[id]);
16411
- if (!remote) {
16412
- throw new ReposError(
16413
- `the Repos row "${name}" has no remote to clone from \u2014 attach an existing checkout with \`crew repos add ${i.route} ${name} <path>\`, or set the row's remote in the tracker and re-run \`crew connect\``
16414
- );
16753
+ let parsed;
16754
+ try {
16755
+ parsed = parseRepoConfig(readFileSync29(file, "utf8"), file);
16756
+ } catch (e) {
16757
+ if (!(e instanceof RepoConfigError)) throw e;
16758
+ return { ...empty, file, error: e.message, exitCode: 1 };
16415
16759
  }
16416
- const doc = parseDocument3(i.text);
16417
- const m = routeMapOf(doc, i.route);
16418
- const existing = configuredRepos(i.text, i.route, i.base);
16419
- if (existing.some((e) => e.name.toLowerCase() === name.toLowerCase())) {
16420
- throw new ReposError(`route ${i.route} already has a checkout named "${name}" \u2014 nothing to clone`);
16760
+ const effective = resolveRepoConfig(parsed, opts.ship, dir);
16761
+ const readiness = validateEffective(effective);
16762
+ const hooks = Object.keys(effective.hooks).map((name) => ({
16763
+ name,
16764
+ label: hookLabel(effective, name),
16765
+ syntax: checkHookSyntax(effective.hooks[name], effective.shell)
16766
+ }));
16767
+ const failed = hooks.some((h) => h.syntax.status === "error");
16768
+ return {
16769
+ file,
16770
+ parsed,
16771
+ effective,
16772
+ readiness,
16773
+ hooks,
16774
+ shadowed: effective.shadowed,
16775
+ samples: renderSamples(effective, dir),
16776
+ error: null,
16777
+ exitCode: failed || opts.strict && readiness.length > 0 ? 1 : 0
16778
+ };
16779
+ }
16780
+ function renderRepoCheck(r) {
16781
+ if (r.error) return `${r.file ?? "crew repos check"}: ${r.error}
16782
+ `;
16783
+ const e = r.effective;
16784
+ const lines = [
16785
+ `${r.file}`,
16786
+ ` platform ${e.platform}`,
16787
+ ` release.mode ${e.release.mode}`,
16788
+ ` release.versioning ${e.release.versioning}`,
16789
+ ` branch.base ${e.branch.base}`,
16790
+ ` branch.name ${e.branch.name}`,
16791
+ ` branch.push ${e.branch.push}`
16792
+ ];
16793
+ for (const s of r.samples) lines.push(` ${s.case}: branch ${s.branch}, worktree ../${s.worktree}`);
16794
+ lines.push(r.hooks.length ? " hooks" : " hooks none");
16795
+ for (const h of r.hooks) {
16796
+ const named = h.label !== e.hooks[h.name] ? ` (${h.label})` : "";
16797
+ const syntax = h.syntax.status === "ok" ? "syntax ok" : h.syntax.status === "unchecked" ? `syntax not checked: ${h.syntax.message}` : "syntax ERROR";
16798
+ lines.push(` ${h.name}${named}: ${syntax}`);
16799
+ if (h.syntax.status === "error") lines.push(...(h.syntax.message ?? "").split("\n").map((l) => ` ${l}`));
16421
16800
  }
16422
- const ship = doc.get("ship", true);
16423
- const basePath = String(m.get("reposBasePath") ?? (isMap(ship) ? ship.get("reposBasePath") : void 0) ?? "~/Crew");
16424
- const target = i.dir ? expandDir(i.dir, i.base) : join33(expandDir(basePath, i.base), i.route.split("/")[0], name);
16425
- const add = planRepoAdd({ text: i.text, base: i.base, route: i.route, path: target, origin: remote, resolved: i.resolved, name, isCheckout: i.isCheckout });
16426
- return { ...add, remote, target };
16801
+ if (r.shadowed.length) lines.push(` this ship declares ${r.shadowed.join(", ")}, and the file overrides it \u2014 the file wins`);
16802
+ for (const p of r.readiness) lines.push(` - release readiness: ${p}`);
16803
+ return `${lines.join("\n")}
16804
+ `;
16427
16805
  }
16428
16806
 
16429
16807
  // src/repos-hooks.ts
16808
+ import { isMap as isMap2, parseDocument as parseDocument6, YAMLMap } from "yaml";
16430
16809
  function packageManager(f) {
16431
16810
  if (f.exists("pnpm-lock.yaml")) return "pnpm";
16432
16811
  if (f.exists("yarn.lock")) return "yarn";
@@ -16567,7 +16946,7 @@ function repoDirOf(text, base, route2, name) {
16567
16946
  return hit.dir;
16568
16947
  }
16569
16948
  function planRouteHooks(text, route2, name, hooks, opts = {}) {
16570
- const doc = parseDocument4(text);
16949
+ const doc = parseDocument6(text);
16571
16950
  if (doc.errors.length > 0) throw new ReposError(`config is not valid YAML \u2014 ${doc.errors[0].message}`);
16572
16951
  const m = routeMapOf(doc, route2);
16573
16952
  const repos = m.get("repos", true);
@@ -16604,7 +16983,7 @@ function planRouteHooks(text, route2, name, hooks, opts = {}) {
16604
16983
  return { text: String(doc), written, kept, replaced };
16605
16984
  }
16606
16985
  function clearRouteHooks(text, route2, name) {
16607
- const doc = parseDocument4(text);
16986
+ const doc = parseDocument6(text);
16608
16987
  if (doc.errors.length > 0) throw new ReposError(`config is not valid YAML \u2014 ${doc.errors[0].message}`);
16609
16988
  const m = routeMapOf(doc, route2);
16610
16989
  const repos = m.get("repos", true);
@@ -16887,56 +17266,8 @@ function planSampleTicket(i) {
16887
17266
  };
16888
17267
  }
16889
17268
 
16890
- // src/doctor-fix.ts
16891
- import { parseDocument as parseDocument5 } from "yaml";
16892
- var sound = (r) => r.hasRepos && r.problems.length === 0;
16893
- function planFix(f) {
16894
- const actions = [];
16895
- const notes = [];
16896
- for (const r of f.routes) {
16897
- if (!r.enabled && sound(r)) {
16898
- actions.push({ kind: "enable", route: r.route });
16899
- const gaps = r.readiness ?? [];
16900
- if (gaps.length) {
16901
- notes.push(`${r.route}: ${gaps.length} release-readiness gap${gaps.length === 1 ? "" : "s"} (${gaps[0]}${gaps.length > 1 ? ", ..." : ""}) \u2014 the route will run, but releases stop at the first missing hook until it is added`);
16902
- }
16903
- } else if (!sound(r) && !r.enabled) {
16904
- notes.push(`${r.route}: not enabled \u2014 ${r.hasRepos ? r.problems[0] : "no checkout is configured"}`);
16905
- }
16906
- }
16907
- if (f.schedulerAbsent && f.routes.some(sound)) {
16908
- if (f.host === "windows") notes.push("scheduler support is not built for Windows yet \u2014 nothing to install; run `crew run` by hand");
16909
- else actions.push({ kind: "install" });
16910
- }
16911
- return { actions, notes };
16912
- }
16913
- async function gatherHealth(c, check) {
16914
- let problems;
16915
- let readiness = [];
16916
- try {
16917
- const r = await check();
16918
- if (Array.isArray(r)) problems = r;
16919
- else ({ problems, readiness } = r);
16920
- } catch (e) {
16921
- problems = [`checks could not run: ${e instanceof Error ? e.message : String(e)}`];
16922
- }
16923
- return { route: c.route, enabled: c.enabled, hasRepos: c.hasRepos, problems, readiness };
16924
- }
16925
- function routesInScope(routes, named) {
16926
- return named ? routes.filter((r) => r.route === named) : routes;
16927
- }
16928
- function planEnable(text, route2) {
16929
- const doc = parseDocument5(text);
16930
- routeMapOf(doc, route2).set("enabled", true);
16931
- return String(doc);
16932
- }
16933
- function schedulerInstalled(plan, exists, crontab) {
16934
- if (plan.mechanism === "cron") return plan.cronMarker !== null && crontab.includes(plan.cronMarker);
16935
- return plan.unitPaths.length > 0 && plan.unitPaths.every(exists);
16936
- }
16937
-
16938
17269
  // src/worktree-sweep.ts
16939
- import { existsSync as existsSync32, readFileSync as readFileSync29, readdirSync as readdirSync12 } from "fs";
17270
+ import { existsSync as existsSync33, readFileSync as readFileSync30, readdirSync as readdirSync12 } from "fs";
16940
17271
  import { basename as basename11, dirname as dirname19, join as join35, resolve as resolve4 } from "path";
16941
17272
  function planWorktreeSweep(target, tickets, contract) {
16942
17273
  const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
@@ -17049,7 +17380,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin",
17049
17380
  let removed = 0;
17050
17381
  let keptBranches = 0;
17051
17382
  for (const a of actions) {
17052
- if (!dryRun && base && !a.keepBranch && existsSync32(a.path) && isHandBackMerge(a.path, base)) {
17383
+ if (!dryRun && base && !a.keepBranch && existsSync33(a.path) && isHandBackMerge(a.path, base)) {
17053
17384
  if (gitOk(a.path, ["merge", "--abort"]) !== null) {
17054
17385
  log.emit(
17055
17386
  `abandoned the hand-back merge of ${base} in ${a.path}: ${a.ticket.issue_id} deployed without it`,
@@ -17057,7 +17388,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin",
17057
17388
  );
17058
17389
  }
17059
17390
  }
17060
- if (!dryRun && existsSync32(a.path)) {
17391
+ if (!dryRun && existsSync33(a.path)) {
17061
17392
  let dirty = false;
17062
17393
  try {
17063
17394
  dirty = status(a.path).length > 0;
@@ -17068,7 +17399,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin",
17068
17399
  continue;
17069
17400
  }
17070
17401
  }
17071
- if (!dryRun && a.requirePushed && a.branch && existsSync32(a.path) && remoteConfigured(cwd, remote) && worktreeHoldsUnpushed(a.path, remote)) {
17402
+ if (!dryRun && a.requirePushed && a.branch && existsSync33(a.path) && remoteConfigured(cwd, remote) && worktreeHoldsUnpushed(a.path, remote)) {
17072
17403
  refuse(a, "unpushed commits", `${a.branch} has commits that are not on ${remote}`);
17073
17404
  continue;
17074
17405
  }
@@ -17117,7 +17448,7 @@ function isHandBackMerge(path, base) {
17117
17448
  if (!msgPath) return false;
17118
17449
  let first;
17119
17450
  try {
17120
- first = readFileSync29(resolve4(path, msgPath), "utf8").split("\n")[0] ?? "";
17451
+ first = readFileSync30(resolve4(path, msgPath), "utf8").split("\n")[0] ?? "";
17121
17452
  } catch {
17122
17453
  return false;
17123
17454
  }
@@ -17222,12 +17553,12 @@ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
17222
17553
  }
17223
17554
 
17224
17555
  // src/stream-sweep.ts
17225
- import { existsSync as existsSync33, readdirSync as readdirSync13, rmSync as rmSync15, statSync as statSync10 } from "fs";
17556
+ import { existsSync as existsSync34, readdirSync as readdirSync13, rmSync as rmSync15, statSync as statSync10 } from "fs";
17226
17557
  import { join as join36 } from "path";
17227
17558
  var DAY_MS2 = 24 * 60 * 60 * 1e3;
17228
17559
  function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
17229
17560
  const dir = join36(stateDir, "streams");
17230
- if (!existsSync33(dir)) return [];
17561
+ if (!existsSync34(dir)) return [];
17231
17562
  const cutoff = now - retentionDays * DAY_MS2;
17232
17563
  return readdirSync13(dir).map((name) => join36(dir, name)).filter((path) => {
17233
17564
  try {
@@ -17255,7 +17586,7 @@ function applyStreamSweep(paths, dryRun, log) {
17255
17586
  return removed;
17256
17587
  }
17257
17588
  function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
17258
- if (!existsSync33(stateDir)) return [];
17589
+ if (!existsSync34(stateDir)) return [];
17259
17590
  const cutoff = now - retentionDays * DAY_MS2;
17260
17591
  return readdirSync13(stateDir).filter((name) => ROTATED_EVENTS_RE.test(name)).map((name) => join36(stateDir, name)).filter((path) => {
17261
17592
  try {
@@ -17267,11 +17598,11 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
17267
17598
  }
17268
17599
 
17269
17600
  // src/cli.ts
17270
- import { realpathSync as realpathSync3, readFileSync as readFileSync35, existsSync as existsSync40, mkdirSync as mkdirSync26, writeFileSync as writeFileSync27, rmSync as rmSync19, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync4, statSync as statSync14, readdirSync as readdirSync18 } from "fs";
17601
+ import { realpathSync as realpathSync3, readFileSync as readFileSync36, existsSync as existsSync41, mkdirSync as mkdirSync26, writeFileSync as writeFileSync28, rmSync as rmSync19, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync4, statSync as statSync14, readdirSync as readdirSync18 } from "fs";
17271
17602
  import { createInterface } from "readline/promises";
17272
17603
 
17273
17604
  // src/periscope-cmd.ts
17274
- import { existsSync as existsSync34, mkdirSync as mkdirSync21, readFileSync as readFileSync30, rmSync as rmSync16, writeFileSync as writeFileSync23 } from "fs";
17605
+ import { existsSync as existsSync35, mkdirSync as mkdirSync21, readFileSync as readFileSync31, rmSync as rmSync16, writeFileSync as writeFileSync24 } from "fs";
17275
17606
  import { join as join37 } from "path";
17276
17607
  var PeriscopeError = class extends Error {
17277
17608
  };
@@ -17283,14 +17614,14 @@ function removePeriscopePrior(stateDir, ticketRecordId) {
17283
17614
  function writePeriscopePrior(stateDir, ticketRecordId, prior) {
17284
17615
  try {
17285
17616
  mkdirSync21(priorDir(stateDir), { recursive: true });
17286
- writeFileSync23(priorPath(stateDir, ticketRecordId), JSON.stringify(prior));
17617
+ writeFileSync24(priorPath(stateDir, ticketRecordId), JSON.stringify(prior));
17287
17618
  } catch {
17288
17619
  }
17289
17620
  }
17290
17621
  function readPeriscopePrior(stateDir, ticketRecordId) {
17291
17622
  try {
17292
17623
  const p = priorPath(stateDir, ticketRecordId);
17293
- return existsSync34(p) ? JSON.parse(readFileSync30(p, "utf8")) : void 0;
17624
+ return existsSync35(p) ? JSON.parse(readFileSync31(p, "utf8")) : void 0;
17294
17625
  } catch {
17295
17626
  return void 0;
17296
17627
  }
@@ -17780,7 +18111,7 @@ import { dirname as dirOf2, resolve as resolvePath } from "path";
17780
18111
 
17781
18112
  // src/ssh-keys.ts
17782
18113
  import { execFileSync as execFileSync8 } from "child_process";
17783
- import { existsSync as existsSync35, mkdirSync as mkdirSync22, readFileSync as readFileSync31, chmodSync as chmodSync3 } from "fs";
18114
+ import { existsSync as existsSync36, mkdirSync as mkdirSync22, readFileSync as readFileSync32, chmodSync as chmodSync3 } from "fs";
17784
18115
  import { dirname as dirname20, join as join39 } from "path";
17785
18116
  var SshKeyError = class extends Error {
17786
18117
  };
@@ -17790,7 +18121,7 @@ function shipSshKeyPathFor(stateDir) {
17790
18121
  function ensureShipSshKeypair(stateDir) {
17791
18122
  const privateKeyPath = shipSshKeyPathFor(stateDir);
17792
18123
  const publicKeyPath = `${privateKeyPath}.pub`;
17793
- if (!existsSync35(privateKeyPath)) {
18124
+ if (!existsSync36(privateKeyPath)) {
17794
18125
  mkdirSync22(dirname20(privateKeyPath), { recursive: true });
17795
18126
  try {
17796
18127
  execFileSync8("ssh-keygen", [
@@ -17811,10 +18142,10 @@ function ensureShipSshKeypair(stateDir) {
17811
18142
  }
17812
18143
  chmodSync3(privateKeyPath, 384);
17813
18144
  }
17814
- if (!existsSync35(publicKeyPath)) {
18145
+ if (!existsSync36(publicKeyPath)) {
17815
18146
  throw new SshKeyError(`${privateKeyPath} exists but ${publicKeyPath} does not \u2014 regenerate or restore it`);
17816
18147
  }
17817
- const publicKey = readFileSync31(publicKeyPath, "utf8").trim();
18148
+ const publicKey = readFileSync32(publicKeyPath, "utf8").trim();
17818
18149
  return { privateKeyPath, publicKeyPath, publicKey };
17819
18150
  }
17820
18151
  function sshKeygenAvailable() {
@@ -17826,6 +18157,26 @@ function sshKeygenAvailable() {
17826
18157
  }
17827
18158
  }
17828
18159
 
18160
+ // src/relay-probe.ts
18161
+ async function probeRelayControl(relayUrl, apiKey, userAgent, fetchFn = fetch, timeoutMs = 1e4) {
18162
+ const started = Date.now();
18163
+ try {
18164
+ const res = await fetchFn(`${relayUrl.replace(/\/+$/, "")}/control/status`, {
18165
+ headers: { Authorization: `Bearer ${apiKey}`, "User-Agent": userAgent },
18166
+ signal: AbortSignal.timeout(timeoutMs)
18167
+ });
18168
+ const latencyMs = Date.now() - started;
18169
+ await res.arrayBuffer().catch(() => void 0);
18170
+ if (res.ok) return { ok: true, status: res.status, latencyMs };
18171
+ return { ok: false, status: res.status, error: `HTTP ${res.status}` };
18172
+ } catch (e) {
18173
+ return { ok: false, error: e.message };
18174
+ }
18175
+ }
18176
+ function describeRelayProbe(url, p) {
18177
+ return p.ok ? `relay control: OK ${url}/control/status (${p.status}, ${p.latencyMs} ms)` : `relay control: FAILED ${url}/control/status \u2014 ${p.error}`;
18178
+ }
18179
+
17829
18180
  // src/passenger-containers.ts
17830
18181
  import { execFileSync as execFileSync9 } from "child_process";
17831
18182
  import { createHash as createHash7 } from "crypto";
@@ -18031,7 +18382,7 @@ function syncAllPassengerCheckouts(cfg) {
18031
18382
 
18032
18383
  // src/tunnel.ts
18033
18384
  import { spawn as spawn5 } from "child_process";
18034
- import { existsSync as existsSync36, mkdirSync as mkdirSync23, readFileSync as readFileSync32, writeFileSync as writeFileSync24, unlinkSync as unlinkSync3, readdirSync as readdirSync14 } from "fs";
18385
+ import { existsSync as existsSync37, mkdirSync as mkdirSync23, readFileSync as readFileSync33, writeFileSync as writeFileSync25, unlinkSync as unlinkSync3, readdirSync as readdirSync14 } from "fs";
18035
18386
  import { randomInt } from "crypto";
18036
18387
  import { join as join40 } from "path";
18037
18388
  var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
@@ -18043,15 +18394,21 @@ function generateTunnelSlug() {
18043
18394
  for (let i = 0; i < SLUG_LENGTH; i++) out += SLUG_ALPHABET[randomInt(SLUG_ALPHABET.length)];
18044
18395
  return out;
18045
18396
  }
18046
- function bindAddrFor(o) {
18047
- const base = `workspace-${o.workspaceId}-slug-${o.slug}-key-${o.apiKey}`;
18048
- return o.mcpSecret ? `${base}-secret-${o.mcpSecret}` : base;
18397
+ function bindAddrFor(o, path) {
18398
+ const base = `workspace-${o.workspaceId}-slug-${o.slug}${path ? `-path-${path}` : ""}-key-${o.apiKey}`;
18399
+ return o.mcpSecret && !path ? `${base}-secret-${o.mcpSecret}` : base;
18049
18400
  }
18050
- function publicUrlFor(slug, relayHost, relayHttpPort, relayPublicDomain) {
18401
+ function relayBaseUrlFor(slug, relayHost, relayHttpPort, relayPublicDomain) {
18051
18402
  const domain = relayPublicDomain ?? relayHost.replace(/^.*@/, "");
18052
- return `https://${slug}.${domain}${relayHttpPort === 443 ? "" : `:${relayHttpPort}`}/mcp`;
18403
+ return `https://${slug}.${domain}${relayHttpPort === 443 ? "" : `:${relayHttpPort}`}`;
18404
+ }
18405
+ function publicUrlFor(slug, relayHost, relayHttpPort, relayPublicDomain) {
18406
+ return `${relayBaseUrlFor(slug, relayHost, relayHttpPort, relayPublicDomain)}/mcp`;
18053
18407
  }
18054
18408
  function sshArgsFor(o) {
18409
+ const forwards = [];
18410
+ if (o.localPort !== void 0) forwards.push("-R", `${bindAddrFor(o)}:0:127.0.0.1:${o.localPort}`);
18411
+ if (o.controlSocket) forwards.push("-R", `${bindAddrFor(o, "control")}:0:${o.controlSocket}`);
18055
18412
  return [
18056
18413
  "-N",
18057
18414
  "-o",
@@ -18066,8 +18423,7 @@ function sshArgsFor(o) {
18066
18423
  o.privateKeyPath,
18067
18424
  "-p",
18068
18425
  String(o.relayPort),
18069
- "-R",
18070
- `${bindAddrFor(o)}:0:127.0.0.1:${o.localPort}`,
18426
+ ...forwards,
18071
18427
  o.relayHost
18072
18428
  ];
18073
18429
  }
@@ -18104,9 +18460,9 @@ function tunnelStatePath(stateDir, workspaceId) {
18104
18460
  }
18105
18461
  function readPersistedTunnel(stateDir, workspaceId) {
18106
18462
  const path = tunnelStatePath(stateDir, workspaceId);
18107
- if (!existsSync36(path)) return void 0;
18463
+ if (!existsSync37(path)) return void 0;
18108
18464
  try {
18109
- return JSON.parse(readFileSync32(path, "utf8"));
18465
+ return JSON.parse(readFileSync33(path, "utf8"));
18110
18466
  } catch {
18111
18467
  return void 0;
18112
18468
  }
@@ -18114,7 +18470,7 @@ function readPersistedTunnel(stateDir, workspaceId) {
18114
18470
  function writePersistedTunnel(stateDir, state) {
18115
18471
  const path = tunnelStatePath(stateDir, state.workspaceId);
18116
18472
  mkdirSync23(join40(stateDir, "passengers"), { recursive: true });
18117
- writeFileSync24(path, `${JSON.stringify(state, null, 2)}
18473
+ writeFileSync25(path, `${JSON.stringify(state, null, 2)}
18118
18474
  `);
18119
18475
  }
18120
18476
  function updatePersistedTunnel(stateDir, workspaceId, patch2) {
@@ -18138,16 +18494,19 @@ function isPidAlive(pid) {
18138
18494
  return false;
18139
18495
  }
18140
18496
  }
18497
+ var CONTROL_ONLY_HASH = "control-only";
18141
18498
  function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, stateDir, mcpSecrets = /* @__PURE__ */ new Map(), deps = {}) {
18142
18499
  const alive = deps.isPidAlive ?? isPidAlive;
18143
18500
  const plannedIds = new Set(plans.map((p) => p.workspaceId));
18144
18501
  for (const plan of plans) {
18145
18502
  const existing = readPersistedTunnel(stateDir, plan.workspaceId);
18503
+ const mountsHash = plan.mountsHash ?? CONTROL_ONLY_HASH;
18504
+ const controlUnchanged = existing?.controlSocket === deps.controlSocket;
18146
18505
  const currentSecret = mcpSecrets.get(plan.workspaceId);
18147
18506
  const secretUnchanged = (existing?.mcpSecret ?? void 0) === (currentSecret ?? void 0);
18148
18507
  const slug = existing?.slug ?? generateTunnelSlug();
18149
18508
  const slugUnchanged = existing?.liveSlug === slug;
18150
- if (existing && alive(existing.pid) && existing.mountsHash === plan.mountsHash && secretUnchanged && slugUnchanged) {
18509
+ if (existing && alive(existing.pid) && existing.mountsHash === mountsHash && secretUnchanged && slugUnchanged && controlUnchanged) {
18151
18510
  if (existing.status === "connecting") {
18152
18511
  const settled = { ...existing, status: "connected" };
18153
18512
  writePersistedTunnel(stateDir, settled);
@@ -18174,6 +18533,7 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
18174
18533
  slug,
18175
18534
  apiKey,
18176
18535
  localPort: plan.port,
18536
+ controlSocket: deps.controlSocket,
18177
18537
  privateKeyPath,
18178
18538
  mcpSecret: currentSecret,
18179
18539
  spawnFn: deps.spawnFn,
@@ -18183,9 +18543,10 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
18183
18543
  });
18184
18544
  const started = {
18185
18545
  pid: tunnel.pid ?? -1,
18186
- containerName: plan.containerName,
18546
+ containerName: plan.containerName ?? "",
18187
18547
  workspaceId: plan.workspaceId,
18188
- mountsHash: plan.mountsHash,
18548
+ mountsHash,
18549
+ controlSocket: deps.controlSocket,
18189
18550
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
18190
18551
  status: settledStatus,
18191
18552
  mcpSecret: currentSecret,
@@ -18212,7 +18573,7 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
18212
18573
  }
18213
18574
  function readAllPersistedWorkspaceIds(stateDir) {
18214
18575
  const dir = join40(stateDir, "passengers");
18215
- if (!existsSync36(dir)) return [];
18576
+ if (!existsSync37(dir)) return [];
18216
18577
  return readdirSync14(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
18217
18578
  }
18218
18579
 
@@ -18333,7 +18694,7 @@ async function unregisterEndpoint(workspaceId, info, d) {
18333
18694
  import { createServer as createHttpServer } from "http";
18334
18695
  import { spawn as spawn6 } from "child_process";
18335
18696
  import { randomBytes } from "crypto";
18336
- import { existsSync as existsSync37, mkdirSync as mkdirSync24, readFileSync as readFileSync33, writeFileSync as writeFileSync25, unlinkSync as unlinkSync4, readdirSync as readdirSync15 } from "fs";
18697
+ import { existsSync as existsSync38, mkdirSync as mkdirSync24, readFileSync as readFileSync34, writeFileSync as writeFileSync26, unlinkSync as unlinkSync4, readdirSync as readdirSync15 } from "fs";
18337
18698
  import { join as join41 } from "path";
18338
18699
 
18339
18700
  // src/secret-compare.ts
@@ -18427,9 +18788,9 @@ function syncDaemonStatePath(stateDir, workspaceId) {
18427
18788
  }
18428
18789
  function readPersistedSyncDaemon(stateDir, workspaceId) {
18429
18790
  const path = syncDaemonStatePath(stateDir, workspaceId);
18430
- if (!existsSync37(path)) return void 0;
18791
+ if (!existsSync38(path)) return void 0;
18431
18792
  try {
18432
- return JSON.parse(readFileSync33(path, "utf8"));
18793
+ return JSON.parse(readFileSync34(path, "utf8"));
18433
18794
  } catch {
18434
18795
  return void 0;
18435
18796
  }
@@ -18437,7 +18798,7 @@ function readPersistedSyncDaemon(stateDir, workspaceId) {
18437
18798
  function writePersistedSyncDaemon(stateDir, state) {
18438
18799
  const path = syncDaemonStatePath(stateDir, state.workspaceId);
18439
18800
  mkdirSync24(join41(stateDir, "passengers"), { recursive: true });
18440
- writeFileSync25(path, `${JSON.stringify(state, null, 2)}
18801
+ writeFileSync26(path, `${JSON.stringify(state, null, 2)}
18441
18802
  `);
18442
18803
  }
18443
18804
  function removePersistedSyncDaemon(stateDir, workspaceId) {
@@ -18458,7 +18819,7 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
18458
18819
  }
18459
18820
  function readAllPersistedSyncWorkspaceIds(stateDir) {
18460
18821
  const dir = join41(stateDir, "passengers");
18461
- if (!existsSync37(dir)) return [];
18822
+ if (!existsSync38(dir)) return [];
18462
18823
  const suffix = "-sync.json";
18463
18824
  return readdirSync15(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
18464
18825
  }
@@ -18546,7 +18907,7 @@ function describeCause(err3) {
18546
18907
  }
18547
18908
 
18548
18909
  // src/daemon.ts
18549
- import { existsSync as existsSync38, mkdirSync as mkdirSync25, readFileSync as readFileSync34, readdirSync as readdirSync16, rmSync as rmSync17, statSync as statSync12, writeFileSync as writeFileSync26 } from "fs";
18910
+ import { existsSync as existsSync39, mkdirSync as mkdirSync25, readFileSync as readFileSync35, readdirSync as readdirSync16, rmSync as rmSync17, statSync as statSync12, writeFileSync as writeFileSync27 } from "fs";
18550
18911
  import { join as join42 } from "path";
18551
18912
  import { randomBytes as randomBytes2 } from "crypto";
18552
18913
  var DEFAULT_FLOOR_MS = 5e3;
@@ -18627,7 +18988,7 @@ function computeTreeSignature(paths) {
18627
18988
  }
18628
18989
  function makeStaleChecker(paths, opts = {}) {
18629
18990
  const initial = computeTreeSignature(paths);
18630
- return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync38(opts.reloadSentinel);
18991
+ return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync39(opts.reloadSentinel);
18631
18992
  }
18632
18993
  function reloadSentinelFor(stateDir) {
18633
18994
  return join42(stateDir, "reload-requested");
@@ -18638,12 +18999,12 @@ function daemonStampFor(stateDir) {
18638
18999
  function noteDaemonStarted(stateDir, now = Date.now()) {
18639
19000
  mkdirSync25(stateDir, { recursive: true });
18640
19001
  rmSync17(reloadSentinelFor(stateDir), { force: true });
18641
- writeFileSync26(daemonStampFor(stateDir), `${JSON.stringify({ pid: process.pid, startedAt: now })}
19002
+ writeFileSync27(daemonStampFor(stateDir), `${JSON.stringify({ pid: process.pid, startedAt: now })}
18642
19003
  `);
18643
19004
  }
18644
19005
  function readDaemonStarted(stateDir) {
18645
19006
  try {
18646
- const v = JSON.parse(readFileSync34(daemonStampFor(stateDir), "utf8")).startedAt;
19007
+ const v = JSON.parse(readFileSync35(daemonStampFor(stateDir), "utf8")).startedAt;
18647
19008
  return typeof v === "number" ? v : null;
18648
19009
  } catch {
18649
19010
  return null;
@@ -19060,7 +19421,7 @@ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
19060
19421
  const target = repoTargetFor(route2, result.ticket.repo_id);
19061
19422
  ticketRepo = target?.name;
19062
19423
  try {
19063
- if (target && ensureRepoCheckout(target.dir, target.remote)) {
19424
+ if (target && ensureRepoCheckout(target.dir, target.remote, { scheme: ship.cloneScheme })) {
19064
19425
  emit.emit(`cloned ${target.name} into ${target.dir}`, { step: "select", role });
19065
19426
  }
19066
19427
  } catch (e) {
@@ -19165,7 +19526,7 @@ import { createHash as createHash9 } from "crypto";
19165
19526
  import {
19166
19527
  chmodSync as chmodSync4,
19167
19528
  closeSync as closeSync5,
19168
- existsSync as existsSync39,
19529
+ existsSync as existsSync40,
19169
19530
  fstatSync,
19170
19531
  openSync as openSync5,
19171
19532
  readSync as readSync3,
@@ -19516,7 +19877,7 @@ function lastLines2(path, n) {
19516
19877
  }
19517
19878
  function readTail(live, opts) {
19518
19879
  const max = opts.maxBytes ?? MAX_TAIL_BYTES;
19519
- if (!existsSync39(live)) {
19880
+ if (!existsSync40(live)) {
19520
19881
  return { lines: [], cursor: opts.after ?? encodeCursor({ dev: 0, ino: 0, off: 0 }), reset: false };
19521
19882
  }
19522
19883
  const cur = opts.after ? decodeCursor(opts.after) : null;
@@ -19673,7 +20034,7 @@ async function route(deps, method, url, req, abort) {
19673
20034
  const run2 = decodeURIComponent(p.slice("/streams/".length));
19674
20035
  if (!RUN_NAME_RE.test(run2) || run2.includes("..") || /\.(pid|meta\.json|events\.jsonl)$/.test(run2)) return err2(400, "not a run name");
19675
20036
  const file = join43(deps.stateDir, "streams", run2.endsWith(".jsonl") ? run2 : `${run2}.jsonl`);
19676
- if (!existsSync39(file)) return err2(404, "no such run");
20037
+ if (!existsSync40(file)) return err2(404, "no such run");
19677
20038
  return tailReply(file, url.searchParams, false, abort);
19678
20039
  }
19679
20040
  if ((p === "/pause" || p === "/resume") && post3) {
@@ -19717,7 +20078,7 @@ function send(res, r) {
19717
20078
  res.end(body);
19718
20079
  }
19719
20080
  async function socketIsStale(path) {
19720
- if (!existsSync39(path)) return false;
20081
+ if (!existsSync40(path)) return false;
19721
20082
  return new Promise((resolve6) => {
19722
20083
  const c = createConnection(path);
19723
20084
  c.once("connect", () => {
@@ -19736,7 +20097,7 @@ async function startControlServer(deps) {
19736
20097
  return null;
19737
20098
  }
19738
20099
  if (await socketIsStale(path)) rmSync18(path, { force: true });
19739
- else if (existsSync39(path)) {
20100
+ else if (existsSync40(path)) {
19740
20101
  log(`control API not started: ${path} is in use by another process`);
19741
20102
  return null;
19742
20103
  }
@@ -19945,6 +20306,9 @@ function usage(problem, requested = false) {
19945
20306
  crew --version [--json] print the crew version on one line (also: crew version); --json adds home and compiled
19946
20307
  crew status [route] [--json] paused/running state; --json for a machine reader
19947
20308
  crew projects [--route R] [--json] the Projects rows each route can read (id, name, prefix, work mode)
20309
+ crew provision --key K --route WS[/PROJECT] [--forge-host H] [--ship-name N] [--json] one non-interactive run: connect, forge tokens,
20310
+ enable routes, install units with lingering, start the daemon; safe to re-run (CREW_CONNECT_KEY,
20311
+ CREW_PROVISION_ROUTES, CREW_SHIP_NAME, CREW_FORGE_TOKEN[_<HOST>] also read)
19948
20312
  crew forge [set|rotate <host>] [--token T | --token-stdin] [--source file|keychain|env] this ship's git-forge tokens (ship.forges)
19949
20313
  crew credential get|store|erase git credential helper answering from ship.forges (git runs it; not for hand use)
19950
20314
  crew whoami [route] [--json] the signed-in identity, key role, operator Crew row and credential source per route
@@ -20045,6 +20409,7 @@ var KNOWN_COMMANDS = /* @__PURE__ */ new Set([
20045
20409
  "agents",
20046
20410
  "connect",
20047
20411
  "credential",
20412
+ "provision",
20048
20413
  "forge",
20049
20414
  "daemon",
20050
20415
  "deploy",
@@ -20120,7 +20485,7 @@ function daemonConfigPaths(c) {
20120
20485
  for (const r of c.routes) if (r.apiKeyFile && !files.includes(r.apiKeyFile)) files.push(r.apiKeyFile);
20121
20486
  return files;
20122
20487
  }
20123
- var STATE_WRITING_COMMANDS = /* @__PURE__ */ new Set(["connect", "disconnect", "install", "uninstall", "reload", "release", "merge", "deploy"]);
20488
+ var STATE_WRITING_COMMANDS = /* @__PURE__ */ new Set(["connect", "provision", "disconnect", "install", "uninstall", "reload", "release", "merge", "deploy"]);
20124
20489
  var STATE_WRITING_SUBCOMMANDS = {
20125
20490
  agents: /* @__PURE__ */ new Set(["sync"]),
20126
20491
  skills: /* @__PURE__ */ new Set(["sync"])
@@ -20134,6 +20499,19 @@ if (sessionRole && (STATE_WRITING_COMMANDS.has(command) || STATE_WRITING_SUBCOMM
20134
20499
  );
20135
20500
  process.exit(2);
20136
20501
  }
20502
+ if (command === "provision") {
20503
+ const outcome = await runProvision(
20504
+ provisionOptionsFrom(argv, process.env),
20505
+ realProvisionDeps(CREW_HOME),
20506
+ (l) => {
20507
+ process.stdout.write(l);
20508
+ },
20509
+ (l) => {
20510
+ process.stderr.write(l);
20511
+ }
20512
+ );
20513
+ process.exit(outcome.code);
20514
+ }
20137
20515
  if (command === "passenger-sync-daemon") {
20138
20516
  runSyncDaemonFromEnv();
20139
20517
  } else {
@@ -20148,6 +20526,11 @@ if (command === "passenger-sync-daemon") {
20148
20526
  }, parseTicketNumber = function(input) {
20149
20527
  const m = /^(?:[A-Za-z]+-)?(\d+)$/.exec(input.trim());
20150
20528
  return m ? m[1] : null;
20529
+ }, routeRelayUrl = function(route3) {
20530
+ const ws = route3.resolved?.workspaceId;
20531
+ const slug = ws ? readPersistedTunnel(cfg.ship.stateDir, ws)?.slug : void 0;
20532
+ if (!cfg.ship.relayHost || !slug) return null;
20533
+ return relayBaseUrlFor(slug, cfg.ship.relayHost, cfg.ship.relayHttpPort, cfg.ship.relayPublicDomain);
20151
20534
  }, passengerApiKeys = function(routes) {
20152
20535
  const keys = /* @__PURE__ */ new Map();
20153
20536
  for (const r of routes) {
@@ -20195,7 +20578,7 @@ if (command === "passenger-sync-daemon") {
20195
20578
  const unsafe = baseBranchUnsafe(out);
20196
20579
  return { action: out.action, unsafe, detail: unsafe ? describeUnsafeBase(out, remote, base) : out.detail };
20197
20580
  };
20198
- requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, endpointReconcileDeps2 = endpointReconcileDeps, releaseRefused2 = releaseRefused, doctorMachineId2 = doctorMachineId, baseHealth2 = baseHealth;
20581
+ requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, routeRelayUrl2 = routeRelayUrl, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, endpointReconcileDeps2 = endpointReconcileDeps, releaseRefused2 = releaseRefused, doctorMachineId2 = doctorMachineId, baseHealth2 = baseHealth;
20199
20582
  let cfg;
20200
20583
  let firstRun;
20201
20584
  let firstRunFromFlags = false;
@@ -20211,7 +20594,7 @@ if (command === "passenger-sync-daemon") {
20211
20594
  };
20212
20595
  if (command === "connect" && !flag("sign-out") && usesFlagAnswers(wizardFlags, connectIsTTY) && shouldRunWizard({ isTTY: true, crewHome: CREW_HOME })) {
20213
20596
  const flagged = flagWizardAnswers(wizardFlags, {
20214
- hostname: hostname2(),
20597
+ hostname: hostname3(),
20215
20598
  platform: hostPlatform(),
20216
20599
  pathEnv: process.env.PATH ?? "",
20217
20600
  nodePath: process.execPath,
@@ -20236,7 +20619,7 @@ if (command === "passenger-sync-daemon") {
20236
20619
  let answers;
20237
20620
  try {
20238
20621
  answers = await runWizard(prompter, {
20239
- hostname: hostname2(),
20622
+ hostname: hostname3(),
20240
20623
  platform: hostPlatform(),
20241
20624
  pathEnv: process.env.PATH ?? "",
20242
20625
  nodePath: process.execPath,
@@ -20251,7 +20634,7 @@ if (command === "passenger-sync-daemon") {
20251
20634
  } else {
20252
20635
  const toleratesNoRoutes = command === "credential" || command === "forge" || command === "repos" || command === "ship" || command === "disconnect" && flag("reset") || command === "connect" && flag("sign-out");
20253
20636
  const configless = command === "connect" && flag("sign-out") || command === "disconnect" && flag("reset");
20254
- if (configless && !existsSync40(findConfigFile(CREW_HOME))) {
20637
+ if (configless && !existsSync41(findConfigFile(CREW_HOME))) {
20255
20638
  cfg = loadConfig(CREW_HOME, firstRunConfigPath(), { text: "ship: {}\n", allowNoRoutes: true });
20256
20639
  } else {
20257
20640
  cfg = loadConfig(CREW_HOME, void 0, toleratesNoRoutes ? { allowNoRoutes: true } : {});
@@ -20266,7 +20649,7 @@ if (command === "passenger-sync-daemon") {
20266
20649
  throw e;
20267
20650
  }
20268
20651
  if (command === "credential") {
20269
- const r = runCredential(positional[1], readFileSync35(0, "utf8"), cfg.ship.forges, cfg.ship.stateDir);
20652
+ const r = runCredential(positional[1], readFileSync36(0, "utf8"), cfg.ship.forges, cfg.ship.stateDir);
20270
20653
  process.stdout.write(r.out);
20271
20654
  process.stderr.write(r.err);
20272
20655
  process.exit(r.code);
@@ -20278,9 +20661,9 @@ if (command === "passenger-sync-daemon") {
20278
20661
  forges: cfg.ship.forges,
20279
20662
  stateDir: cfg.ship.stateDir,
20280
20663
  configFile: cfg.configFile,
20281
- readFile: (p) => readFileSync35(p, "utf8"),
20282
- writeFile: (p, t) => writeFileSync27(p, t),
20283
- stdin: () => readFileSync35(0, "utf8"),
20664
+ readFile: (p) => readFileSync36(p, "utf8"),
20665
+ writeFile: (p, t) => writeFileSync28(p, t),
20666
+ stdin: () => readFileSync36(0, "utf8"),
20284
20667
  env: process.env
20285
20668
  }
20286
20669
  );
@@ -20328,7 +20711,10 @@ if (command === "passenger-sync-daemon") {
20328
20711
  async function syncHostPassengers() {
20329
20712
  if (dryRun) return;
20330
20713
  const passengerRoutes = cfg.routes.filter((r) => r.enabled && r.hostPassengers);
20331
- if (passengerRoutes.length === 0) return;
20714
+ if (passengerRoutes.length === 0) {
20715
+ await syncShipTunnels([], /* @__PURE__ */ new Map(), []);
20716
+ return;
20717
+ }
20332
20718
  if (!dockerAvailable()) {
20333
20719
  const pathProblem = dockerPathProblem(cfg.ship, COMMON_DOCKER_DIRS);
20334
20720
  emit.warn(
@@ -20370,37 +20756,64 @@ if (command === "passenger-sync-daemon") {
20370
20756
  { step: "passengers", data: { ...result } }
20371
20757
  );
20372
20758
  }
20373
- if (cfg.ship.relayHost) {
20374
- try {
20375
- const { privateKeyPath } = ensureShipSshKeypair(cfg.ship.stateDir);
20376
- const plans = planContainers(cfg);
20377
- const mcpSecrets = new Map([...syncEndpoints].map(([workspaceId, e]) => [workspaceId, e.secret]));
20378
- const relayHost = cfg.ship.relayHost;
20379
- const reconcile = endpointReconcileDeps(passengerRoutes, relayHost);
20380
- const pending = [];
20381
- syncPassengerTunnels(plans, relayHost, cfg.ship.relayPort, privateKeyPath, cfg.ship.stateDir, mcpSecrets, {
20382
- apiKeys: passengerApiKeys(passengerRoutes),
20383
- onStatus: (workspaceId, status2, info) => {
20384
- const owningRoute = passengerRoutes.find((r) => r.resolved?.workspaceId === workspaceId);
20385
- if (!owningRoute) return;
20386
- pending.push(
20387
- new Tracker(owningRoute, cfg.ship).updateTunnelState(cfg.ship.name, { tunnel_status: status2 }).catch((e) => {
20388
- emit.warn(`could not write tunnel_status: ${e.message}`, { step: "passengers" });
20389
- })
20390
- );
20391
- if (status2 === "disconnected") pending.push(unregisterEndpoint(workspaceId, info, reconcile));
20392
- }
20393
- });
20394
- for (const plan of plans) pending.push(reconcileEndpoint(plan.workspaceId, reconcile));
20395
- await Promise.all(pending);
20396
- } catch (e) {
20397
- emit.warn(`passenger tunnel sync failed: ${e.message}`, { step: "passengers" });
20398
- }
20399
- }
20759
+ await syncShipTunnels(planContainers(cfg), new Map([...syncEndpoints].map(([workspaceId, e]) => [workspaceId, e.secret])), passengerRoutes);
20400
20760
  } catch (e) {
20401
20761
  emit.warn(`passenger container sync failed: ${e.message}`, { step: "passengers" });
20402
20762
  }
20403
20763
  }
20764
+ async function syncShipTunnels(containerPlans, mcpSecrets, passengerRoutes) {
20765
+ if (!cfg.ship.relayHost) return;
20766
+ const relayHost = cfg.ship.relayHost;
20767
+ try {
20768
+ const { privateKeyPath } = ensureShipSshKeypair(cfg.ship.stateDir);
20769
+ const controlRoutes = cfg.routes.filter((r) => r.enabled && r.resolved?.workspaceId);
20770
+ const planned = new Set(containerPlans.map((p) => p.workspaceId));
20771
+ const plans = [...containerPlans];
20772
+ for (const r of controlRoutes) {
20773
+ const ws = r.resolved.workspaceId;
20774
+ if (planned.has(ws)) continue;
20775
+ planned.add(ws);
20776
+ plans.push({ workspaceId: ws });
20777
+ }
20778
+ const apiRoutes = controlRoutes;
20779
+ const reconcile = endpointReconcileDeps(passengerRoutes, relayHost);
20780
+ const pending = [];
20781
+ syncPassengerTunnels(plans, relayHost, cfg.ship.relayPort, privateKeyPath, cfg.ship.stateDir, mcpSecrets, {
20782
+ apiKeys: passengerApiKeys(apiRoutes),
20783
+ controlSocket: controlSocketFor(cfg.ship.stateDir),
20784
+ onStatus: (workspaceId, status2, info) => {
20785
+ const owningRoute = apiRoutes.find((r) => r.resolved?.workspaceId === workspaceId);
20786
+ if (!owningRoute) return;
20787
+ pending.push(
20788
+ new Tracker(owningRoute, cfg.ship).updateTunnelState(cfg.ship.name, { tunnel_status: status2 }).catch((e) => {
20789
+ emit.warn(`could not write tunnel_status: ${e.message}`, { step: "passengers" });
20790
+ })
20791
+ );
20792
+ if (status2 === "disconnected") pending.push(unregisterEndpoint(workspaceId, info, reconcile));
20793
+ }
20794
+ });
20795
+ for (const plan of containerPlans) pending.push(reconcileEndpoint(plan.workspaceId, reconcile));
20796
+ for (const plan of plans) pending.push(reconcileRelayUrl(plan.workspaceId, apiRoutes));
20797
+ await Promise.all(pending);
20798
+ } catch (e) {
20799
+ emit.warn(`ship tunnel sync failed: ${e.message}`, { step: "passengers" });
20800
+ }
20801
+ }
20802
+ async function reconcileRelayUrl(workspaceId, routes) {
20803
+ if (!cfg.ship.relayHost) return;
20804
+ const persisted = readPersistedTunnel(cfg.ship.stateDir, workspaceId);
20805
+ if (!persisted?.slug || persisted.status !== "connected") return;
20806
+ const url = relayBaseUrlFor(persisted.slug, cfg.ship.relayHost, cfg.ship.relayHttpPort, cfg.ship.relayPublicDomain);
20807
+ if (persisted.relayUrl === url) return;
20808
+ const route3 = routes.find((r) => r.resolved?.workspaceId === workspaceId);
20809
+ if (!route3) return;
20810
+ try {
20811
+ await new Tracker(route3, cfg.ship).updateRelayUrl(cfg.ship.name, url);
20812
+ updatePersistedTunnel(cfg.ship.stateDir, workspaceId, { relayUrl: url });
20813
+ } catch (e) {
20814
+ emit.warn(`could not write relay_url: ${e.message}`, { step: "passengers" });
20815
+ }
20816
+ }
20404
20817
  async function releaseFleet(opts = {}) {
20405
20818
  if (releaseRefused()) return;
20406
20819
  precheckTickets.clear();
@@ -20504,7 +20917,7 @@ if (command === "passenger-sync-daemon") {
20504
20917
  }
20505
20918
  const hasSystemd = detectSystemd();
20506
20919
  for (const job of ["run", "release", "passengers"]) {
20507
- const plan = planInstall(cfg.ship, CREW_HOME, host, hasSystemd, job);
20920
+ const plan = planInstall(cfg.ship, CREW_HOME, host, hasSystemd, job, installContext());
20508
20921
  process.stdout.write(`installing ${job} via ${plan.mechanism} for ${host}${dryRun ? " (dry run)" : ""}
20509
20922
  `);
20510
20923
  await applyInstall(plan, CREW_HOME, dryRun, {
@@ -20517,13 +20930,13 @@ if (command === "passenger-sync-daemon") {
20517
20930
  if (host === "linux" && hasSystemd) {
20518
20931
  const label = labelFor(CREW_HOME, "run");
20519
20932
  const staleTimer = join44(homedir10(), ".config", "systemd", "user", `${label}.timer`);
20520
- if (existsSync40(staleTimer)) {
20933
+ if (existsSync41(staleTimer)) {
20521
20934
  process.stdout.write(
20522
20935
  `removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
20523
20936
  `
20524
20937
  );
20525
20938
  if (!dryRun) {
20526
- spawnSync7("systemctl", ["--user", "disable", "--now", `${label}.timer`], { cwd: CREW_HOME });
20939
+ spawnSync8("systemctl", ["--user", "disable", "--now", `${label}.timer`], { cwd: CREW_HOME });
20527
20940
  try {
20528
20941
  unlinkSync5(staleTimer);
20529
20942
  } catch {
@@ -20544,7 +20957,7 @@ if (command === "passenger-sync-daemon") {
20544
20957
  const tracker = new Tracker(c, cfg.ship);
20545
20958
  problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
20546
20959
  for (const r of await resolvedRepos(c)) {
20547
- if (!existsSync40(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
20960
+ if (!existsSync41(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
20548
20961
  if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
20549
20962
  readiness.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
20550
20963
  }
@@ -20562,9 +20975,9 @@ if (command === "passenger-sync-daemon") {
20562
20975
  }
20563
20976
  async function doctorFix(o) {
20564
20977
  const routes = await Promise.all(routesInScope(cfg.routes, named).map((c) => gatherHealth({ route: c.route, enabled: c.enabled, hasRepos: reposOf(c).length > 0 }, () => routeProblems(c))));
20565
- const unitExists = (p) => existsSync40(p);
20566
- const crontab = o.host === "linux" ? spawnSync7("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
20567
- const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
20978
+ const unitExists = (p) => existsSync41(p);
20979
+ const crontab = o.host === "linux" ? spawnSync8("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
20980
+ const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run", installContext()), unitExists, crontab) : true;
20568
20981
  const plan = planFix({ routes, schedulerAbsent, host: o.host });
20569
20982
  for (const n of plan.notes) process.stdout.write(`fix: ${n}
20570
20983
  `);
@@ -20579,7 +20992,7 @@ if (command === "passenger-sync-daemon") {
20579
20992
  for (const a of plan.actions) {
20580
20993
  if (a.kind === "enable") {
20581
20994
  if (prompter && !await prompter.confirm(`Route ${a.route} passes every check \u2014 set enabled: true in ${cfg.configFile}?`, false)) continue;
20582
- writeFileSync27(cfg.configFile, planEnable(readFileSync35(cfg.configFile, "utf8"), a.route));
20995
+ writeFileSync28(cfg.configFile, planEnable(readFileSync36(cfg.configFile, "utf8"), a.route));
20583
20996
  enabledSound.add(a.route);
20584
20997
  changed2.push(`enabled route ${a.route} in ${cfg.configFile}`);
20585
20998
  } else {
@@ -21321,7 +21734,7 @@ ${cu.tail.trimEnd()}
21321
21734
  const target = repoTargetFor(w.route, result.ticket.repo_id);
21322
21735
  fleetTicketRepo = target?.name;
21323
21736
  try {
21324
- if (target && ensureRepoCheckout(target.dir, target.remote)) {
21737
+ if (target && ensureRepoCheckout(target.dir, target.remote, { scheme: cfg.ship.cloneScheme })) {
21325
21738
  wemit.emit(`cloned ${target.name} into ${target.dir}`, { step: "select", role: w.role });
21326
21739
  }
21327
21740
  } catch (e) {
@@ -21538,7 +21951,7 @@ ${cu.tail.trimEnd()}
21538
21951
  const target = repoTargetFor(route2, result.ticket.repo_id);
21539
21952
  ticketRepo = target?.name;
21540
21953
  try {
21541
- if (target && ensureRepoCheckout(target.dir, target.remote)) {
21954
+ if (target && ensureRepoCheckout(target.dir, target.remote, { scheme: cfg.ship.cloneScheme })) {
21542
21955
  emit.emit(`cloned ${target.name} into ${target.dir}`, { step: "select", role: current });
21543
21956
  }
21544
21957
  } catch (e) {
@@ -21659,7 +22072,7 @@ ${cu.tail.trimEnd()}
21659
22072
  }
21660
22073
  let failed = false;
21661
22074
  for (const cmd2 of commands) {
21662
- const res2 = spawnSync7(cmd2[0], cmd2.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
22075
+ const res2 = spawnSync8(cmd2[0], cmd2.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
21663
22076
  if (res2.stdout) process.stdout.write(res2.stdout);
21664
22077
  if (res2.stderr) process.stderr.write(res2.stderr);
21665
22078
  if (cmd2 === control.startCommand && (res2.error || res2.status !== 0)) {
@@ -21677,7 +22090,7 @@ ${cu.tail.trimEnd()}
21677
22090
  `);
21678
22091
  break;
21679
22092
  }
21680
- const res = spawnSync7(cmd[0], cmd.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
22093
+ const res = spawnSync8(cmd[0], cmd.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
21681
22094
  if (res.stdout) process.stdout.write(res.stdout);
21682
22095
  if (res.stderr) process.stderr.write(res.stderr);
21683
22096
  if (daemonSub !== "status" && (res.error || res.status !== 0)) {
@@ -21961,10 +22374,10 @@ ${cu.tail.trimEnd()}
21961
22374
  `);
21962
22375
  if (!dryRun) {
21963
22376
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
21964
- const raw = existsSync40(resolvedPath) ? JSON.parse(readFileSync35(resolvedPath, "utf8")) : {};
22377
+ const raw = existsSync41(resolvedPath) ? JSON.parse(readFileSync36(resolvedPath, "utf8")) : {};
21965
22378
  raw.agentSkills = result.agentSkills;
21966
22379
  mkdirSync26(dirOf2(resolvedPath), { recursive: true });
21967
- writeFileSync27(resolvedPath, `${JSON.stringify(raw, null, 2)}
22380
+ writeFileSync28(resolvedPath, `${JSON.stringify(raw, null, 2)}
21968
22381
  `);
21969
22382
  }
21970
22383
  if (result.outcomes.some((o) => o.action === "diverged")) process.exitCode = 1;
@@ -21987,7 +22400,7 @@ ${cu.tail.trimEnd()}
21987
22400
  throw e;
21988
22401
  }
21989
22402
  const oldName = cfg.ship.name;
21990
- writeFileSync27(cfg.configFile, setShipNameInYaml(readFileSync35(cfg.configFile, "utf8"), newName));
22403
+ writeFileSync28(cfg.configFile, setShipNameInYaml(readFileSync36(cfg.configFile, "utf8"), newName));
21991
22404
  cfg.ship.name = newName;
21992
22405
  let renameMachineId;
21993
22406
  try {
@@ -22034,7 +22447,7 @@ ${cu.tail.trimEnd()}
22034
22447
  process.exit(2);
22035
22448
  };
22036
22449
  if (sub !== "add" && sub !== "list" && sub !== "clone" && sub !== "hooks" && sub !== "check") usage2();
22037
- const configText = readFileSync35(cfg.configFile, "utf8");
22450
+ const configText = readFileSync36(cfg.configFile, "utf8");
22038
22451
  if (sub === "check") {
22039
22452
  try {
22040
22453
  const routeArg = value("route") ?? positional[2];
@@ -22065,8 +22478,8 @@ ${cu.tail.trimEnd()}
22065
22478
  try {
22066
22479
  const a = parseHooksArgs(positional.slice(2), routeNamesOf(configText));
22067
22480
  const dir = repoDirOf(configText, dirname22(cfg.configFile), a.route, a.name);
22068
- if (!existsSync40(dir)) throw new ReposError(`${dir} does not exist on disk`);
22069
- const files = { exists: (r) => existsSync40(resolvePath(dir, r)), list: (r) => {
22481
+ if (!existsSync41(dir)) throw new ReposError(`${dir} does not exist on disk`);
22482
+ const files = { exists: (r) => existsSync41(resolvePath(dir, r)), list: (r) => {
22070
22483
  try {
22071
22484
  return readdirSync18(resolvePath(dir, r));
22072
22485
  } catch {
@@ -22074,7 +22487,7 @@ ${cu.tail.trimEnd()}
22074
22487
  }
22075
22488
  }, read: (r) => {
22076
22489
  try {
22077
- return readFileSync35(resolvePath(dir, r), "utf8");
22490
+ return readFileSync36(resolvePath(dir, r), "utf8");
22078
22491
  } catch {
22079
22492
  return void 0;
22080
22493
  }
@@ -22116,19 +22529,19 @@ ${cu.tail.trimEnd()}
22116
22529
  let cleared;
22117
22530
  if (clear2) {
22118
22531
  const plan = clearRouteHooks(configText, a.route, a.name);
22119
- if (plan.removed.length > 0) writeFileSync27(cfg.configFile, plan.text);
22532
+ if (plan.removed.length > 0) writeFileSync28(cfg.configFile, plan.text);
22120
22533
  cleared = { file: cfg.configFile, removed: plan.removed };
22121
22534
  }
22122
22535
  if (write && suggestion.source === null && recipeIds.length === 0 && hookSpecs.length === 0) throw new ReposError(`nothing to write: ${suggestion.notes[0]}`);
22123
22536
  if (write === "route") {
22124
22537
  const plan = planRouteHooks(configText, a.route, a.name, picked.hooks, { replace: flag("replace") });
22125
- writeFileSync27(cfg.configFile, plan.text);
22538
+ writeFileSync28(cfg.configFile, plan.text);
22126
22539
  written = { target: "route", file: cfg.configFile, hooks: plan.written, kept: plan.kept, replaced: plan.replaced };
22127
22540
  } else if (write === "repo") {
22128
22541
  const have = existingRepoConfig(files);
22129
22542
  if (have) throw new ReposError(`${resolvePath(dir, have)} already exists; not overwriting it`);
22130
22543
  const file = resolvePath(dir, ".crew.yaml");
22131
- writeFileSync27(file, starterRepoConfig(picked.hooks, picked.release), { flag: "wx" });
22544
+ writeFileSync28(file, starterRepoConfig(picked.hooks, picked.release), { flag: "wx" });
22132
22545
  written = { target: "repo", file, hooks: Object.keys(picked.hooks), kept: [], replaced: [] };
22133
22546
  }
22134
22547
  if (flag("json")) {
@@ -22184,7 +22597,7 @@ ${cu.tail.trimEnd()}
22184
22597
  if (sub === "list" && !routeName) usage2();
22185
22598
  if (sub === "add") {
22186
22599
  try {
22187
- const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync40(resolvePath(p.replace(/^~(?=\/|$)/, homedir10()))));
22600
+ const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync41(resolvePath(p.replace(/^~(?=\/|$)/, homedir10()))));
22188
22601
  routeName = a.route;
22189
22602
  addPath = a.path;
22190
22603
  addName = a.name;
@@ -22213,16 +22626,16 @@ ${cu.tail.trimEnd()}
22213
22626
  }
22214
22627
  const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
22215
22628
  let resolvedRemotes;
22216
- if (existsSync40(resolvedFile)) {
22629
+ if (existsSync41(resolvedFile)) {
22217
22630
  try {
22218
- resolvedRemotes = JSON.parse(readFileSync35(resolvedFile, "utf8"));
22631
+ resolvedRemotes = JSON.parse(readFileSync36(resolvedFile, "utf8"));
22219
22632
  } catch {
22220
22633
  }
22221
22634
  }
22222
22635
  const originOf = (dir) => gitOk(dir, ["remote", "get-url", "origin"]) ?? void 0;
22223
22636
  const text = configText;
22224
22637
  const base = dirname22(cfg.configFile);
22225
- const isCheckout = (d) => existsSync40(`${d}/.git`);
22638
+ const isCheckout = (d) => existsSync41(`${d}/.git`);
22226
22639
  try {
22227
22640
  if (sub === "list") {
22228
22641
  if (flag("json")) {
@@ -22232,11 +22645,11 @@ ${cu.tail.trimEnd()}
22232
22645
  base,
22233
22646
  resolved: resolvedRemotes,
22234
22647
  originOf,
22235
- exists: (d) => existsSync40(d),
22648
+ exists: (d) => existsSync41(d),
22236
22649
  isCheckout,
22237
22650
  repoHasHooks: (d) => REPO_CONFIG_FILENAMES.some((f) => {
22238
22651
  try {
22239
- return Object.keys(parseRepoConfig(readFileSync35(resolvePath(d, f), "utf8"), f).hooks).length > 0;
22652
+ return Object.keys(parseRepoConfig(readFileSync36(resolvePath(d, f), "utf8"), f).hooks).length > 0;
22240
22653
  } catch {
22241
22654
  return false;
22242
22655
  }
@@ -22253,7 +22666,7 @@ ${cu.tail.trimEnd()}
22253
22666
  for (const r of repos) {
22254
22667
  const origin2 = originOf(r.dir);
22255
22668
  const row = matchRepoName(origin2, resolvedRemotes);
22256
- const note = !existsSync40(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
22669
+ const note = !existsSync41(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
22257
22670
  process.stdout.write(`${r.name} ${r.dir} ${note}
22258
22671
  `);
22259
22672
  }
@@ -22265,9 +22678,9 @@ ${cu.tail.trimEnd()}
22265
22678
  ` : `${human}
22266
22679
  `);
22267
22680
  const plan2 = planRepoClone({ text, base, route: routeName, name: addName, resolved: resolvedRemotes, dir: value("dir"), isCheckout });
22268
- if (existsSync40(plan2.target) && (existsSync40(`${plan2.target}/.git`) || readdirSync18(plan2.target).length > 0)) {
22681
+ if (existsSync41(plan2.target) && (existsSync41(`${plan2.target}/.git`) || readdirSync18(plan2.target).length > 0)) {
22269
22682
  throw new ReposError(
22270
- existsSync40(`${plan2.target}/.git`) ? `${plan2.target} is already a checkout \u2014 attach it with \`crew repos add ${routeName} ${plan2.name} ${plan2.target}\`` : `${plan2.target} exists and is not empty \u2014 pass --dir to clone somewhere else`
22683
+ existsSync41(`${plan2.target}/.git`) ? `${plan2.target} is already a checkout \u2014 attach it with \`crew repos add ${routeName} ${plan2.name} ${plan2.target}\`` : `${plan2.target} exists and is not empty \u2014 pass --dir to clone somewhere else`
22271
22684
  );
22272
22685
  }
22273
22686
  if (dryRun) {
@@ -22285,14 +22698,14 @@ ${cu.tail.trimEnd()}
22285
22698
  throw new ReposError(`${e.message}${authHint}
22286
22699
  crew.yaml was not changed`);
22287
22700
  }
22288
- writeFileSync27(cfg.configFile, plan2.text);
22701
+ writeFileSync28(cfg.configFile, plan2.text);
22289
22702
  if (plan2.droppedDir) process.stderr.write(`crew repos: dropped dir: ${plan2.droppedDir} from route ${routeName} \u2014 it is not a checkout (the clone base); checkouts now live under repos:
22290
22703
  `);
22291
22704
  say("attached", { route: routeName, name: plan2.name, dir: plan2.target, mode: plan2.mode }, `cloned and attached ${plan2.target} to ${routeName} as ${plan2.mode === "dir" ? "dir" : `repos.${plan2.name}`} in ${cfg.configFile}`);
22292
22705
  break;
22293
22706
  }
22294
22707
  const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir10()));
22295
- const top = existsSync40(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
22708
+ const top = existsSync41(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
22296
22709
  if (!top) throw new ReposError(`${target} is not a git checkout`);
22297
22710
  const origin = originOf(top);
22298
22711
  const plan = planRepoAdd({
@@ -22344,15 +22757,15 @@ ${cu.tail.trimEnd()}
22344
22757
  const created = await rowTracker.createRepoRow(rowPayload);
22345
22758
  process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
22346
22759
  `);
22347
- if (existsSync40(resolvedFile)) {
22348
- const raw = JSON.parse(readFileSync35(resolvedFile, "utf8"));
22760
+ if (existsSync41(resolvedFile)) {
22761
+ const raw = JSON.parse(readFileSync36(resolvedFile, "utf8"));
22349
22762
  raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
22350
22763
  raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
22351
- writeFileSync27(resolvedFile, `${JSON.stringify(raw, null, 2)}
22764
+ writeFileSync28(resolvedFile, `${JSON.stringify(raw, null, 2)}
22352
22765
  `);
22353
22766
  }
22354
22767
  }
22355
- writeFileSync27(cfg.configFile, plan.text);
22768
+ writeFileSync28(cfg.configFile, plan.text);
22356
22769
  process.stdout.write(`added ${top} to ${routeName} as ${plan.mode === "dir" ? "dir" : `repos.${plan.name}`} in ${cfg.configFile}
22357
22770
  `);
22358
22771
  } catch (e) {
@@ -22563,7 +22976,7 @@ ${reconstruction.prompt}
22563
22976
  let result;
22564
22977
  try {
22565
22978
  result = await loginWithDeviceCode(`${baseUrl}/api`, {
22566
- deviceName: `crew on ${hostname2()}`,
22979
+ deviceName: `crew on ${hostname3()}`,
22567
22980
  // Lets the approval page preselect the workspace this connect is for.
22568
22981
  // A bare `crew connect` has none and passes nothing.
22569
22982
  ...workspace ? { workspaceSlug: workspace } : {},
@@ -22719,7 +23132,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
22719
23132
  process.exit(2);
22720
23133
  }
22721
23134
  if (found.shipNameAdopted && !dryRun) {
22722
- writeFileSync27(cfg.configFile, setShipNameInYaml(readFileSync35(cfg.configFile, "utf8"), found.shipNameAdopted));
23135
+ writeFileSync28(cfg.configFile, setShipNameInYaml(readFileSync36(cfg.configFile, "utf8"), found.shipNameAdopted));
22723
23136
  cfg.ship.name = found.shipNameAdopted;
22724
23137
  }
22725
23138
  if (found.shipRow) {
@@ -22762,10 +23175,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
22762
23175
  }
22763
23176
  }
22764
23177
  let previous;
22765
- const hadPreviousFile = existsSync40(resolvedPath);
23178
+ const hadPreviousFile = existsSync41(resolvedPath);
22766
23179
  if (hadPreviousFile) {
22767
23180
  try {
22768
- previous = JSON.parse(readFileSync35(resolvedPath, "utf8"));
23181
+ previous = JSON.parse(readFileSync36(resolvedPath, "utf8"));
22769
23182
  } catch {
22770
23183
  }
22771
23184
  }
@@ -22897,7 +23310,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
22897
23310
  mkdirSync26(dirname22(resolvedPath), { recursive: true });
22898
23311
  if (hadPreviousFile) copyFileSync2(resolvedPath, `${resolvedPath}.bak`);
22899
23312
  const tmpPath = `${resolvedPath}.tmp${process.pid}`;
22900
- writeFileSync27(tmpPath, `${JSON.stringify(resolved, null, 2)}
23313
+ writeFileSync28(tmpPath, `${JSON.stringify(resolved, null, 2)}
22901
23314
  `);
22902
23315
  renameSync4(tmpPath, resolvedPath);
22903
23316
  process.stderr.write(`Resolved ids written to ${resolvedPath}
@@ -22944,7 +23357,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
22944
23357
  `);
22945
23358
  } else {
22946
23359
  mkdirSync26(dirname22(apiKeyFile), { recursive: true });
22947
- writeFileSync27(apiKeyFile, `${apiKeyVar}=${apiKey}
23360
+ writeFileSync28(apiKeyFile, `${apiKeyVar}=${apiKey}
22948
23361
  `, { mode: 384 });
22949
23362
  process.stderr.write(`API key written to ${apiKeyFile}
22950
23363
  `);
@@ -23417,6 +23830,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
23417
23830
  const rotatePending = [];
23418
23831
  syncPassengerTunnels(plans, cfg.ship.relayHost, cfg.ship.relayPort, privateKeyPath, cfg.ship.stateDir, /* @__PURE__ */ new Map(), {
23419
23832
  apiKeys: passengerApiKeys([route2]),
23833
+ controlSocket: controlSocketFor(cfg.ship.stateDir),
23420
23834
  onStatus: (wsId, status2, info) => {
23421
23835
  rotatePending.push(
23422
23836
  new Tracker(route2, cfg.ship).updateTunnelState(cfg.ship.name, { tunnel_status: status2 }).catch((e) => {
@@ -23678,7 +24092,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
23678
24092
  const shellR = hit && resolveShell(hit.repo.config.shell ?? cfg.ship.shell);
23679
24093
  const resumed = await runPeriscopeResume({
23680
24094
  find: async () => hit && { row: hit.row, tag: hit.tag },
23681
- worktreeExists: (p) => existsSync40(p),
24095
+ worktreeExists: (p) => existsSync41(p),
23682
24096
  caps: periscopeCaps(cfg.ship),
23683
24097
  runHook: async (name, cwd, env) => {
23684
24098
  const script = hit?.repo.config.hooks[name];
@@ -23773,7 +24187,7 @@ ${where.join("\n")}`);
23773
24187
  prepare: async (path) => {
23774
24188
  for (const f of repoP.config.worktrees.copy) {
23775
24189
  const src = join44(repoP.dir, f);
23776
- if (!existsSync40(src)) continue;
24190
+ if (!existsSync41(src)) continue;
23777
24191
  mkdirSync26(dirOf2(join44(path, f)), { recursive: true });
23778
24192
  copyFileSync2(src, join44(path, f));
23779
24193
  }
@@ -23861,7 +24275,7 @@ ${where.join("\n")}`);
23861
24275
  process.stdout.write(upOutput(res, terminal));
23862
24276
  const opener = openerFor(process.platform);
23863
24277
  if (opener && shouldOpen({ open: flag("open"), noOpen: flag("no-open"), json: jsonP, terminal })) {
23864
- const r = spawnSync7(opener, [res.url], { stdio: "ignore" });
24278
+ const r = spawnSync8(opener, [res.url], { stdio: "ignore" });
23865
24279
  if (r.error || r.status !== 0) process.stderr.write(`crew periscope: could not open ${res.url} with ${opener}
23866
24280
  `);
23867
24281
  }
@@ -23993,6 +24407,14 @@ ${where.join("\n")}`);
23993
24407
  const url = mcpUrlFor(value("base-url") ?? cfg.routes[0]?.baseUrl ?? DEFAULT_BASE_URL);
23994
24408
  const disconnectHarnesses = /* @__PURE__ */ new Set([...shipPair(cfg.ship), ...shipToolchains(cfg.ship).map((t) => t.harness).filter(isPairHarness)]);
23995
24409
  const removePairEntries = (mcpUrl, dry) => [...disconnectHarnesses].map((h) => pairInstallerFor(h).removePair({ url: mcpUrl, bin: cfg.ship.agent.bin, dryRun: dry }));
24410
+ if (!dryRun) {
24411
+ for (const r of cfg.routes.filter((x) => x.resolved?.shipsModelId)) {
24412
+ await new Tracker(r, cfg.ship).updateRelayUrl(cfg.ship.name, null).catch((e) => {
24413
+ process.stderr.write(`could not clear relay_url for ${r.route}: ${e.message}
24414
+ `);
24415
+ });
24416
+ }
24417
+ }
23996
24418
  if (!flag("reset")) {
23997
24419
  for (const line of removePairEntries(url, dryRun)) process.stderr.write(`${line}
23998
24420
  `);
@@ -24050,7 +24472,7 @@ ${where.join("\n")}`);
24050
24472
  };
24051
24473
  const droppedRoutes = [];
24052
24474
  try {
24053
- const rawCfg = parseYaml2(readFileSync35(cfg.configFile, "utf8"));
24475
+ const rawCfg = parseYaml2(readFileSync36(cfg.configFile, "utf8"));
24054
24476
  for (const r of rawCfg?.routes ?? []) {
24055
24477
  if (typeof r?.route !== "string" || cfg.routes.some((x) => x.route === r.route)) continue;
24056
24478
  try {
@@ -24086,19 +24508,19 @@ ${where.join("\n")}`);
24086
24508
  }
24087
24509
  const host = hostPlatform();
24088
24510
  const control = host === "windows" ? null : planDaemonControl(CREW_HOME, host, detectSystemd());
24089
- const unitStatus = control?.statusCommand ? spawnSync7(control.statusCommand[0], control.statusCommand.slice(1), { cwd: CREW_HOME, encoding: "utf8" }) : null;
24511
+ const unitStatus = control?.statusCommand ? spawnSync8(control.statusCommand[0], control.statusCommand.slice(1), { cwd: CREW_HOME, encoding: "utf8" }) : null;
24090
24512
  const unitRunning = unitStatus !== null && !unitStatus.error && unitStatus.status === 0;
24091
24513
  if (!unitRunning) {
24092
24514
  process.stdout.write("crew reload: no daemon unit is running \u2014 nothing to reload (the next run reads crew.yaml fresh)\n");
24093
24515
  break;
24094
24516
  }
24095
24517
  mkdirSync26(cfg.ship.stateDir, { recursive: true });
24096
- writeFileSync27(sentinel, `${(/* @__PURE__ */ new Date()).toISOString()}
24518
+ writeFileSync28(sentinel, `${(/* @__PURE__ */ new Date()).toISOString()}
24097
24519
  `);
24098
24520
  process.stdout.write("crew reload: requested \u2014 the daemon finishes its in-flight pass, then relaunches with the new config\n");
24099
24521
  if (flag("now") && control?.stopCommand && control.startCommand) {
24100
24522
  for (const cmd of [control.stopCommand, control.startCommand]) {
24101
- const res = spawnSync7(cmd[0], cmd.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
24523
+ const res = spawnSync8(cmd[0], cmd.slice(1), { cwd: CREW_HOME, encoding: "utf8" });
24102
24524
  if (cmd === control.startCommand && (res.error || res.status !== 0)) {
24103
24525
  process.stderr.write(`crew reload: ${cmd.join(" ")} exited ${res.status ?? "error"}
24104
24526
  `);
@@ -24144,7 +24566,7 @@ ${where.join("\n")}`);
24144
24566
  }
24145
24567
  case "log": {
24146
24568
  try {
24147
- const text = readFileSync35(cfg.ship.logFile, "utf8").split("\n");
24569
+ const text = readFileSync36(cfg.ship.logFile, "utf8").split("\n");
24148
24570
  process.stdout.write(`${text.slice(-(Number(value("tail")) || 40)).join("\n")}
24149
24571
  `);
24150
24572
  } catch {
@@ -24167,7 +24589,7 @@ ${where.join("\n")}`);
24167
24589
  };
24168
24590
  if (!route2.resolved) fail(`route ${route2.route} is not connected \u2014 run \`crew connect\` first`);
24169
24591
  const tracker = new Tracker(route2, cfg.ship);
24170
- const configText = readFileSync35(cfg.configFile, "utf8");
24592
+ const configText = readFileSync36(cfg.configFile, "utf8");
24171
24593
  const cfgBase = dirname22(cfg.configFile);
24172
24594
  const configured2 = configuredRepos(configText, route2.route, cfgBase);
24173
24595
  const dir = configured2.find((r) => r.name === SAMPLE_REPO)?.dir ?? sampleDir(configured2.map((r) => r.dir), route2.reposBasePath, route2.route.split("/")[0]);
@@ -24196,7 +24618,7 @@ ${where.join("\n")}`);
24196
24618
  }
24197
24619
  if (configured2.some((r) => r.dir === dir)) {
24198
24620
  try {
24199
- writeFileSync27(cfg.configFile, planRepoRemove(configText, cfgBase, route2.route, dir).text);
24621
+ writeFileSync28(cfg.configFile, planRepoRemove(configText, cfgBase, route2.route, dir).text);
24200
24622
  } catch (e) {
24201
24623
  if (e instanceof ReposError) fail(e.message);
24202
24624
  throw e;
@@ -24204,31 +24626,31 @@ ${where.join("\n")}`);
24204
24626
  }
24205
24627
  if (row) {
24206
24628
  await tracker.deleteRepoRow(row.id);
24207
- if (existsSync40(resolvedFile)) {
24208
- const raw = JSON.parse(readFileSync35(resolvedFile, "utf8"));
24629
+ if (existsSync41(resolvedFile)) {
24630
+ const raw = JSON.parse(readFileSync36(resolvedFile, "utf8"));
24209
24631
  if (raw.repoNames) delete raw.repoNames[row.id];
24210
24632
  if (raw.repoRemotes) delete raw.repoRemotes[row.id];
24211
- writeFileSync27(resolvedFile, `${JSON.stringify(raw, null, 2)}
24633
+ writeFileSync28(resolvedFile, `${JSON.stringify(raw, null, 2)}
24212
24634
  `);
24213
24635
  }
24214
24636
  }
24215
- if (existsSync40(join44(dir, ".git")) && basename13(dir) === SAMPLE_REPO) rmSync19(dir, { recursive: true, force: true });
24637
+ if (existsSync41(join44(dir, ".git")) && basename13(dir) === SAMPLE_REPO) rmSync19(dir, { recursive: true, force: true });
24216
24638
  say("removed", { dir, closed }, `removed the sample repo${closed.length ? `, closed ${closed.join(", ")}` : ""}`);
24217
24639
  break;
24218
24640
  }
24219
24641
  const projectId = route2.resolved.areaId ?? resolveRepoProject(await tracker.projectRows(), value("project"));
24220
- const fresh = !existsSync40(join44(dir, ".git"));
24221
- if (existsSync40(dir) && fresh && readdirSync18(dir).length > 0) fail(`${dir} exists and is not a git checkout \u2014 move it aside`);
24642
+ const fresh = !existsSync41(join44(dir, ".git"));
24643
+ if (existsSync41(dir) && fresh && readdirSync18(dir).length > 0) fail(`${dir} exists and is not a git checkout \u2014 move it aside`);
24222
24644
  if (dryRun) {
24223
24645
  say("dry-run", { dir, newCheckout: fresh, repoRow: row?.id ?? null }, `would ${fresh ? "create" : "reuse"} ${dir}${row ? "" : ", create the Repos row"}, and file one ${tracker.contract.statuses.approved} ticket`);
24224
24646
  break;
24225
24647
  }
24226
24648
  if (fresh) {
24227
24649
  mkdirSync26(dir, { recursive: true });
24228
- writeFileSync27(join44(dir, "README.md"), SAMPLE_README);
24229
- writeFileSync27(join44(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
24650
+ writeFileSync28(join44(dir, "README.md"), SAMPLE_README);
24651
+ writeFileSync28(join44(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
24230
24652
  const g = (...a) => {
24231
- const r = spawnSync7("git", a, { cwd: dir, encoding: "utf8" });
24653
+ const r = spawnSync8("git", a, { cwd: dir, encoding: "utf8" });
24232
24654
  if (r.status !== 0) fail(`git ${a[0]} failed in ${dir}: ${(r.stderr || r.stdout).trim()}`);
24233
24655
  };
24234
24656
  g("init", "-b", "main");
@@ -24244,17 +24666,17 @@ ${where.join("\n")}`);
24244
24666
  spec: { platform: "unix", releaseMode: "integrate", ciProvider: "none" }
24245
24667
  });
24246
24668
  repoId = (await tracker.createRepoRow(payload)).id;
24247
- if (existsSync40(resolvedFile)) {
24248
- const raw = JSON.parse(readFileSync35(resolvedFile, "utf8"));
24669
+ if (existsSync41(resolvedFile)) {
24670
+ const raw = JSON.parse(readFileSync36(resolvedFile, "utf8"));
24249
24671
  raw.repoNames = { ...raw.repoNames, [repoId]: SAMPLE_REPO };
24250
24672
  raw.repoRemotes = { ...raw.repoRemotes, [repoId]: SAMPLE_REMOTE };
24251
- writeFileSync27(resolvedFile, `${JSON.stringify(raw, null, 2)}
24673
+ writeFileSync28(resolvedFile, `${JSON.stringify(raw, null, 2)}
24252
24674
  `);
24253
24675
  }
24254
24676
  }
24255
24677
  if (!configured2.some((r) => r.dir === dir)) {
24256
24678
  try {
24257
- writeFileSync27(cfg.configFile, planRepoAdd({ text: configText, base: cfgBase, route: route2.route, path: dir, name: SAMPLE_REPO }).text);
24679
+ writeFileSync28(cfg.configFile, planRepoAdd({ text: configText, base: cfgBase, route: route2.route, path: dir, name: SAMPLE_REPO }).text);
24258
24680
  } catch (e) {
24259
24681
  if (e instanceof ReposError) fail(e.message);
24260
24682
  throw e;
@@ -24427,7 +24849,9 @@ ${where.join("\n")}`);
24427
24849
  attention: openShipAttention(state),
24428
24850
  // Additive (CREW-2495): this ship's Ships-row name and whether it has drifted from `name`.
24429
24851
  rowName: nameSync.rowName ?? null,
24430
- nameDrift: nameSync.drift
24852
+ nameDrift: nameSync.drift,
24853
+ // Additive (CREW-2803): the relay URL this route's tunnel is reachable at, or null.
24854
+ relayUrl: routeRelayUrl(route2)
24431
24855
  },
24432
24856
  // Additive (CREW-1402): what the operator owes. `version` stays 1.
24433
24857
  todo: todo.map((i) => ({ ticket: displayKey(i.ticket), title: i.ticket.title ?? null, reasons: i.reasons })),
@@ -24517,7 +24941,8 @@ ${owed.map((l) => ` ${l}
24517
24941
  route: ${route2.route} -> ${route2.dir}
24518
24942
  enabled: ${route2.enabled}
24519
24943
  periscopes: ${periscopeSummary(cfg)}
24520
- ` + periscopes.map((p) => `periscope: ${p.ticket} ${p.repo} ${p.url} ${p.state}${p.serving ? "" : " (not serving)"} expires ${p.expiresAt}
24944
+ ` + (routeRelayUrl(route2) ? `relay: ${routeRelayUrl(route2)}
24945
+ ` : "") + periscopes.map((p) => `periscope: ${p.ticket} ${p.repo} ${p.url} ${p.state}${p.serving ? "" : " (not serving)"} expires ${p.expiresAt}
24521
24946
  `).join("") + `crew: ${state.isPaused() ? "paused" : "active"}
24522
24947
  release: ${state.releaseSkipReason() ? "paused" : "active"}
24523
24948
  watermark: ${state.watermark()}
@@ -24589,7 +25014,7 @@ watermark: ${state.watermark()}
24589
25014
  }
24590
25015
  for (const r of repos) {
24591
25016
  const remote = r.config.branch.remote;
24592
- if (existsSync40(r.dir)) {
25017
+ if (existsSync41(r.dir)) {
24593
25018
  pushAuth.push({
24594
25019
  route: c.route,
24595
25020
  repo: r.name,
@@ -24674,7 +25099,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
24674
25099
  }
24675
25100
  try {
24676
25101
  const evFile = eventFileFor(cfg.ship.stateDir);
24677
- const evSize = existsSync40(evFile) ? statSync14(evFile).size : 0;
25102
+ const evSize = existsSync41(evFile) ? statSync14(evFile).size : 0;
24678
25103
  process.stdout.write(
24679
25104
  evSize > cfg.ship.eventsRotateBytes * 4 ? `events log: WARN ${evFile} is ${Math.round(evSize / 1048576)} MB (> 4x eventsRotateBytes); run \`crew reap\` to rotate it
24680
25105
  ` : "events log: ok\n"
@@ -24682,10 +25107,10 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
24682
25107
  } catch {
24683
25108
  }
24684
25109
  if (host !== "windows") {
24685
- const unitPlan = planInstall(cfg.ship, CREW_HOME, host, detectSystemd(), "run");
25110
+ const unitPlan = planInstall(cfg.ship, CREW_HOME, host, detectSystemd(), "run", installContext());
24686
25111
  const abandons = unitAbandonsAgents(unitPlan, (p) => {
24687
25112
  try {
24688
- return readFileSync35(p, "utf8");
25113
+ return readFileSync36(p, "utf8");
24689
25114
  } catch {
24690
25115
  return null;
24691
25116
  }
@@ -25109,6 +25534,21 @@ ${foreign.join("\n")}
25109
25534
  );
25110
25535
  }
25111
25536
  }
25537
+ if (cfg.ship.relayHost && !flag("offline")) {
25538
+ const relayUrl = routeRelayUrl(route2);
25539
+ if (!relayUrl) {
25540
+ process.stdout.write("relay control: no tunnel URL yet (the first `crew passengers` cycle opens it)\n");
25541
+ } else {
25542
+ let key;
25543
+ try {
25544
+ key = resolveApiKey(route2);
25545
+ } catch {
25546
+ key = void 0;
25547
+ }
25548
+ process.stdout.write(`${key ? describeRelayProbe(relayUrl, await probeRelayControl(relayUrl, key, cfg.ship.userAgent)) : "relay control: could not check (no API key for this route)"}
25549
+ `);
25550
+ }
25551
+ }
25112
25552
  for (const l of doctorSelftestLines(readSelftest(cfg.ship.stateDir), shipToolchains(cfg.ship).map((t) => t.name))) {
25113
25553
  process.stdout.write(`${l}
25114
25554
  `);
@@ -25130,6 +25570,7 @@ ${foreign.join("\n")}
25130
25570
  }
25131
25571
  var requireArmed2;
25132
25572
  var parseTicketNumber2;
25573
+ var routeRelayUrl2;
25133
25574
  var passengerApiKeys2;
25134
25575
  var endpointApiFor2;
25135
25576
  var endpointReconcileDeps2;