@tablation/crew 0.98.0 → 0.99.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +147 -19
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -83,6 +83,25 @@ function routePeriscopeProblems(o) {
83
83
  function previewHostFinding(message) {
84
84
  return /does not resolve/.test(message) ? { severity: "attention", kind: "preview_host_unresolvable", message } : { severity: "blocker", kind: "preview_host", message };
85
85
  }
86
+ var refId = (v) => typeof v === "string" ? v : v && typeof v === "object" && typeof v.id === "string" ? v.id : void 0;
87
+ function duplicateLivePeriscopes(rows, tagOf, shipName2, route2) {
88
+ const byTicket = /* @__PURE__ */ new Map();
89
+ for (const r of rows) {
90
+ const t = refId(r.issue_id);
91
+ if (t) byTicket.set(t, [...byTicket.get(t) ?? [], r]);
92
+ }
93
+ const out = [];
94
+ for (const [t, rs] of byTicket) {
95
+ if (rs.length < 2) continue;
96
+ out.push({
97
+ severity: "attention",
98
+ kind: "periscope_duplicate_live",
99
+ ...route2 ? { route: route2 } : {},
100
+ message: `${tagOf(t)} has ${rs.length} live Periscopes rows (${rs.map((r) => `${r.id} on ${shipName2(r.ship_id)}${r.url ? ` ${r.url}` : ""}`).join("; ")}) \u2014 the periscope_url rollup assumes one; end all but one with \`crew periscope down\``
101
+ });
102
+ }
103
+ return out;
104
+ }
86
105
 
87
106
  // src/periscope-state.ts
88
107
  import { existsSync as existsSync2, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs";
@@ -1602,6 +1621,28 @@ import { dirname as dirname3, join as join9 } from "path";
1602
1621
  var ForgeError = class extends Error {
1603
1622
  };
1604
1623
  var DEFAULT_FORGE_USERNAME = "x-access-token";
1624
+ var DEFAULT_FORGE_HOST = "github.com";
1625
+ var FORGE_DESCRIPTORS = {
1626
+ github: {
1627
+ kind: "github",
1628
+ httpsUrl: (host, remote) => `https://${host}/${remote}.git`,
1629
+ sshUrl: (host, remote) => `git@${host}:${remote}.git`,
1630
+ credentialUsername: DEFAULT_FORGE_USERNAME
1631
+ },
1632
+ gitlab: {
1633
+ kind: "gitlab",
1634
+ httpsUrl: (host, remote) => `https://${host}/${remote}.git`,
1635
+ sshUrl: (host, remote) => `git@${host}:${remote}.git`,
1636
+ credentialUsername: "oauth2"
1637
+ }
1638
+ };
1639
+ function normalizeForgeHost(host) {
1640
+ const h = host?.trim().toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, "").replace(/\/+$/, "");
1641
+ return h || DEFAULT_FORGE_HOST;
1642
+ }
1643
+ function forgeDescriptorFor(host) {
1644
+ return normalizeForgeHost(host).includes("gitlab") ? FORGE_DESCRIPTORS.gitlab : FORGE_DESCRIPTORS.github;
1645
+ }
1605
1646
  var DEFAULT_KEYCHAIN_SERVICE = "crew-forge";
1606
1647
  function defaultTokenFilePath(stateDir, host) {
1607
1648
  return join9(stateDir, "forges", `${host.replace(/[^a-z0-9.-]/gi, "_")}.token`);
@@ -1814,7 +1855,7 @@ function parseForges(raw) {
1814
1855
  throw new ForgeError(`${where}.token.source must be one of: ${Object.keys(TOKEN_SOURCES).join(", ")}`);
1815
1856
  }
1816
1857
  out[host.toLowerCase()] = {
1817
- username: typeof e.username === "string" && e.username ? e.username : DEFAULT_FORGE_USERNAME,
1858
+ username: typeof e.username === "string" && e.username ? e.username : forgeDescriptorFor(host).credentialUsername,
1818
1859
  token
1819
1860
  };
1820
1861
  }
@@ -2932,6 +2973,7 @@ function parseResolved(raw, m, where) {
2932
2973
  reposModelId: raw.reposModelId,
2933
2974
  repoNames: raw.repoNames,
2934
2975
  repoRemotes: raw.repoRemotes,
2976
+ repoForgeHosts: raw.repoForgeHosts,
2935
2977
  models: {
2936
2978
  issues: m.req(raw.models?.issues, `${where}.resolved.models.issues`),
2937
2979
  comments: m.req(raw.models?.comments, `${where}.resolved.models.comments`),
@@ -3437,23 +3479,29 @@ function reposOf(r) {
3437
3479
  if (r.dirIsCloneBase) {
3438
3480
  const [workspace2] = splitRoute(r.route);
3439
3481
  const remotes2 = r.resolved?.repoRemotes ?? {};
3482
+ const hosts = r.resolved?.repoForgeHosts ?? {};
3440
3483
  return Object.entries(r.resolved?.repoNames ?? {}).map(([id, name]) => ({
3441
3484
  name,
3442
3485
  dir: defaultRepoDir(r.reposBasePath, workspace2, name),
3443
- remote: remotes2[id]
3486
+ remote: remotes2[id],
3487
+ ...hosts[id] ? { forgeHost: hosts[id] } : {}
3444
3488
  }));
3445
3489
  }
3446
3490
  if (trackerNames.length <= 1) return [{ name: basename3(r.dir), dir: r.dir }];
3447
3491
  const remotes = r.resolved?.repoRemotes ?? {};
3448
3492
  const remoteByName = {};
3493
+ const hostByName = {};
3449
3494
  for (const [id, name] of Object.entries(r.resolved.repoNames)) {
3450
3495
  if (remotes[id]) remoteByName[name] = remotes[id];
3496
+ const h = r.resolved?.repoForgeHosts?.[id];
3497
+ if (h) hostByName[name] = h;
3451
3498
  }
3452
3499
  const [workspace] = splitRoute(r.route);
3453
3500
  return trackerNames.map((name) => ({
3454
3501
  name,
3455
3502
  dir: defaultRepoDir(r.reposBasePath, workspace, name),
3456
- remote: remoteByName[name]
3503
+ remote: remoteByName[name],
3504
+ ...hostByName[name] ? { forgeHost: hostByName[name] } : {}
3457
3505
  }));
3458
3506
  }
3459
3507
  function passengerRepoTargets(r) {
@@ -3469,10 +3517,12 @@ function passengerCheckoutTargets(r) {
3469
3517
  const remotes = r.resolved?.repoRemotes ?? {};
3470
3518
  return passengerRepoTargets(r).map((t) => {
3471
3519
  const id = repoIdForName(r, t.name);
3520
+ const forgeHost = t.forgeHost ?? (id ? r.resolved?.repoForgeHosts?.[id] : void 0);
3472
3521
  return {
3473
3522
  name: t.name,
3474
3523
  dir: passengerCheckoutDir(r.reposBasePath, workspace, t.name),
3475
- remote: t.remote ?? (id ? remotes[id] : void 0)
3524
+ remote: t.remote ?? (id ? remotes[id] : void 0),
3525
+ ...forgeHost ? { forgeHost } : {}
3476
3526
  };
3477
3527
  });
3478
3528
  }
@@ -4780,8 +4830,10 @@ function setForgeTokenHosts(hosts) {
4780
4830
  }
4781
4831
  function cloneUrlFor(remote, opts = {}) {
4782
4832
  const origin = opts.originUrl?.trim() ?? "";
4783
- const scheme = tokenHosts.has("github.com") ? "https" : /^https?:\/\//i.test(origin) ? "https" : /^(git@|ssh:\/\/)/i.test(origin) ? "ssh" : opts.scheme ?? "ssh";
4784
- return scheme === "https" ? `https://github.com/${remote}.git` : `git@github.com:${remote}.git`;
4833
+ const host = normalizeForgeHost(opts.host);
4834
+ const scheme = tokenHosts.has(host) ? "https" : /^https?:\/\//i.test(origin) ? "https" : /^(git@|ssh:\/\/)/i.test(origin) ? "ssh" : opts.scheme ?? "ssh";
4835
+ const forge = forgeDescriptorFor(host);
4836
+ return scheme === "https" ? forge.httpsUrl(host, remote) : forge.sshUrl(host, remote);
4785
4837
  }
4786
4838
  function originUrlOf(dir, remoteName) {
4787
4839
  try {
@@ -5244,7 +5296,7 @@ function probePushAuth(o, run2 = realGit, baseEnv = process.env) {
5244
5296
  const raw = (err3.stderr ? String(err3.stderr) : err3.message).trim().split("\n").filter(Boolean).slice(-2).join(" / ");
5245
5297
  const detail = raw || "push dry-run failed";
5246
5298
  const f = o.forge;
5247
- if (!f) return { state: "failed", detail, hint: pushAuthHint(o.remoteUrl) };
5299
+ if (!f) return { state: "failed", detail: hostOfRemote(o.remoteUrl) ? `${hostOfRemote(o.remoteUrl)}: ${detail}` : detail, hint: pushAuthHint(o.remoteUrl) };
5248
5300
  if (!f.hasToken) {
5249
5301
  return {
5250
5302
  state: "failed",
@@ -7639,6 +7691,30 @@ var Tracker = class {
7639
7691
  limit: 200
7640
7692
  });
7641
7693
  }
7694
+ /**
7695
+ * Every `live` Periscopes row on any ship (CREW-2871), for the one-per-issue
7696
+ * guard and doctor. Empty without the table.
7697
+ */
7698
+ async livePeriscopesAllShips() {
7699
+ const modelId = this.route.resolved?.periscopesModelId;
7700
+ if (!modelId) return [];
7701
+ return this.client.records.list(modelId, {
7702
+ filters: encodeFilters([{ columnName: "state", operator: "EQ", value: "live" }]),
7703
+ limit: 500
7704
+ });
7705
+ }
7706
+ /** The `live` Periscopes rows of one ticket, on any ship (CREW-2871). */
7707
+ async livePeriscopesForTicket(ticketRecordId) {
7708
+ const modelId = this.route.resolved?.periscopesModelId;
7709
+ if (!modelId) return [];
7710
+ return this.client.records.list(modelId, {
7711
+ filters: encodeFilters([
7712
+ { columnName: "issue_id", operator: "EQ", value: ticketRecordId },
7713
+ { columnName: "state", operator: "EQ", value: "live" }
7714
+ ]),
7715
+ limit: 50
7716
+ });
7717
+ }
7642
7718
  /** Patches one Periscopes row (EPIC-077). */
7643
7719
  async updatePeriscope(rowId, fields) {
7644
7720
  const modelId = this.route.resolved?.periscopesModelId;
@@ -7663,12 +7739,12 @@ var Tracker = class {
7663
7739
  const conds = [{ columnName: "ship_id", operator: "EQ", value: shipId }];
7664
7740
  if (states?.length) conds.push({ columnName: "state", operator: "IN", value: states });
7665
7741
  const rows = await this.client.records.list(modelId, { filters: encodeFilters(conds), limit: 200 });
7666
- const refId = (v) => typeof v === "string" ? v : v && typeof v === "object" && typeof v.id === "string" ? v.id : void 0;
7667
- const ids = [...new Set(rows.map((r) => refId(r.issue_id)).filter((x) => !!x))];
7742
+ const refId2 = (v) => typeof v === "string" ? v : v && typeof v === "object" && typeof v.id === "string" ? v.id : void 0;
7743
+ const ids = [...new Set(rows.map((r) => refId2(r.issue_id)).filter((x) => !!x))];
7668
7744
  const tickets = new Map((await this.ticketsByIds(ids)).map((t) => [t.id, t]));
7669
7745
  const out = [];
7670
7746
  for (const row of rows) {
7671
- const t = tickets.get(refId(row.issue_id) ?? "");
7747
+ const t = tickets.get(refId2(row.issue_id) ?? "");
7672
7748
  if (t) out.push({ row, ticket: t });
7673
7749
  }
7674
7750
  return out.sort((a, b) => String(b.row.started_at ?? "").localeCompare(String(a.row.started_at ?? "")));
@@ -13156,7 +13232,8 @@ function planRepoClone(i) {
13156
13232
  const basePath = String(m.get("reposBasePath") ?? (isMap(ship) ? ship.get("reposBasePath") : void 0) ?? "~/Crew");
13157
13233
  const target = i.dir ? expandDir(i.dir, i.base) : join29(expandDir(basePath, i.base), i.route.split("/")[0], name);
13158
13234
  const add = planRepoAdd({ text: i.text, base: i.base, route: i.route, path: target, origin: remote, resolved: i.resolved, name, isCheckout: i.isCheckout });
13159
- return { ...add, remote, target };
13235
+ const forgeHost = i.resolved?.repoForgeHosts?.[id];
13236
+ return { ...add, remote, target, ...forgeHost ? { forgeHost } : {} };
13160
13237
  }
13161
13238
 
13162
13239
  // src/doctor-fix.ts
@@ -15282,7 +15359,7 @@ function runForge(a, io) {
15282
15359
  const doc = parseDocument4(io.readFile(io.configFile));
15283
15360
  doc.setIn(["ship", "forges", host, "token"], tok);
15284
15361
  io.writeFile(io.configFile, doc.toString());
15285
- io.forges = { ...io.forges ?? {}, [host]: { username: "x-access-token", token: tok } };
15362
+ io.forges = { ...io.forges ?? {}, [host]: { username: forgeDescriptorFor(host).credentialUsername, token: tok } };
15286
15363
  existing = forgeFor(io.forges, host);
15287
15364
  }
15288
15365
  if (existing.entry.token.source === "env") {
@@ -16279,6 +16356,10 @@ async function discover(o) {
16279
16356
  repos.filter((r) => r.remote).map((r) => [r.id, r.remote])
16280
16357
  );
16281
16358
  if (Object.keys(remotes).length > 0) out.repoRemotes = remotes;
16359
+ const forgeHosts = Object.fromEntries(
16360
+ repos.filter((r) => r.forge_host?.trim()).map((r) => [r.id, r.forge_host.trim().toLowerCase()])
16361
+ );
16362
+ if (Object.keys(forgeHosts).length > 0) out.repoForgeHosts = forgeHosts;
16282
16363
  }
16283
16364
  if (o.area) {
16284
16365
  if (!out.areaModelId) problems.push(`--area given but this project has no "Projects" table`);
@@ -17672,6 +17753,13 @@ import { existsSync as existsSync35, mkdirSync as mkdirSync21, readFileSync as r
17672
17753
  import { join as join37 } from "path";
17673
17754
  var PeriscopeError = class extends Error {
17674
17755
  };
17756
+ var LivePeriscopeError = class extends PeriscopeError {
17757
+ data;
17758
+ constructor(tag, number, live) {
17759
+ super(`${tag} already has a live periscope on ${live.ship}: ${live.url} (worktree ${live.worktree}) \u2014 run \`crew periscope down ${number} --keep|--discard\` on ${live.ship}, or resume it there with \`crew periscope up --resume ${number}\``);
17760
+ this.data = { refused: "live_periscope_exists", ...live };
17761
+ }
17762
+ };
17675
17763
  var priorDir = (stateDir) => join37(stateDir, "periscope-priors");
17676
17764
  var priorPath = (stateDir, ticketRecordId) => join37(priorDir(stateDir), `${ticketRecordId}.json`);
17677
17765
  function removePeriscopePrior(stateDir, ticketRecordId) {
@@ -17738,6 +17826,10 @@ function periscopeCaps(l) {
17738
17826
  idleMinutes: l.periscopeIdleMinutes ?? DEFAULT_PERISCOPE_IDLE_MINUTES2
17739
17827
  };
17740
17828
  }
17829
+ async function assertNoLivePeriscope(e, t, number) {
17830
+ const live = (await e.liveRows(t.id))[0];
17831
+ if (live) throw new LivePeriscopeError(t.tag, number, live);
17832
+ }
17741
17833
  async function existingRefusal(e, t) {
17742
17834
  if (t.closed) return `${t.tag} is ${t.status}; a closed ticket cannot be periscoped`;
17743
17835
  if (e.repoId && t.repoId !== e.repoId) return `${t.tag} belongs to a different repository than ${e.repoName}`;
@@ -17796,6 +17888,7 @@ async function runPeriscopeUp(d, args) {
17796
17888
  if (!ex) throw new PeriscopeError("--issue is not supported here");
17797
17889
  prior = await ex.load(args.issue);
17798
17890
  if (!prior) throw new PeriscopeError(`no ticket numbered ${args.issue} on this route`);
17891
+ await assertNoLivePeriscope(ex, prior, args.issue);
17799
17892
  const refused = await existingRefusal(ex, prior);
17800
17893
  if (refused) throw new PeriscopeError(refused);
17801
17894
  const unsafe = d.baseProblem();
@@ -18432,7 +18525,8 @@ function passengerCheckoutSyncSpecs(cfg, workspaceId) {
18432
18525
  gitRemoteName: effective.branch.remote,
18433
18526
  base: effective.branch.base,
18434
18527
  originUrl: ordinary ? originUrlOf(ordinary.dir, effective.branch.remote) : null,
18435
- cloneScheme: cfg.ship.cloneScheme
18528
+ cloneScheme: cfg.ship.cloneScheme,
18529
+ ...target.forgeHost ? { forgeHost: target.forgeHost } : {}
18436
18530
  });
18437
18531
  }
18438
18532
  }
@@ -18442,7 +18536,7 @@ function syncAllPassengerCheckouts(cfg) {
18442
18536
  return passengerCheckoutSyncSpecs(cfg).map((spec) => ({
18443
18537
  name: spec.name,
18444
18538
  workspaceId: spec.workspaceId,
18445
- outcome: syncPassengerCheckout(spec.dir, spec.remote, spec.gitRemoteName, spec.base, { originUrl: spec.originUrl, scheme: spec.cloneScheme })
18539
+ outcome: syncPassengerCheckout(spec.dir, spec.remote, spec.gitRemoteName, spec.base, { originUrl: spec.originUrl, scheme: spec.cloneScheme, host: spec.forgeHost })
18446
18540
  }));
18447
18541
  }
18448
18542
 
@@ -18815,7 +18909,7 @@ function createSyncDaemonServer(targets, secret) {
18815
18909
  try {
18816
18910
  const synced = targets.map((t) => ({
18817
18911
  name: t.name,
18818
- outcome: syncPassengerCheckout(t.dir, t.remote, t.gitRemoteName, t.base, { originUrl: t.originUrl, scheme: t.cloneScheme })
18912
+ outcome: syncPassengerCheckout(t.dir, t.remote, t.gitRemoteName, t.base, { originUrl: t.originUrl, scheme: t.cloneScheme, host: t.forgeHost })
18819
18913
  }));
18820
18914
  res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ synced }));
18821
18915
  } catch (error) {
@@ -19511,7 +19605,7 @@ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
19511
19605
  const target = repoTargetFor(route2, result.ticket.repo_id);
19512
19606
  ticketRepo = target?.name;
19513
19607
  try {
19514
- if (target && ensureRepoCheckout(target.dir, target.remote, { scheme: ship.cloneScheme })) {
19608
+ if (target && ensureRepoCheckout(target.dir, target.remote, { scheme: ship.cloneScheme, host: target.forgeHost })) {
19515
19609
  emit.emit(`cloned ${target.name} into ${target.dir}`, { step: "select", role });
19516
19610
  }
19517
19611
  } catch (e) {
@@ -20272,6 +20366,25 @@ var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname22(process.execPath)
20272
20366
  var RELEASE_LOCK_TTL_MS = 20 * 60 * 1e3;
20273
20367
  var CYCLE_SECONDS2 = 120;
20274
20368
  var STALE_CYCLES = 3;
20369
+ async function duplicateLivePeriscopeProblems(route2, ship) {
20370
+ try {
20371
+ const tr = new Tracker(route2, ship);
20372
+ const rows = await tr.livePeriscopesAllShips();
20373
+ const refOf = (v) => typeof v === "string" ? v : v?.id;
20374
+ const counts = /* @__PURE__ */ new Map();
20375
+ for (const r of rows) {
20376
+ const t = refOf(r.issue_id);
20377
+ if (t) counts.set(t, (counts.get(t) ?? 0) + 1);
20378
+ }
20379
+ const dupIds = [...counts].filter(([, n]) => n > 1).map(([t]) => t);
20380
+ if (dupIds.length === 0) return [];
20381
+ const tags = new Map((await tr.ticketsByIds(dupIds)).map((t) => [t.id, t.issue_tag ?? t.issue_id]));
20382
+ const ships = new Map((await tr.shipRows()).map((x) => [x.id, x.name ?? x.id]));
20383
+ return duplicateLivePeriscopes(rows, (id) => tags.get(id) ?? id, (id) => id ? ships.get(id) ?? id : "an unknown ship", route2.route);
20384
+ } catch {
20385
+ return [];
20386
+ }
20387
+ }
20275
20388
  function renderShipLine(s, issueLabelById) {
20276
20389
  const name = s.name ?? "(unnamed)";
20277
20390
  if (!s.last_seen) return `${name.padEnd(20)} never seen`;
@@ -21828,7 +21941,7 @@ ${cu.tail.trimEnd()}
21828
21941
  const target = repoTargetFor(w.route, result.ticket.repo_id);
21829
21942
  fleetTicketRepo = target?.name;
21830
21943
  try {
21831
- if (target && ensureRepoCheckout(target.dir, target.remote, { scheme: cfg.ship.cloneScheme })) {
21944
+ if (target && ensureRepoCheckout(target.dir, target.remote, { scheme: cfg.ship.cloneScheme, host: target.forgeHost })) {
21832
21945
  wemit.emit(`cloned ${target.name} into ${target.dir}`, { step: "select", role: w.role });
21833
21946
  }
21834
21947
  } catch (e) {
@@ -22045,7 +22158,7 @@ ${cu.tail.trimEnd()}
22045
22158
  const target = repoTargetFor(route2, result.ticket.repo_id);
22046
22159
  ticketRepo = target?.name;
22047
22160
  try {
22048
- if (target && ensureRepoCheckout(target.dir, target.remote, { scheme: cfg.ship.cloneScheme })) {
22161
+ if (target && ensureRepoCheckout(target.dir, target.remote, { scheme: cfg.ship.cloneScheme, host: target.forgeHost })) {
22049
22162
  emit.emit(`cloned ${target.name} into ${target.dir}`, { step: "select", role: current });
22050
22163
  }
22051
22164
  } catch (e) {
@@ -22785,7 +22898,7 @@ ${cu.tail.trimEnd()}
22785
22898
  }
22786
22899
  say("cloning", { remote: plan2.remote, target: plan2.target }, `cloning ${plan2.remote} into ${plan2.target}`);
22787
22900
  try {
22788
- ensureRepoCheckout(plan2.target, plan2.remote, { scheme: cfg.ship.cloneScheme ?? "https" });
22901
+ ensureRepoCheckout(plan2.target, plan2.remote, { scheme: cfg.ship.cloneScheme ?? "https", host: plan2.forgeHost });
22789
22902
  } catch (e) {
22790
22903
  if (!(e instanceof GitError)) throw e;
22791
22904
  const authHint = /authentication failed|could not read (username|password)|permission denied|terminal prompts disabled|repository not found/i.test(e.message) ? "\n hint: git could not authenticate \u2014 run `gh auth login` (or add an SSH key and set ship.cloneScheme: ssh), then re-run" : "";
@@ -23375,6 +23488,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
23375
23488
  reposModelId: found.reposModelId,
23376
23489
  repoNames: found.repoNames,
23377
23490
  repoRemotes: found.repoRemotes,
23491
+ repoForgeHosts: found.repoForgeHosts,
23378
23492
  models: found.models,
23379
23493
  seats: found.seats,
23380
23494
  holds: found.holds.map((h) => ({ id: h.id, role: h.name })),
@@ -24331,6 +24445,10 @@ ${where.join("\n")}`);
24331
24445
  const row = crewP.find((r) => r.id === assigneeId);
24332
24446
  return row?.agent_id ? row.name ?? assigneeId : void 0;
24333
24447
  },
24448
+ liveRows: async (ticketRecordId) => {
24449
+ const [rows, ships] = [await trackerP.livePeriscopesForTicket(ticketRecordId), await trackerP.shipRows()];
24450
+ return rows.map((r) => ({ ship: ships.find((x) => x.id === r.ship_id)?.name ?? r.ship_id ?? "an unknown ship", url: r.url ?? "", worktree: r.worktree }));
24451
+ },
24334
24452
  hasLiveRun: (ticketRecordId) => liveRuns(cfg.ship.stateDir).some((r) => r.ticketRecordId === ticketRecordId),
24335
24453
  hold: async (id) => {
24336
24454
  const auth = await trackerP.authorshipProbe();
@@ -24354,6 +24472,7 @@ ${where.join("\n")}`);
24354
24472
  if (a.issue) {
24355
24473
  const t = await deps.existing.load(a.issue);
24356
24474
  if (!t) throw new PeriscopeError(`no ticket numbered ${a.issue} on this route`);
24475
+ await assertNoLivePeriscope(deps.existing, t, a.issue);
24357
24476
  const refused = await existingRefusal(deps.existing, t);
24358
24477
  if (refused) throw new PeriscopeError(refused);
24359
24478
  say("dry-run", `would hold ${t.tag} (status ${t.status} stays) for ${pairId}, cut or reuse its worktree for ${a.repo}, run setup${a.isolate ? ", isolate" : ""}, ports, handoff and preview on ${deps.host}, and record a Periscopes row`);
@@ -24377,6 +24496,8 @@ ${where.join("\n")}`);
24377
24496
  } catch (e) {
24378
24497
  if (e instanceof PeriscopeError || e instanceof ConfigError) {
24379
24498
  process.stderr.write(`crew periscope: ${e.message}
24499
+ `);
24500
+ if (e instanceof LivePeriscopeError && jsonP) process.stdout.write(`${JSON.stringify(e.data)}
24380
24501
  `);
24381
24502
  process.exit(1);
24382
24503
  }
@@ -25166,6 +25287,7 @@ watermark: ${state.watermark()}
25166
25287
  const { settled } = await settleRepos(reposOf(route2), (t) => t.name, (t) => resolveOneRepo(route2, t));
25167
25288
  const tablePresent = flag("offline") ? void 0 : (await new Tracker(route2, cfg.ship).periscopesStatus().catch(() => ({ present: true }))).present;
25168
25289
  problems.push(...routePeriscopeProblems({ route: route2.route, hosts: true, repos: settled.map((r) => ({ name: r.name, hasPreviewHook: !!r.config.hooks.preview })), tablePresent }));
25290
+ if (!flag("offline")) problems.push(...await duplicateLivePeriscopeProblems(route2, cfg.ship));
25169
25291
  }
25170
25292
  problems.push(...attentionProblems(openShipAttention(state), problems));
25171
25293
  if (problems.some((p) => p.severity === "blocker")) process.exitCode = 1;
@@ -25311,6 +25433,12 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
25311
25433
  process.stdout.write(`periscopes: ${f.severity.toUpperCase()} ${f.message}
25312
25434
  `);
25313
25435
  }
25436
+ if (!flag("offline")) {
25437
+ for (const f of await duplicateLivePeriscopeProblems(route2, cfg.ship)) {
25438
+ process.stdout.write(`periscopes: ${f.severity.toUpperCase()} ${f.message}
25439
+ `);
25440
+ }
25441
+ }
25314
25442
  }
25315
25443
  process.stdout.write(
25316
25444
  !periscopes.present ? "periscopes: Periscopes table absent \u2014 this workspace cannot host periscopes\n" : periscopes.missing.length === 0 ? `periscopes: Periscopes table present (${periscopes.modelId})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.98.0",
3
+ "version": "0.99.1",
4
4
  "description": "A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.",
5
5
  "type": "module",
6
6
  "license": "MIT",