@tablation/crew 0.88.0 → 0.90.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 +462 -152
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -139,6 +139,89 @@ async function periscopesForStatus(stateDir, probe = portListening) {
139
139
  }));
140
140
  }
141
141
 
142
+ // src/shell-init.ts
143
+ var SHELLS = ["zsh", "bash", "fish"];
144
+ function shellFor(requested, envShell) {
145
+ const pick = (requested ?? envShell ?? "").split("/").pop() ?? "";
146
+ return SHELLS.includes(pick) ? pick : "zsh";
147
+ }
148
+ var POSIX = String.raw`# crew shell-init: "periscope up" lands in the worktree, "periscope down" returns to the checkout.
149
+ periscope() {
150
+ case "${"$"}{1-}" in
151
+ up|start)
152
+ shift
153
+ local _out _wt _url _noopen=
154
+ _out=$(mktemp) || return 1
155
+ case " $* " in *" --no-open "*) _noopen=1 ;; esac
156
+ command crew periscope up --json "$@" 2>&1 >"$_out" \
157
+ | command sed -e 's/^{.*"message":"\([^"]*\)".*}$/ \1/' >&2
158
+ if command grep -q '"event":"done"' "$_out"; then
159
+ _wt=$(command sed -n 's/.*"worktree":"\([^"]*\)".*/\1/p' "$_out" | command tail -n 1)
160
+ _url=$(command sed -n 's/.*"url":"\([^"]*\)".*/\1/p' "$_out" | command tail -n 1)
161
+ fi
162
+ command rm -f "$_out"
163
+ [ -n "$_wt" ] && [ -d "$_wt" ] || return 1
164
+ builtin cd "$_wt" || return 1
165
+ printf '%s\n%s\n' "$_url" "$_wt"
166
+ if [ -z "$_noopen" ] && [ -n "$_url" ]; then
167
+ if command -v open >/dev/null 2>&1; then command open "$_url"
168
+ elif command -v xdg-open >/dev/null 2>&1; then command xdg-open "$_url" >/dev/null 2>&1; fi
169
+ fi
170
+ ;;
171
+ down|end)
172
+ local _common _checkout=
173
+ _common=$(command git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) && _checkout=${"$"}{_common%/.git}
174
+ command crew periscope "$@" || return $?
175
+ if [ -n "$_checkout" ] && [ -d "$_checkout" ]; then builtin cd "$_checkout" || return 1; fi
176
+ ;;
177
+ *)
178
+ command crew periscope "$@"
179
+ ;;
180
+ esac
181
+ }
182
+ `;
183
+ var FISH = String.raw`# crew shell-init: "periscope up" lands in the worktree, "periscope down" returns to the checkout.
184
+ function periscope
185
+ switch "$argv[1]"
186
+ case up start
187
+ set -e argv[1]
188
+ set -l out (mktemp); or return 1
189
+ command crew periscope up --json $argv 2>&1 >$out \
190
+ | command sed -e 's/^{.*"message":"\([^"]*\)".*}$/ \1/' >&2
191
+ set -l wt
192
+ set -l url
193
+ if command grep -q '"event":"done"' $out
194
+ set wt (command sed -n 's/.*"worktree":"\([^"]*\)".*/\1/p' $out | command tail -n 1)
195
+ set url (command sed -n 's/.*"url":"\([^"]*\)".*/\1/p' $out | command tail -n 1)
196
+ end
197
+ command rm -f $out
198
+ test -n "$wt" -a -d "$wt"; or return 1
199
+ builtin cd $wt; or return 1
200
+ printf '%s\n%s\n' $url $wt
201
+ if not contains -- --no-open $argv; and test -n "$url"
202
+ if command -sq open
203
+ command open $url
204
+ else if command -sq xdg-open
205
+ command xdg-open $url >/dev/null 2>&1
206
+ end
207
+ end
208
+ case down end
209
+ set -l checkout
210
+ set -l common (command git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
211
+ and set checkout (string replace -r '/\.git$' '' -- $common)
212
+ command crew periscope $argv; or return $status
213
+ if test -n "$checkout" -a -d "$checkout"
214
+ builtin cd $checkout
215
+ end
216
+ case '*'
217
+ command crew periscope $argv
218
+ end
219
+ end
220
+ `;
221
+ function shellInitScript(shell) {
222
+ return shell === "fish" ? FISH : POSIX;
223
+ }
224
+
142
225
  // src/config.ts
143
226
  import { readFileSync as readFileSync9, existsSync as existsSync8 } from "fs";
144
227
  import { resolve, dirname as dirname3, isAbsolute, join as join10, basename as basename3 } from "path";
@@ -2778,12 +2861,25 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome, t
2778
2861
  return result;
2779
2862
  }
2780
2863
  function routeForDir(cfg, dir) {
2781
- const target = resolve(dir);
2782
- const isInside = (base) => {
2864
+ return locateDir(cfg, dir)?.route;
2865
+ }
2866
+ function locateDir(cfg, dir, commonDirOf) {
2867
+ const within = (base, target2) => {
2783
2868
  const b = resolve(base);
2784
- return target === b || target.startsWith(`${b}/`);
2869
+ return target2 === b || target2.startsWith(`${b}/`);
2785
2870
  };
2786
- return cfg.routes.find((r) => reposOf(r).some((repo) => isInside(repo.dir)));
2871
+ const find = (target2) => {
2872
+ for (const route of cfg.routes) {
2873
+ const repo = reposOf(route).find((x) => within(x.dir, target2));
2874
+ if (repo) return { route, repo, checkout: resolve(repo.dir) };
2875
+ }
2876
+ return void 0;
2877
+ };
2878
+ const target = resolve(dir);
2879
+ const direct = find(target);
2880
+ if (direct) return direct;
2881
+ const common = commonDirOf?.(target);
2882
+ return common ? find(dirname3(resolve(target, common))) : void 0;
2787
2883
  }
2788
2884
  function findRoute(cfg, route) {
2789
2885
  if (!route) {
@@ -9803,7 +9899,7 @@ function doctorSelftestLines(stored, toolchains) {
9803
9899
  }
9804
9900
 
9805
9901
  // src/cli.ts
9806
- import { resolve as resolve5, dirname as dirname20, join as join40, basename as basename12 } from "path";
9902
+ import { resolve as resolve5, dirname as dirname20, join as join41, basename as basename12 } from "path";
9807
9903
  import { fileURLToPath } from "url";
9808
9904
  import { hostname as hostname2, homedir as homedir10 } from "os";
9809
9905
  import { spawnSync as spawnSync7 } from "child_process";
@@ -9843,6 +9939,8 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
9843
9939
  "write",
9844
9940
  "topic",
9845
9941
  "assignee",
9942
+ "issue",
9943
+ "shell",
9846
9944
  "resume"
9847
9945
  ]);
9848
9946
  function positionalArgs(argv2) {
@@ -16532,24 +16630,55 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
16532
16630
  }
16533
16631
 
16534
16632
  // src/cli.ts
16535
- import { realpathSync as realpathSync3, readFileSync as readFileSync32, existsSync as existsSync36, mkdirSync as mkdirSync23, writeFileSync as writeFileSync24, rmSync as rmSync16, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync3, statSync as statSync11, readdirSync as readdirSync17 } from "fs";
16633
+ import { realpathSync as realpathSync3, readFileSync as readFileSync33, existsSync as existsSync37, mkdirSync as mkdirSync24, writeFileSync as writeFileSync25, rmSync as rmSync17, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync3, statSync as statSync11, readdirSync as readdirSync17 } from "fs";
16536
16634
  import { createInterface } from "readline/promises";
16537
16635
 
16538
16636
  // src/periscope-cmd.ts
16637
+ import { existsSync as existsSync32, mkdirSync as mkdirSync19, readFileSync as readFileSync28, rmSync as rmSync15, writeFileSync as writeFileSync21 } from "fs";
16638
+ import { join as join35 } from "path";
16539
16639
  var PeriscopeError = class extends Error {
16540
16640
  };
16641
+ var priorDir = (stateDir) => join35(stateDir, "periscope-priors");
16642
+ var priorPath = (stateDir, ticketRecordId) => join35(priorDir(stateDir), `${ticketRecordId}.json`);
16643
+ function removePeriscopePrior(stateDir, ticketRecordId) {
16644
+ rmSync15(priorPath(stateDir, ticketRecordId), { force: true });
16645
+ }
16646
+ function writePeriscopePrior(stateDir, ticketRecordId, prior) {
16647
+ try {
16648
+ mkdirSync19(priorDir(stateDir), { recursive: true });
16649
+ writeFileSync21(priorPath(stateDir, ticketRecordId), JSON.stringify(prior));
16650
+ } catch {
16651
+ }
16652
+ }
16653
+ function readPeriscopePrior(stateDir, ticketRecordId) {
16654
+ try {
16655
+ const p = priorPath(stateDir, ticketRecordId);
16656
+ return existsSync32(p) ? JSON.parse(readFileSync28(p, "utf8")) : void 0;
16657
+ } catch {
16658
+ return void 0;
16659
+ }
16660
+ }
16541
16661
  var DEFAULT_MAX_PERISCOPES2 = 3;
16542
16662
  var DEFAULT_PERISCOPE_IDLE_MINUTES2 = 120;
16543
16663
  function parsePeriscopeUpArgs(positional2, flags) {
16664
+ const usage2 = "usage: crew periscope up [[route] REPO] [--topic TEXT | --issue NNN] [--isolate] [--host HOST] [--assignee CREW_ROW_ID] [--no-open] [--json]";
16544
16665
  const rest = positional2.slice(2);
16545
- if (rest.length === 0 || rest.length > 2) {
16546
- throw new PeriscopeError("usage: crew periscope up [route] REPO [--topic TEXT] [--isolate] [--host HOST] [--assignee CREW_ROW_ID] [--json]");
16547
- }
16666
+ if (rest.length > 2) throw new PeriscopeError(usage2);
16548
16667
  const topic = flags.topic?.trim();
16668
+ let issue;
16669
+ if (flags.issue !== void 0) {
16670
+ if (topic) throw new PeriscopeError(`--issue and --topic cannot be combined: --issue periscopes a ticket that already exists
16671
+ ${usage2}`);
16672
+ const m = /^(?:[A-Za-z]+-)?(\d+)$/.exec(flags.issue.trim());
16673
+ if (!m) throw new PeriscopeError(`"${flags.issue}" is not a ticket number
16674
+ ${usage2}`);
16675
+ issue = String(Number.parseInt(m[1], 10));
16676
+ }
16549
16677
  return {
16550
16678
  ...rest.length === 2 ? { route: rest[0] } : {},
16551
- repo: rest[rest.length - 1],
16679
+ ...rest.length > 0 ? { repo: rest[rest.length - 1] } : {},
16552
16680
  ...topic ? { topic } : {},
16681
+ ...issue ? { issue } : {},
16553
16682
  isolate: flags.isolate,
16554
16683
  ...flags.host ? { host: flags.host } : {},
16555
16684
  ...flags.assignee ? { assignee: flags.assignee } : {},
@@ -16574,7 +16703,37 @@ function periscopeCaps(l) {
16574
16703
  idleMinutes: l.periscopeIdleMinutes ?? DEFAULT_PERISCOPE_IDLE_MINUTES2
16575
16704
  };
16576
16705
  }
16706
+ async function existingRefusal(e, t) {
16707
+ if (t.closed) return `${t.tag} is ${t.status}; a closed ticket cannot be periscoped`;
16708
+ if (e.repoId && t.repoId !== e.repoId) return `${t.tag} belongs to a different repository than ${e.repoName}`;
16709
+ if (t.assignee && t.assignee !== e.pairId) {
16710
+ const agent = await e.agentHolder(t.assignee);
16711
+ if (agent) return `${t.tag} is held by ${agent}`;
16712
+ }
16713
+ if (t.heldByShipId && e.shipId && t.heldByShipId !== e.shipId) return `${t.tag} is held by another ship (${t.heldByShipId})`;
16714
+ if (e.hasLiveRun(t.id)) return `${t.tag} has a run in flight on this ship`;
16715
+ return void 0;
16716
+ }
16577
16717
  var tail = (s, n = 20) => s.trimEnd().split("\n").slice(-n).join("\n");
16718
+ function defaultTopic(repo, now) {
16719
+ return `${repo}, ${now.toISOString().slice(0, 10)}`;
16720
+ }
16721
+ function openerFor(platform2) {
16722
+ return platform2 === "darwin" ? "open" : platform2 === "linux" ? "xdg-open" : void 0;
16723
+ }
16724
+ function upOutput(res, terminal) {
16725
+ return terminal ? `${res.ticket}
16726
+ ${res.url}
16727
+ ${res.worktree}
16728
+ ` : `${res.ticket}
16729
+ ${res.worktree}
16730
+ ${res.url}
16731
+ `;
16732
+ }
16733
+ function shouldOpen(f) {
16734
+ if (f.noOpen || f.json) return false;
16735
+ return f.open || f.terminal;
16736
+ }
16578
16737
  function progressComment(url, handoff) {
16579
16738
  return [
16580
16739
  `Periscope is up: ${url}`,
@@ -16595,17 +16754,35 @@ async function periscopePreflight(d, repo) {
16595
16754
  }
16596
16755
  async function runPeriscopeUp(d, args) {
16597
16756
  await periscopePreflight(d, args.repo);
16598
- const topic = args.topic ?? (await d.askTopic())?.trim();
16599
- if (!topic) throw new PeriscopeError("a topic is required: pass --topic TEXT");
16600
- const unsafe = d.baseProblem();
16601
- if (unsafe) throw new PeriscopeError(`DO NOT CUT A WORKTREE FROM THIS BASE \u2014 ${unsafe}`);
16602
- const draft = await d.fileDraft(`Periscope: ${topic}`);
16603
- d.say("filed", `filed draft ${draft.tag}`, { ticket: draft.tag, ticketRecordId: draft.id });
16757
+ const ex = d.existing;
16758
+ let draft;
16759
+ let prior;
16760
+ if (args.issue) {
16761
+ if (!ex) throw new PeriscopeError("--issue is not supported here");
16762
+ prior = await ex.load(args.issue);
16763
+ if (!prior) throw new PeriscopeError(`no ticket numbered ${args.issue} on this route`);
16764
+ const refused = await existingRefusal(ex, prior);
16765
+ if (refused) throw new PeriscopeError(refused);
16766
+ const unsafe = d.baseProblem();
16767
+ if (unsafe) throw new PeriscopeError(`DO NOT CUT A WORKTREE FROM THIS BASE \u2014 ${unsafe}`);
16768
+ await ex.hold(prior.id);
16769
+ ex.remember(prior.id, { assignee: prior.assignee, status: prior.status });
16770
+ draft = prior;
16771
+ d.say("held", `holding ${prior.tag} (status ${prior.status} stays)`, { ticket: prior.tag, ticketRecordId: prior.id });
16772
+ } else {
16773
+ const topic = args.topic ?? (await d.askTopic())?.trim() ?? defaultTopic(args.repo, d.now());
16774
+ const unsafe = d.baseProblem();
16775
+ if (unsafe) throw new PeriscopeError(`DO NOT CUT A WORKTREE FROM THIS BASE \u2014 ${unsafe}`);
16776
+ draft = await d.fileDraft(`Periscope: ${topic}`);
16777
+ d.say("filed", `filed draft ${draft.tag}`, { ticket: draft.tag, ticketRecordId: draft.id });
16778
+ }
16604
16779
  let wt;
16605
16780
  const abandon = async (why) => {
16606
16781
  if (wt) await d.teardown(wt.path, wt.branch).catch(() => {
16607
16782
  });
16608
- await d.closeDraft(draft.id, `Periscope abandoned: ${why.split("\n")[0]}`).catch(() => {
16783
+ if (prior) await d.existing.restore(prior.id, prior.assignee).catch(() => {
16784
+ });
16785
+ else await d.closeDraft(draft.id, `Periscope abandoned: ${why.split("\n")[0]}`).catch(() => {
16609
16786
  });
16610
16787
  throw new PeriscopeError(why);
16611
16788
  };
@@ -16682,25 +16859,36 @@ ${tail(preview.output)}`);
16682
16859
  await d.comment(draft.id, progressComment(url, handoff?.output)).catch((e) => {
16683
16860
  d.say("warning", `progress comment not posted: ${e.message}`);
16684
16861
  });
16685
- return { ticket: draft.tag, ticketRecordId: draft.id, worktree: wt.path, branch: wt.branch, url, periscopeRecordId: row.id };
16862
+ return {
16863
+ ticket: draft.tag,
16864
+ ticketRecordId: draft.id,
16865
+ worktree: wt.path,
16866
+ branch: wt.branch,
16867
+ url,
16868
+ periscopeRecordId: row.id,
16869
+ ...prior ? { existing: true, priorStatus: prior.status } : {}
16870
+ };
16686
16871
  }
16687
16872
  function parsePeriscopeDownArgs(positional2, flags) {
16688
- const usage2 = "usage: crew periscope down [route] NNN --keep|--discard [--allow-empty] [--json]";
16873
+ const usage2 = "usage: crew periscope down [[route] NNN] --keep|--discard [--allow-empty] [--json]";
16689
16874
  const rest = positional2.slice(2);
16690
- if (rest.length === 0 || rest.length > 2) throw new PeriscopeError(usage2);
16875
+ if (rest.length > 2) throw new PeriscopeError(usage2);
16691
16876
  if (flags.keep === flags.discard) throw new PeriscopeError(`pass exactly one of --keep or --discard
16692
16877
  ${usage2}`);
16693
- const m = /^(?:[A-Za-z]+-)?(\d+)$/.exec(rest[rest.length - 1]);
16694
- if (!m) throw new PeriscopeError(`"${rest[rest.length - 1]}" is not a ticket number
16878
+ const m = rest.length === 0 ? null : /^(?:[A-Za-z]+-)?(\d+)$/.exec(rest[rest.length - 1]);
16879
+ if (rest.length > 0 && !m) throw new PeriscopeError(`"${rest[rest.length - 1]}" is not a ticket number
16695
16880
  ${usage2}`);
16696
16881
  return {
16697
16882
  ...rest.length === 2 ? { route: rest[0] } : {},
16698
- number: m[1],
16883
+ ...m ? { number: m[1] } : {},
16699
16884
  mode: flags.keep ? "keep" : "discard",
16700
16885
  allowEmpty: flags.allowEmpty,
16701
16886
  json: flags.json
16702
16887
  };
16703
16888
  }
16889
+ function rowForCwd(rows, cwd) {
16890
+ return rows.find((r) => r.state === "live" && r.worktree !== "" && (cwd === r.worktree || cwd.startsWith(`${r.worktree}/`)));
16891
+ }
16704
16892
  var PROTOTYPE_HEADING = "## Prototype";
16705
16893
  function prototypeSection(files) {
16706
16894
  const added = files.reduce((n, f) => n + f.added, 0);
@@ -16731,6 +16919,21 @@ async function runPeriscopeDown(d, args) {
16731
16919
  d.say("unchanged", `${done.ticket} is already ${done.state}; nothing to do`);
16732
16920
  return { ticket: done.ticket, mode: args.mode, outcome: "unchanged", state: done.state };
16733
16921
  }
16922
+ const existing = (await d.readTicket(row.ticketRecordId)).isDraft === false;
16923
+ const prior = existing ? d.priorHold?.(row.ticketRecordId) : void 0;
16924
+ if (existing && args.mode === "discard") {
16925
+ const killed2 = await d.stopServer(row.worktree, row.number);
16926
+ d.say("server", killed2 ? `stopped ${killed2} process(es)` : "no server was listening");
16927
+ const onRemote = d.remoteHasBranch?.(row.worktree, row.branch) ?? true;
16928
+ d.removeWorktree(row.worktree, row.branch, !onRemote);
16929
+ d.say("worktree", onRemote ? `removed ${row.worktree}; ${row.branch} stays, it is on the remote` : `removed ${row.worktree} and branch ${row.branch}`);
16930
+ await d.updateTicket(row.ticketRecordId, { status: "released", fillProjectAndRepo: false });
16931
+ d.say("ticket", `${row.ticket} hold cleared; status unchanged${prior ? ` (assignee before the periscope: ${prior.assignee ?? "none"})` : ""}`);
16932
+ await d.setRowState(row.id, "discarded");
16933
+ d.say("row", "row is discarded");
16934
+ forgetLocal(d, row.ticketRecordId);
16935
+ return { ticket: row.ticket, mode: "discard", outcome: "done", state: "discarded", existing: true, ...prior ? { status: prior.status } : {} };
16936
+ }
16734
16937
  if (args.mode === "discard") {
16735
16938
  const killed2 = await d.stopServer(row.worktree, row.number);
16736
16939
  d.say("server", killed2 ? `stopped ${killed2} process(es)` : "no server was listening");
@@ -16762,12 +16965,17 @@ async function runPeriscopeDown(d, args) {
16762
16965
  const description = t.description.includes(PROTOTYPE_HEADING) ? t.description : `${t.description.trimEnd()}
16763
16966
 
16764
16967
  ${section}`;
16765
- await d.updateTicket(row.ticketRecordId, {
16766
- description,
16767
- fillProjectAndRepo: !t.hasProject || !t.hasRepo,
16768
- status: "new"
16769
- });
16770
- d.say("ticket", `${row.ticket} is new and unassigned with a Prototype section`);
16968
+ if (existing) {
16969
+ await d.updateTicket(row.ticketRecordId, { description, fillProjectAndRepo: false, status: "released" });
16970
+ d.say("ticket", `${row.ticket} has a Prototype section and no assignee; status unchanged${prior ? ` (assignee before the periscope: ${prior.assignee ?? "none"})` : ""}`);
16971
+ } else {
16972
+ await d.updateTicket(row.ticketRecordId, {
16973
+ description,
16974
+ fillProjectAndRepo: !t.hasProject || !t.hasRepo,
16975
+ status: "new"
16976
+ });
16977
+ d.say("ticket", `${row.ticket} is new and unassigned with a Prototype section`);
16978
+ }
16771
16979
  await d.postPushed(row.ticketRecordId, pushed.sha, pushed.remote, row.branch);
16772
16980
  const killed = await d.stopServer(row.worktree, row.number);
16773
16981
  d.say("server", killed ? `stopped ${killed} process(es)` : "no server was listening");
@@ -16776,7 +16984,7 @@ ${section}`;
16776
16984
  await d.setRowState(row.id, "kept");
16777
16985
  d.say("row", "row is kept");
16778
16986
  forgetLocal(d, row.ticketRecordId);
16779
- return { ticket: row.ticket, mode: "keep", outcome: "done", state: "kept", sha: pushed.sha };
16987
+ return { ticket: row.ticket, mode: "keep", outcome: "done", state: "kept", sha: pushed.sha, ...existing ? { existing: true, ...prior ? { status: prior.status } : {} } : {} };
16780
16988
  }
16781
16989
  function periscopeListEntries(rows, now, isListening) {
16782
16990
  return rows.filter((r) => r.state === "live").map((r) => {
@@ -16838,7 +17046,7 @@ ${tail2(preview.output)}`);
16838
17046
  // src/periscope-idle.ts
16839
17047
  import { execFileSync as execFileSync6 } from "child_process";
16840
17048
  import { statSync as statSync9 } from "fs";
16841
- import { join as join35 } from "path";
17049
+ import { join as join36 } from "path";
16842
17050
  var MAX_SCANNED_FILES = 2e4;
16843
17051
  function newestWorktreeMtime(root) {
16844
17052
  let listing;
@@ -16858,7 +17066,7 @@ function newestWorktreeMtime(root) {
16858
17066
  if (!rel) continue;
16859
17067
  if (++scanned > MAX_SCANNED_FILES) break;
16860
17068
  try {
16861
- const m = statSync9(join35(root, rel)).mtimeMs;
17069
+ const m = statSync9(join36(root, rel)).mtimeMs;
16862
17070
  if (newest === null || m > newest) newest = m;
16863
17071
  } catch {
16864
17072
  }
@@ -16934,18 +17142,18 @@ import { dirname as dirOf2, resolve as resolvePath } from "path";
16934
17142
 
16935
17143
  // src/ssh-keys.ts
16936
17144
  import { execFileSync as execFileSync7 } from "child_process";
16937
- import { existsSync as existsSync32, mkdirSync as mkdirSync19, readFileSync as readFileSync28, chmodSync } from "fs";
16938
- import { dirname as dirname19, join as join36 } from "path";
17145
+ import { existsSync as existsSync33, mkdirSync as mkdirSync20, readFileSync as readFileSync29, chmodSync } from "fs";
17146
+ import { dirname as dirname19, join as join37 } from "path";
16939
17147
  var SshKeyError = class extends Error {
16940
17148
  };
16941
17149
  function shipSshKeyPathFor(stateDir) {
16942
- return join36(stateDir, "ssh", "id_ed25519");
17150
+ return join37(stateDir, "ssh", "id_ed25519");
16943
17151
  }
16944
17152
  function ensureShipSshKeypair(stateDir) {
16945
17153
  const privateKeyPath = shipSshKeyPathFor(stateDir);
16946
17154
  const publicKeyPath = `${privateKeyPath}.pub`;
16947
- if (!existsSync32(privateKeyPath)) {
16948
- mkdirSync19(dirname19(privateKeyPath), { recursive: true });
17155
+ if (!existsSync33(privateKeyPath)) {
17156
+ mkdirSync20(dirname19(privateKeyPath), { recursive: true });
16949
17157
  try {
16950
17158
  execFileSync7("ssh-keygen", [
16951
17159
  "-t",
@@ -16965,10 +17173,10 @@ function ensureShipSshKeypair(stateDir) {
16965
17173
  }
16966
17174
  chmodSync(privateKeyPath, 384);
16967
17175
  }
16968
- if (!existsSync32(publicKeyPath)) {
17176
+ if (!existsSync33(publicKeyPath)) {
16969
17177
  throw new SshKeyError(`${privateKeyPath} exists but ${publicKeyPath} does not \u2014 regenerate or restore it`);
16970
17178
  }
16971
- const publicKey = readFileSync28(publicKeyPath, "utf8").trim();
17179
+ const publicKey = readFileSync29(publicKeyPath, "utf8").trim();
16972
17180
  return { privateKeyPath, publicKeyPath, publicKey };
16973
17181
  }
16974
17182
  function sshKeygenAvailable() {
@@ -17185,9 +17393,9 @@ function syncAllPassengerCheckouts(cfg) {
17185
17393
 
17186
17394
  // src/tunnel.ts
17187
17395
  import { spawn as spawn5 } from "child_process";
17188
- import { existsSync as existsSync33, mkdirSync as mkdirSync20, readFileSync as readFileSync29, writeFileSync as writeFileSync21, unlinkSync as unlinkSync3, readdirSync as readdirSync14 } from "fs";
17396
+ import { existsSync as existsSync34, mkdirSync as mkdirSync21, readFileSync as readFileSync30, writeFileSync as writeFileSync22, unlinkSync as unlinkSync3, readdirSync as readdirSync14 } from "fs";
17189
17397
  import { randomInt } from "crypto";
17190
- import { join as join37 } from "path";
17398
+ import { join as join38 } from "path";
17191
17399
  var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
17192
17400
  var defaultSpawn = (cmd, args) => spawn5(cmd, args, DEFAULT_SPAWN_OPTIONS);
17193
17401
  var SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
@@ -17254,21 +17462,21 @@ function startTunnel(opts) {
17254
17462
  };
17255
17463
  }
17256
17464
  function tunnelStatePath(stateDir, workspaceId) {
17257
- return join37(stateDir, "passengers", `${workspaceId}.json`);
17465
+ return join38(stateDir, "passengers", `${workspaceId}.json`);
17258
17466
  }
17259
17467
  function readPersistedTunnel(stateDir, workspaceId) {
17260
17468
  const path = tunnelStatePath(stateDir, workspaceId);
17261
- if (!existsSync33(path)) return void 0;
17469
+ if (!existsSync34(path)) return void 0;
17262
17470
  try {
17263
- return JSON.parse(readFileSync29(path, "utf8"));
17471
+ return JSON.parse(readFileSync30(path, "utf8"));
17264
17472
  } catch {
17265
17473
  return void 0;
17266
17474
  }
17267
17475
  }
17268
17476
  function writePersistedTunnel(stateDir, state) {
17269
17477
  const path = tunnelStatePath(stateDir, state.workspaceId);
17270
- mkdirSync20(join37(stateDir, "passengers"), { recursive: true });
17271
- writeFileSync21(path, `${JSON.stringify(state, null, 2)}
17478
+ mkdirSync21(join38(stateDir, "passengers"), { recursive: true });
17479
+ writeFileSync22(path, `${JSON.stringify(state, null, 2)}
17272
17480
  `);
17273
17481
  }
17274
17482
  function updatePersistedTunnel(stateDir, workspaceId, patch2) {
@@ -17365,8 +17573,8 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
17365
17573
  }
17366
17574
  }
17367
17575
  function readAllPersistedWorkspaceIds(stateDir) {
17368
- const dir = join37(stateDir, "passengers");
17369
- if (!existsSync33(dir)) return [];
17576
+ const dir = join38(stateDir, "passengers");
17577
+ if (!existsSync34(dir)) return [];
17370
17578
  return readdirSync14(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
17371
17579
  }
17372
17580
 
@@ -17487,8 +17695,8 @@ async function unregisterEndpoint(workspaceId, info, d) {
17487
17695
  import { createServer as createHttpServer } from "http";
17488
17696
  import { spawn as spawn6 } from "child_process";
17489
17697
  import { randomBytes } from "crypto";
17490
- import { existsSync as existsSync34, mkdirSync as mkdirSync21, readFileSync as readFileSync30, writeFileSync as writeFileSync22, unlinkSync as unlinkSync4, readdirSync as readdirSync15 } from "fs";
17491
- import { join as join38 } from "path";
17698
+ import { existsSync as existsSync35, mkdirSync as mkdirSync22, readFileSync as readFileSync31, writeFileSync as writeFileSync23, unlinkSync as unlinkSync4, readdirSync as readdirSync15 } from "fs";
17699
+ import { join as join39 } from "path";
17492
17700
 
17493
17701
  // src/secret-compare.ts
17494
17702
  import { timingSafeEqual } from "crypto";
@@ -17577,21 +17785,21 @@ var defaultSpawn2 = (cmd, args, env) => {
17577
17785
  return child;
17578
17786
  };
17579
17787
  function syncDaemonStatePath(stateDir, workspaceId) {
17580
- return join38(stateDir, "passengers", `${workspaceId}-sync.json`);
17788
+ return join39(stateDir, "passengers", `${workspaceId}-sync.json`);
17581
17789
  }
17582
17790
  function readPersistedSyncDaemon(stateDir, workspaceId) {
17583
17791
  const path = syncDaemonStatePath(stateDir, workspaceId);
17584
- if (!existsSync34(path)) return void 0;
17792
+ if (!existsSync35(path)) return void 0;
17585
17793
  try {
17586
- return JSON.parse(readFileSync30(path, "utf8"));
17794
+ return JSON.parse(readFileSync31(path, "utf8"));
17587
17795
  } catch {
17588
17796
  return void 0;
17589
17797
  }
17590
17798
  }
17591
17799
  function writePersistedSyncDaemon(stateDir, state) {
17592
17800
  const path = syncDaemonStatePath(stateDir, state.workspaceId);
17593
- mkdirSync21(join38(stateDir, "passengers"), { recursive: true });
17594
- writeFileSync22(path, `${JSON.stringify(state, null, 2)}
17801
+ mkdirSync22(join39(stateDir, "passengers"), { recursive: true });
17802
+ writeFileSync23(path, `${JSON.stringify(state, null, 2)}
17595
17803
  `);
17596
17804
  }
17597
17805
  function removePersistedSyncDaemon(stateDir, workspaceId) {
@@ -17611,8 +17819,8 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
17611
17819
  }
17612
17820
  }
17613
17821
  function readAllPersistedSyncWorkspaceIds(stateDir) {
17614
- const dir = join38(stateDir, "passengers");
17615
- if (!existsSync34(dir)) return [];
17822
+ const dir = join39(stateDir, "passengers");
17823
+ if (!existsSync35(dir)) return [];
17616
17824
  const suffix = "-sync.json";
17617
17825
  return readdirSync15(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
17618
17826
  }
@@ -17700,8 +17908,8 @@ function describeCause(err) {
17700
17908
  }
17701
17909
 
17702
17910
  // src/daemon.ts
17703
- import { existsSync as existsSync35, mkdirSync as mkdirSync22, readFileSync as readFileSync31, readdirSync as readdirSync16, rmSync as rmSync15, statSync as statSync10, writeFileSync as writeFileSync23 } from "fs";
17704
- import { join as join39 } from "path";
17911
+ import { existsSync as existsSync36, mkdirSync as mkdirSync23, readFileSync as readFileSync32, readdirSync as readdirSync16, rmSync as rmSync16, statSync as statSync10, writeFileSync as writeFileSync24 } from "fs";
17912
+ import { join as join40 } from "path";
17705
17913
  import { randomBytes as randomBytes2 } from "crypto";
17706
17914
  var DEFAULT_FLOOR_MS = 5e3;
17707
17915
  var DEFAULT_CEILING_MS = INTERVAL_SECONDS * 1e3;
@@ -17772,7 +17980,7 @@ function latestMtime(path) {
17772
17980
  if (!stat.isDirectory()) return 0;
17773
17981
  let latest = stat.mtimeMs;
17774
17982
  for (const entry of readdirSync16(path, { withFileTypes: true })) {
17775
- latest = Math.max(latest, latestMtime(join39(path, entry.name)));
17983
+ latest = Math.max(latest, latestMtime(join40(path, entry.name)));
17776
17984
  }
17777
17985
  return latest;
17778
17986
  }
@@ -17781,23 +17989,23 @@ function computeTreeSignature(paths) {
17781
17989
  }
17782
17990
  function makeStaleChecker(paths, opts = {}) {
17783
17991
  const initial = computeTreeSignature(paths);
17784
- return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync35(opts.reloadSentinel);
17992
+ return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync36(opts.reloadSentinel);
17785
17993
  }
17786
17994
  function reloadSentinelFor(stateDir) {
17787
- return join39(stateDir, "reload-requested");
17995
+ return join40(stateDir, "reload-requested");
17788
17996
  }
17789
17997
  function daemonStampFor(stateDir) {
17790
- return join39(stateDir, "daemon.started");
17998
+ return join40(stateDir, "daemon.started");
17791
17999
  }
17792
18000
  function noteDaemonStarted(stateDir, now = Date.now()) {
17793
- mkdirSync22(stateDir, { recursive: true });
17794
- rmSync15(reloadSentinelFor(stateDir), { force: true });
17795
- writeFileSync23(daemonStampFor(stateDir), `${JSON.stringify({ pid: process.pid, startedAt: now })}
18001
+ mkdirSync23(stateDir, { recursive: true });
18002
+ rmSync16(reloadSentinelFor(stateDir), { force: true });
18003
+ writeFileSync24(daemonStampFor(stateDir), `${JSON.stringify({ pid: process.pid, startedAt: now })}
17796
18004
  `);
17797
18005
  }
17798
18006
  function readDaemonStarted(stateDir) {
17799
18007
  try {
17800
- const v = JSON.parse(readFileSync31(daemonStampFor(stateDir), "utf8")).startedAt;
18008
+ const v = JSON.parse(readFileSync32(daemonStampFor(stateDir), "utf8")).startedAt;
17801
18009
  return typeof v === "number" ? v : null;
17802
18010
  } catch {
17803
18011
  return null;
@@ -18419,12 +18627,13 @@ function usage(problem, requested = false) {
18419
18627
  crew reap [route] kill orphaned servers, drop worktrees for closed tickets
18420
18628
  crew drop [route] NNN remove a merged ticket's worktree and branch
18421
18629
  crew rotate-passenger-url [route] force a Host Passengers tunnel to reconnect with a fresh public URL
18422
- crew periscope up [route] REPO --topic TEXT [--isolate] [--host H] [--assignee ID] [--dry-run] [--json]
18630
+ crew periscope up [[route] REPO] [--topic TEXT|--issue NNN] [--isolate] [--host H] [--assignee ID] [--no-open] [--open] [--dry-run] [--json]
18423
18631
  crew periscope up --resume NNN restart the preview an idle periscope's server was stopped for
18424
18632
  file a draft ticket, cut and prepare its worktree, start the repo's preview hook and record a Periscopes row (alias: start)
18425
- crew periscope down [route] NNN --keep|--discard [--allow-empty] [--json]
18633
+ crew periscope down [[route] NNN] --keep|--discard [--allow-empty] [--json]
18426
18634
  end a periscope: commit and push its branch (ticket back to new), or throw it away (alias: end)
18427
18635
  crew periscope list [route] [--json] the live periscopes on this ship, their idle minutes and whether each server is listening
18636
+ crew shell-init [--shell zsh|bash|fish] print the periscope shell function (install: eval "$(crew shell-init)")
18428
18637
  crew worktree [route] NNN cut (or refresh) this run's worktree for a ticket from the remote; prints its path
18429
18638
  crew unassign [route] NNN hand back a session's ticket \u2014 clears assignee, next cycle picks it up
18430
18639
  crew sync [route] fast-forward the checkout and its worktrees from the remote
@@ -18530,6 +18739,7 @@ var KNOWN_COMMANDS = /* @__PURE__ */ new Set([
18530
18739
  "run",
18531
18740
  "sample-ticket",
18532
18741
  "selftest",
18742
+ "shell-init",
18533
18743
  "ship",
18534
18744
  "skills",
18535
18745
  "status",
@@ -18541,6 +18751,16 @@ var KNOWN_COMMANDS = /* @__PURE__ */ new Set([
18541
18751
  "worktree"
18542
18752
  ]);
18543
18753
  if (!KNOWN_COMMANDS.has(command)) usage(`unknown command "${command}"`);
18754
+ if (command === "shell-init") {
18755
+ const asked = value("shell");
18756
+ if (asked !== void 0 && !SHELLS.includes(asked)) {
18757
+ process.stderr.write(`crew shell-init: unknown shell "${asked}" (have: ${SHELLS.join(", ")})
18758
+ `);
18759
+ process.exit(2);
18760
+ }
18761
+ process.stdout.write(shellInitScript(shellFor(asked, process.env.SHELL)));
18762
+ process.exit(0);
18763
+ }
18544
18764
  function runReposCheck(dir, ship) {
18545
18765
  const report = checkRepoSpec(dir, { ship, strict: flag("strict") });
18546
18766
  if (flag("json")) {
@@ -18699,7 +18919,7 @@ if (command === "passenger-sync-daemon") {
18699
18919
  } else {
18700
18920
  const toleratesNoRoutes = command === "repos" || command === "ship" || command === "disconnect" && flag("reset") || command === "connect" && flag("sign-out");
18701
18921
  const configless = command === "connect" && flag("sign-out") || command === "disconnect" && flag("reset");
18702
- if (configless && !existsSync36(findConfigFile(CREW_HOME))) {
18922
+ if (configless && !existsSync37(findConfigFile(CREW_HOME))) {
18703
18923
  cfg = loadConfig(CREW_HOME, firstRunConfigPath(), { text: "ship: {}\n", allowNoRoutes: true });
18704
18924
  } else {
18705
18925
  cfg = loadConfig(CREW_HOME, void 0, toleratesNoRoutes ? { allowNoRoutes: true } : {});
@@ -18937,8 +19157,8 @@ if (command === "passenger-sync-daemon") {
18937
19157
  }
18938
19158
  if (host === "linux" && hasSystemd) {
18939
19159
  const label = labelFor(CREW_HOME, "run");
18940
- const staleTimer = join40(homedir10(), ".config", "systemd", "user", `${label}.timer`);
18941
- if (existsSync36(staleTimer)) {
19160
+ const staleTimer = join41(homedir10(), ".config", "systemd", "user", `${label}.timer`);
19161
+ if (existsSync37(staleTimer)) {
18942
19162
  process.stdout.write(
18943
19163
  `removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
18944
19164
  `
@@ -18965,7 +19185,7 @@ if (command === "passenger-sync-daemon") {
18965
19185
  const tracker = new Tracker(c, cfg.ship);
18966
19186
  problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
18967
19187
  for (const r of await resolvedRepos(c)) {
18968
- if (!existsSync36(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
19188
+ if (!existsSync37(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
18969
19189
  if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
18970
19190
  readiness.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
18971
19191
  }
@@ -18983,7 +19203,7 @@ if (command === "passenger-sync-daemon") {
18983
19203
  }
18984
19204
  async function doctorFix(o) {
18985
19205
  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))));
18986
- const unitExists = (p) => existsSync36(p);
19206
+ const unitExists = (p) => existsSync37(p);
18987
19207
  const crontab = o.host === "linux" ? spawnSync7("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
18988
19208
  const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
18989
19209
  const plan = planFix({ routes, schedulerAbsent, host: o.host });
@@ -19000,7 +19220,7 @@ if (command === "passenger-sync-daemon") {
19000
19220
  for (const a of plan.actions) {
19001
19221
  if (a.kind === "enable") {
19002
19222
  if (prompter && !await prompter.confirm(`Route ${a.route} passes every check \u2014 set enabled: true in ${cfg.configFile}?`, false)) continue;
19003
- writeFileSync24(cfg.configFile, planEnable(readFileSync32(cfg.configFile, "utf8"), a.route));
19223
+ writeFileSync25(cfg.configFile, planEnable(readFileSync33(cfg.configFile, "utf8"), a.route));
19004
19224
  enabledSound.add(a.route);
19005
19225
  changed2.push(`enabled route ${a.route} in ${cfg.configFile}`);
19006
19226
  } else {
@@ -20104,7 +20324,7 @@ ${cu.tail.trimEnd()}
20104
20324
  process.exit(1);
20105
20325
  }
20106
20326
  requireArmed("run the daemon loop");
20107
- const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join40(CREW_HOME, "src"), join40(CREW_HOME, "dist"), join40(CREW_HOME, "bin")];
20327
+ const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join41(CREW_HOME, "src"), join41(CREW_HOME, "dist"), join41(CREW_HOME, "bin")];
20108
20328
  const configPaths = daemonConfigPaths(cfg);
20109
20329
  watchPaths.push(...configPaths);
20110
20330
  noteDaemonStarted(cfg.ship.stateDir);
@@ -20333,10 +20553,10 @@ ${cu.tail.trimEnd()}
20333
20553
  `);
20334
20554
  if (!dryRun) {
20335
20555
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
20336
- const raw = existsSync36(resolvedPath) ? JSON.parse(readFileSync32(resolvedPath, "utf8")) : {};
20556
+ const raw = existsSync37(resolvedPath) ? JSON.parse(readFileSync33(resolvedPath, "utf8")) : {};
20337
20557
  raw.agentSkills = result.agentSkills;
20338
- mkdirSync23(dirOf2(resolvedPath), { recursive: true });
20339
- writeFileSync24(resolvedPath, `${JSON.stringify(raw, null, 2)}
20558
+ mkdirSync24(dirOf2(resolvedPath), { recursive: true });
20559
+ writeFileSync25(resolvedPath, `${JSON.stringify(raw, null, 2)}
20340
20560
  `);
20341
20561
  }
20342
20562
  if (result.outcomes.some((o) => o.action === "diverged")) process.exitCode = 1;
@@ -20359,7 +20579,7 @@ ${cu.tail.trimEnd()}
20359
20579
  throw e;
20360
20580
  }
20361
20581
  const oldName = cfg.ship.name;
20362
- writeFileSync24(cfg.configFile, setShipNameInYaml(readFileSync32(cfg.configFile, "utf8"), newName));
20582
+ writeFileSync25(cfg.configFile, setShipNameInYaml(readFileSync33(cfg.configFile, "utf8"), newName));
20363
20583
  cfg.ship.name = newName;
20364
20584
  let renameMachineId;
20365
20585
  try {
@@ -20406,7 +20626,7 @@ ${cu.tail.trimEnd()}
20406
20626
  process.exit(2);
20407
20627
  };
20408
20628
  if (sub !== "add" && sub !== "list" && sub !== "clone" && sub !== "hooks" && sub !== "check") usage2();
20409
- const configText = readFileSync32(cfg.configFile, "utf8");
20629
+ const configText = readFileSync33(cfg.configFile, "utf8");
20410
20630
  if (sub === "check") {
20411
20631
  try {
20412
20632
  const routeArg = value("route") ?? positional[2];
@@ -20437,8 +20657,8 @@ ${cu.tail.trimEnd()}
20437
20657
  try {
20438
20658
  const a = parseHooksArgs(positional.slice(2), routeNamesOf(configText));
20439
20659
  const dir = repoDirOf(configText, dirname20(cfg.configFile), a.route, a.name);
20440
- if (!existsSync36(dir)) throw new ReposError(`${dir} does not exist on disk`);
20441
- const files = { exists: (r) => existsSync36(resolvePath(dir, r)), list: (r) => {
20660
+ if (!existsSync37(dir)) throw new ReposError(`${dir} does not exist on disk`);
20661
+ const files = { exists: (r) => existsSync37(resolvePath(dir, r)), list: (r) => {
20442
20662
  try {
20443
20663
  return readdirSync17(resolvePath(dir, r));
20444
20664
  } catch {
@@ -20446,7 +20666,7 @@ ${cu.tail.trimEnd()}
20446
20666
  }
20447
20667
  }, read: (r) => {
20448
20668
  try {
20449
- return readFileSync32(resolvePath(dir, r), "utf8");
20669
+ return readFileSync33(resolvePath(dir, r), "utf8");
20450
20670
  } catch {
20451
20671
  return void 0;
20452
20672
  }
@@ -20488,19 +20708,19 @@ ${cu.tail.trimEnd()}
20488
20708
  let cleared;
20489
20709
  if (clear2) {
20490
20710
  const plan = clearRouteHooks(configText, a.route, a.name);
20491
- if (plan.removed.length > 0) writeFileSync24(cfg.configFile, plan.text);
20711
+ if (plan.removed.length > 0) writeFileSync25(cfg.configFile, plan.text);
20492
20712
  cleared = { file: cfg.configFile, removed: plan.removed };
20493
20713
  }
20494
20714
  if (write && suggestion.source === null && recipeIds.length === 0 && hookSpecs.length === 0) throw new ReposError(`nothing to write: ${suggestion.notes[0]}`);
20495
20715
  if (write === "route") {
20496
20716
  const plan = planRouteHooks(configText, a.route, a.name, picked.hooks, { replace: flag("replace") });
20497
- writeFileSync24(cfg.configFile, plan.text);
20717
+ writeFileSync25(cfg.configFile, plan.text);
20498
20718
  written = { target: "route", file: cfg.configFile, hooks: plan.written, kept: plan.kept, replaced: plan.replaced };
20499
20719
  } else if (write === "repo") {
20500
20720
  const have = existingRepoConfig(files);
20501
20721
  if (have) throw new ReposError(`${resolvePath(dir, have)} already exists; not overwriting it`);
20502
20722
  const file = resolvePath(dir, ".crew.yaml");
20503
- writeFileSync24(file, starterRepoConfig(picked.hooks, picked.release), { flag: "wx" });
20723
+ writeFileSync25(file, starterRepoConfig(picked.hooks, picked.release), { flag: "wx" });
20504
20724
  written = { target: "repo", file, hooks: Object.keys(picked.hooks), kept: [], replaced: [] };
20505
20725
  }
20506
20726
  if (flag("json")) {
@@ -20556,7 +20776,7 @@ ${cu.tail.trimEnd()}
20556
20776
  if (sub === "list" && !routeName) usage2();
20557
20777
  if (sub === "add") {
20558
20778
  try {
20559
- const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync36(resolvePath(p.replace(/^~(?=\/|$)/, homedir10()))));
20779
+ const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync37(resolvePath(p.replace(/^~(?=\/|$)/, homedir10()))));
20560
20780
  routeName = a.route;
20561
20781
  addPath = a.path;
20562
20782
  addName = a.name;
@@ -20585,16 +20805,16 @@ ${cu.tail.trimEnd()}
20585
20805
  }
20586
20806
  const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
20587
20807
  let resolvedRemotes;
20588
- if (existsSync36(resolvedFile)) {
20808
+ if (existsSync37(resolvedFile)) {
20589
20809
  try {
20590
- resolvedRemotes = JSON.parse(readFileSync32(resolvedFile, "utf8"));
20810
+ resolvedRemotes = JSON.parse(readFileSync33(resolvedFile, "utf8"));
20591
20811
  } catch {
20592
20812
  }
20593
20813
  }
20594
20814
  const originOf = (dir) => gitOk(dir, ["remote", "get-url", "origin"]) ?? void 0;
20595
20815
  const text = configText;
20596
20816
  const base = dirname20(cfg.configFile);
20597
- const isCheckout = (d) => existsSync36(`${d}/.git`);
20817
+ const isCheckout = (d) => existsSync37(`${d}/.git`);
20598
20818
  try {
20599
20819
  if (sub === "list") {
20600
20820
  if (flag("json")) {
@@ -20604,11 +20824,11 @@ ${cu.tail.trimEnd()}
20604
20824
  base,
20605
20825
  resolved: resolvedRemotes,
20606
20826
  originOf,
20607
- exists: (d) => existsSync36(d),
20827
+ exists: (d) => existsSync37(d),
20608
20828
  isCheckout,
20609
20829
  repoHasHooks: (d) => REPO_CONFIG_FILENAMES.some((f) => {
20610
20830
  try {
20611
- return Object.keys(parseRepoConfig(readFileSync32(resolvePath(d, f), "utf8"), f).hooks).length > 0;
20831
+ return Object.keys(parseRepoConfig(readFileSync33(resolvePath(d, f), "utf8"), f).hooks).length > 0;
20612
20832
  } catch {
20613
20833
  return false;
20614
20834
  }
@@ -20625,7 +20845,7 @@ ${cu.tail.trimEnd()}
20625
20845
  for (const r of repos) {
20626
20846
  const origin2 = originOf(r.dir);
20627
20847
  const row = matchRepoName(origin2, resolvedRemotes);
20628
- const note = !existsSync36(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
20848
+ const note = !existsSync37(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
20629
20849
  process.stdout.write(`${r.name} ${r.dir} ${note}
20630
20850
  `);
20631
20851
  }
@@ -20637,9 +20857,9 @@ ${cu.tail.trimEnd()}
20637
20857
  ` : `${human}
20638
20858
  `);
20639
20859
  const plan2 = planRepoClone({ text, base, route: routeName, name: addName, resolved: resolvedRemotes, dir: value("dir"), isCheckout });
20640
- if (existsSync36(plan2.target) && (existsSync36(`${plan2.target}/.git`) || readdirSync17(plan2.target).length > 0)) {
20860
+ if (existsSync37(plan2.target) && (existsSync37(`${plan2.target}/.git`) || readdirSync17(plan2.target).length > 0)) {
20641
20861
  throw new ReposError(
20642
- existsSync36(`${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`
20862
+ existsSync37(`${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`
20643
20863
  );
20644
20864
  }
20645
20865
  if (dryRun) {
@@ -20657,14 +20877,14 @@ ${cu.tail.trimEnd()}
20657
20877
  throw new ReposError(`${e.message}${authHint}
20658
20878
  crew.yaml was not changed`);
20659
20879
  }
20660
- writeFileSync24(cfg.configFile, plan2.text);
20880
+ writeFileSync25(cfg.configFile, plan2.text);
20661
20881
  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:
20662
20882
  `);
20663
20883
  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}`);
20664
20884
  break;
20665
20885
  }
20666
20886
  const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir10()));
20667
- const top = existsSync36(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
20887
+ const top = existsSync37(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
20668
20888
  if (!top) throw new ReposError(`${target} is not a git checkout`);
20669
20889
  const origin = originOf(top);
20670
20890
  const plan = planRepoAdd({
@@ -20716,15 +20936,15 @@ ${cu.tail.trimEnd()}
20716
20936
  const created = await rowTracker.createRepoRow(rowPayload);
20717
20937
  process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
20718
20938
  `);
20719
- if (existsSync36(resolvedFile)) {
20720
- const raw = JSON.parse(readFileSync32(resolvedFile, "utf8"));
20939
+ if (existsSync37(resolvedFile)) {
20940
+ const raw = JSON.parse(readFileSync33(resolvedFile, "utf8"));
20721
20941
  raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
20722
20942
  raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
20723
- writeFileSync24(resolvedFile, `${JSON.stringify(raw, null, 2)}
20943
+ writeFileSync25(resolvedFile, `${JSON.stringify(raw, null, 2)}
20724
20944
  `);
20725
20945
  }
20726
20946
  }
20727
- writeFileSync24(cfg.configFile, plan.text);
20947
+ writeFileSync25(cfg.configFile, plan.text);
20728
20948
  process.stdout.write(`added ${top} to ${routeName} as ${plan.mode === "dir" ? "dir" : `repos.${plan.name}`} in ${cfg.configFile}
20729
20949
  `);
20730
20950
  } catch (e) {
@@ -21091,7 +21311,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21091
21311
  process.exit(2);
21092
21312
  }
21093
21313
  if (found.shipNameAdopted && !dryRun) {
21094
- writeFileSync24(cfg.configFile, setShipNameInYaml(readFileSync32(cfg.configFile, "utf8"), found.shipNameAdopted));
21314
+ writeFileSync25(cfg.configFile, setShipNameInYaml(readFileSync33(cfg.configFile, "utf8"), found.shipNameAdopted));
21095
21315
  cfg.ship.name = found.shipNameAdopted;
21096
21316
  }
21097
21317
  if (found.shipRow) {
@@ -21134,10 +21354,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21134
21354
  }
21135
21355
  }
21136
21356
  let previous;
21137
- const hadPreviousFile = existsSync36(resolvedPath);
21357
+ const hadPreviousFile = existsSync37(resolvedPath);
21138
21358
  if (hadPreviousFile) {
21139
21359
  try {
21140
- previous = JSON.parse(readFileSync32(resolvedPath, "utf8"));
21360
+ previous = JSON.parse(readFileSync33(resolvedPath, "utf8"));
21141
21361
  } catch {
21142
21362
  }
21143
21363
  }
@@ -21266,10 +21486,10 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21266
21486
  process.stderr.write(`(dry run) would write resolved ids to ${resolvedPath}
21267
21487
  `);
21268
21488
  } else {
21269
- mkdirSync23(dirname20(resolvedPath), { recursive: true });
21489
+ mkdirSync24(dirname20(resolvedPath), { recursive: true });
21270
21490
  if (hadPreviousFile) copyFileSync2(resolvedPath, `${resolvedPath}.bak`);
21271
21491
  const tmpPath = `${resolvedPath}.tmp${process.pid}`;
21272
- writeFileSync24(tmpPath, `${JSON.stringify(resolved, null, 2)}
21492
+ writeFileSync25(tmpPath, `${JSON.stringify(resolved, null, 2)}
21273
21493
  `);
21274
21494
  renameSync3(tmpPath, resolvedPath);
21275
21495
  process.stderr.write(`Resolved ids written to ${resolvedPath}
@@ -21315,8 +21535,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21315
21535
  process.stderr.write(`(dry run) would write the minted API key to ${apiKeyFile}
21316
21536
  `);
21317
21537
  } else {
21318
- mkdirSync23(dirname20(apiKeyFile), { recursive: true });
21319
- writeFileSync24(apiKeyFile, `${apiKeyVar}=${apiKey}
21538
+ mkdirSync24(dirname20(apiKeyFile), { recursive: true });
21539
+ writeFileSync25(apiKeyFile, `${apiKeyVar}=${apiKey}
21320
21540
  `, { mode: 384 });
21321
21541
  process.stderr.write(`API key written to ${apiKeyFile}
21322
21542
  `);
@@ -21866,8 +22086,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21866
22086
  case "periscope": {
21867
22087
  const subP = positional[1];
21868
22088
  if (subP !== "up" && subP !== "start" && subP !== "down" && subP !== "end" && subP !== "list") {
21869
- process.stderr.write("crew periscope up [route] REPO --topic TEXT [--isolate] [--host H] [--assignee ID] [--dry-run] [--json]\n");
21870
- process.stderr.write("crew periscope down [route] NNN --keep|--discard [--allow-empty] [--json]\n");
22089
+ process.stderr.write("crew periscope up [[route] REPO] [--topic TEXT|--issue NNN] [--isolate] [--host H] [--assignee ID] [--no-open] [--open] [--dry-run] [--json]\n");
22090
+ process.stderr.write("crew periscope down [[route] NNN] --keep|--discard [--allow-empty] [--json]\n");
21871
22091
  process.stderr.write("crew periscope list [route] [--json]\n");
21872
22092
  process.exit(2);
21873
22093
  }
@@ -21912,11 +22132,27 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21912
22132
  `);
21913
22133
  break;
21914
22134
  }
21915
- const a = downArgs;
22135
+ const real = (p) => {
22136
+ try {
22137
+ return realpathSync3(p);
22138
+ } catch {
22139
+ return p;
22140
+ }
22141
+ };
22142
+ const gathered = await Promise.all(routesP.map(async (rt) => ({ rt, rows: await gather(rt) })));
22143
+ const number = downArgs.number ?? (() => {
22144
+ const here = real(process.cwd());
22145
+ for (const g of gathered) {
22146
+ const hit = rowForCwd(g.rows.map((r) => ({ ...r, worktree: real(r.worktree) })), here);
22147
+ if (hit) return hit.number;
22148
+ }
22149
+ throw new PeriscopeError("not inside a periscope worktree \u2014 name the ticket: crew periscope down NNN --keep|--discard");
22150
+ })();
22151
+ const a = { ...downArgs, number };
21916
22152
  const hits = [];
21917
- for (const rt of routesP) {
21918
- const rows = (await gather(rt)).filter((r) => r.number === a.number);
21919
- if (rows.length) hits.push({ rt, rows });
22153
+ for (const g of gathered) {
22154
+ const rows = g.rows.filter((r) => r.number === a.number);
22155
+ if (rows.length) hits.push({ rt: g.rt, rows });
21920
22156
  }
21921
22157
  if (hits.length !== 1) {
21922
22158
  throw new PeriscopeError(hits.length === 0 ? `no periscope for ticket ${a.number} on this ship` : `ticket ${a.number} has periscopes on more than one route (${hits.map((h) => h.rt.route).join(", ")}) \u2014 name the route`);
@@ -21950,11 +22186,17 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21950
22186
  },
21951
22187
  readTicket: async (id) => {
21952
22188
  const t = await trD.ticket(id);
21953
- return { description: String(t.description ?? ""), hasProject: !!t[cD.columns.slice], hasRepo: !!t[cD.columns.repo] };
22189
+ return { description: String(t.description ?? ""), hasProject: !!t[cD.columns.slice], hasRepo: !!t[cD.columns.repo], isDraft: t.status === cD.statuses.draft };
21954
22190
  },
22191
+ priorHold: (id) => readPeriscopePrior(cfg.ship.stateDir, id),
22192
+ remoteHasBranch: (_wt, branch) => remoteBranchExists(repoD.dir, remote, branch),
21955
22193
  updateTicket: async (id, p) => {
21956
22194
  const patch2 = {};
21957
- if (p.status === "new") {
22195
+ if (p.status === "released") {
22196
+ patch2[cD.columns.assignee] = null;
22197
+ if ((await trD.authorshipProbe()).issueHeld) patch2[cD.columns.heldBy] = null;
22198
+ if (p.description !== void 0) patch2.description = p.description;
22199
+ } else if (p.status === "new") {
21958
22200
  patch2[cD.columns.status] = "new";
21959
22201
  patch2[cD.columns.assignee] = null;
21960
22202
  if (p.description !== void 0) patch2.description = p.description;
@@ -21979,13 +22221,17 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21979
22221
  if (deleteBranch) gitOk(repoD.dir, ["branch", "-D", branch]);
21980
22222
  },
21981
22223
  setRowState: (id, state2) => trD.updatePeriscope(id, { state: state2 }),
21982
- forgetLocal: (id) => removePeriscopeSidecar(cfg.ship.stateDir, id),
22224
+ forgetLocal: (id) => {
22225
+ removePeriscopeSidecar(cfg.ship.stateDir, id);
22226
+ removePeriscopePrior(cfg.ship.stateDir, id);
22227
+ },
21983
22228
  say
21984
22229
  };
21985
22230
  if (dryRun) {
21986
22231
  say("dry-run", `would end the periscope for ${rowsD[0].ticket} (${a.mode})`);
21987
22232
  break;
21988
22233
  }
22234
+ process.chdir(repoD.dir);
21989
22235
  const res = await runPeriscopeDown(depsD, a);
21990
22236
  if (jsonP) process.stdout.write(`${JSON.stringify({ event: "done", ...res })}
21991
22237
  `);
@@ -22024,7 +22270,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
22024
22270
  const shellR = hit && resolveShell(hit.repo.config.shell ?? cfg.ship.shell);
22025
22271
  const resumed = await runPeriscopeResume({
22026
22272
  find: async () => hit && { row: hit.row, tag: hit.tag },
22027
- worktreeExists: (p) => existsSync36(p),
22273
+ worktreeExists: (p) => existsSync37(p),
22028
22274
  caps: periscopeCaps(cfg.ship),
22029
22275
  runHook: async (name, cwd, env) => {
22030
22276
  const script = hit?.repo.config.hooks[name];
@@ -22043,7 +22289,19 @@ ${resumed.url}
22043
22289
  `);
22044
22290
  break;
22045
22291
  }
22046
- const a = parsePeriscopeUpArgs(positional, { topic: value("topic"), host: value("host"), assignee: value("assignee"), isolate: flag("isolate"), json: jsonP });
22292
+ const parsedUp = parsePeriscopeUpArgs(positional, { topic: value("topic"), issue: value("issue"), host: value("host"), assignee: value("assignee"), isolate: flag("isolate"), json: jsonP });
22293
+ let a;
22294
+ if (parsedUp.repo !== void 0) a = { ...parsedUp, repo: parsedUp.repo };
22295
+ else {
22296
+ const here = locateDir(cfg, process.cwd(), (d) => gitOk(d, ["rev-parse", "--path-format=absolute", "--git-common-dir"]) ?? void 0);
22297
+ if (!here) {
22298
+ const where = cfg.routes.flatMap((r) => reposOf(r).map((x) => ` ${x.name}: ${x.dir}`));
22299
+ throw new PeriscopeError(`run it from inside a configured checkout, or name the repository (crew periscope up REPO). Configured checkouts:
22300
+ ${where.join("\n")}`);
22301
+ }
22302
+ a = { ...parsedUp, repo: here.repo.name, route: parsedUp.route ?? here.route.route };
22303
+ }
22304
+ const terminal = !!process.stdin.isTTY && !!process.stdout.isTTY && !jsonP;
22047
22305
  const candidatesP = a.route ? [findRoute(cfg, a.route)] : cfg.routes.filter((r) => r.enabled);
22048
22306
  const servedP = [];
22049
22307
  for (const cand of candidatesP) {
@@ -22075,10 +22333,11 @@ ${resumed.url}
22075
22333
  return h.unsafe ? h.detail : void 0;
22076
22334
  },
22077
22335
  askTopic: async () => {
22078
- if (!process.stdin.isTTY || jsonP) return void 0;
22336
+ if (!terminal) return void 0;
22079
22337
  const rl = createInterface({ input: process.stdin, output: process.stderr });
22080
22338
  try {
22081
- return await rl.question("Periscope topic: ");
22339
+ const answer = (await rl.question(`Periscope topic for ${a.repo} (blank for "${defaultTopic(a.repo, /* @__PURE__ */ new Date())}"): `)).trim();
22340
+ return answer || void 0;
22082
22341
  } finally {
22083
22342
  rl.close();
22084
22343
  }
@@ -22105,10 +22364,10 @@ ${resumed.url}
22105
22364
  },
22106
22365
  prepare: async (path) => {
22107
22366
  for (const f of repoP.config.worktrees.copy) {
22108
- const src = join40(repoP.dir, f);
22109
- if (!existsSync36(src)) continue;
22110
- mkdirSync23(dirOf2(join40(path, f)), { recursive: true });
22111
- copyFileSync2(src, join40(path, f));
22367
+ const src = join41(repoP.dir, f);
22368
+ if (!existsSync37(src)) continue;
22369
+ mkdirSync24(dirOf2(join41(path, f)), { recursive: true });
22370
+ copyFileSync2(src, join41(path, f));
22112
22371
  }
22113
22372
  const setup = repoP.config.hooks.setup;
22114
22373
  return setup ? runScript(setup, { cwd: path, shell: shellP, onLine: (l) => {
@@ -22132,22 +22391,73 @@ ${resumed.url}
22132
22391
  const closed = cP.statuses.resolved.includes("closed_obsolete") ? "closed_obsolete" : cP.statuses.wontFix;
22133
22392
  await trackerP.updateTicket(ticketId, { [cP.columns.status]: closed, [cP.columns.resolutionNote]: note });
22134
22393
  },
22394
+ existing: {
22395
+ load: async (number) => {
22396
+ const t = (await trackerP.ticketsByNumbers([number])).find((x) => String(x[cP.columns.key]).replace(/^\D+/, "").replace(/^0+(?=\d)/, "") === number);
22397
+ if (!t) return void 0;
22398
+ return {
22399
+ id: t.id,
22400
+ tag: t.issue_tag ?? t.issue_id,
22401
+ issueId: t.issue_id,
22402
+ prefix: t.project_issue_prefix,
22403
+ status: t.status,
22404
+ closed: cP.statuses.resolved.includes(t.status) || t.status === cP.statuses.wontFix,
22405
+ repoId: t[cP.columns.repo] ?? null,
22406
+ assignee: t[cP.columns.assignee] ?? null,
22407
+ heldByShipId: t[cP.columns.heldBy] ?? null
22408
+ };
22409
+ },
22410
+ repoId: repoP.id ?? null,
22411
+ repoName: a.repo,
22412
+ pairId,
22413
+ shipId: (await trackerP.authorshipProbe()).shipId,
22414
+ agentHolder: async (assigneeId) => {
22415
+ const row = crewP.find((r) => r.id === assigneeId);
22416
+ return row?.agent_id ? row.name ?? assigneeId : void 0;
22417
+ },
22418
+ hasLiveRun: (ticketRecordId) => liveRuns(cfg.ship.stateDir).some((r) => r.ticketRecordId === ticketRecordId),
22419
+ hold: async (id) => {
22420
+ const auth = await trackerP.authorshipProbe();
22421
+ await trackerP.updateTicket(id, {
22422
+ [cP.columns.assignee]: pairId,
22423
+ ...auth.issueHeld && auth.shipId ? { [cP.columns.heldBy]: auth.shipId } : {}
22424
+ });
22425
+ },
22426
+ restore: async (id, assignee) => {
22427
+ const auth = await trackerP.authorshipProbe();
22428
+ await trackerP.updateTicket(id, { [cP.columns.assignee]: assignee, ...auth.issueHeld ? { [cP.columns.heldBy]: null } : {} });
22429
+ },
22430
+ remember: (id, prior) => writePeriscopePrior(cfg.ship.stateDir, id, prior)
22431
+ },
22135
22432
  now: () => /* @__PURE__ */ new Date(),
22136
22433
  host: a.host ?? "localhost",
22137
22434
  say
22138
22435
  };
22139
22436
  if (dryRun) {
22140
22437
  await periscopePreflight(deps, a.repo);
22141
- say("dry-run", `would file "Periscope: ${a.topic ?? "<topic>"}" as ${cP.statuses.draft} for ${pairId}, cut a worktree for ${a.repo}, run setup${a.isolate ? ", isolate" : ""}, ports, handoff and preview on ${deps.host}, and record a Periscopes row (expires after ${caps.idleMinutes} minutes idle)`);
22438
+ if (a.issue) {
22439
+ const t = await deps.existing.load(a.issue);
22440
+ if (!t) throw new PeriscopeError(`no ticket numbered ${a.issue} on this route`);
22441
+ const refused = await existingRefusal(deps.existing, t);
22442
+ if (refused) throw new PeriscopeError(refused);
22443
+ 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`);
22444
+ break;
22445
+ }
22446
+ say("dry-run", `would file "Periscope: ${a.topic ?? defaultTopic(a.repo, /* @__PURE__ */ new Date())}" as ${cP.statuses.draft} for ${pairId}, cut a worktree for ${a.repo}, run setup${a.isolate ? ", isolate" : ""}, ports, handoff and preview on ${deps.host}, and record a Periscopes row (expires after ${caps.idleMinutes} minutes idle)`);
22142
22447
  break;
22143
22448
  }
22144
22449
  const res = await runPeriscopeUp(deps, a);
22145
22450
  if (jsonP) process.stdout.write(`${JSON.stringify({ event: "done", ...res })}
22146
22451
  `);
22147
- else process.stdout.write(`${res.ticket}
22148
- ${res.worktree}
22149
- ${res.url}
22452
+ else {
22453
+ process.stdout.write(upOutput(res, terminal));
22454
+ const opener = openerFor(process.platform);
22455
+ if (opener && shouldOpen({ open: flag("open"), noOpen: flag("no-open"), json: jsonP, terminal })) {
22456
+ const r = spawnSync7(opener, [res.url], { stdio: "ignore" });
22457
+ if (r.error || r.status !== 0) process.stderr.write(`crew periscope: could not open ${res.url} with ${opener}
22150
22458
  `);
22459
+ }
22460
+ }
22151
22461
  } catch (e) {
22152
22462
  if (e instanceof PeriscopeError || e instanceof ConfigError) {
22153
22463
  process.stderr.write(`crew periscope: ${e.message}
@@ -22332,7 +22642,7 @@ ${res.url}
22332
22642
  };
22333
22643
  const droppedRoutes = [];
22334
22644
  try {
22335
- const rawCfg = parseYaml2(readFileSync32(cfg.configFile, "utf8"));
22645
+ const rawCfg = parseYaml2(readFileSync33(cfg.configFile, "utf8"));
22336
22646
  for (const r of rawCfg?.routes ?? []) {
22337
22647
  if (typeof r?.route !== "string" || cfg.routes.some((x) => x.route === r.route)) continue;
22338
22648
  try {
@@ -22374,8 +22684,8 @@ ${res.url}
22374
22684
  process.stdout.write("crew reload: no daemon unit is running \u2014 nothing to reload (the next run reads crew.yaml fresh)\n");
22375
22685
  break;
22376
22686
  }
22377
- mkdirSync23(cfg.ship.stateDir, { recursive: true });
22378
- writeFileSync24(sentinel, `${(/* @__PURE__ */ new Date()).toISOString()}
22687
+ mkdirSync24(cfg.ship.stateDir, { recursive: true });
22688
+ writeFileSync25(sentinel, `${(/* @__PURE__ */ new Date()).toISOString()}
22379
22689
  `);
22380
22690
  process.stdout.write("crew reload: requested \u2014 the daemon finishes its in-flight pass, then relaunches with the new config\n");
22381
22691
  if (flag("now") && control?.stopCommand && control.startCommand) {
@@ -22426,7 +22736,7 @@ ${res.url}
22426
22736
  }
22427
22737
  case "log": {
22428
22738
  try {
22429
- const text = readFileSync32(cfg.ship.logFile, "utf8").split("\n");
22739
+ const text = readFileSync33(cfg.ship.logFile, "utf8").split("\n");
22430
22740
  process.stdout.write(`${text.slice(-(Number(value("tail")) || 40)).join("\n")}
22431
22741
  `);
22432
22742
  } catch {
@@ -22449,7 +22759,7 @@ ${res.url}
22449
22759
  };
22450
22760
  if (!route.resolved) fail(`route ${route.route} is not connected \u2014 run \`crew connect\` first`);
22451
22761
  const tracker = new Tracker(route, cfg.ship);
22452
- const configText = readFileSync32(cfg.configFile, "utf8");
22762
+ const configText = readFileSync33(cfg.configFile, "utf8");
22453
22763
  const cfgBase = dirname20(cfg.configFile);
22454
22764
  const configured = configuredRepos(configText, route.route, cfgBase);
22455
22765
  const dir = configured.find((r) => r.name === SAMPLE_REPO)?.dir ?? sampleDir(configured.map((r) => r.dir), route.reposBasePath, route.route.split("/")[0]);
@@ -22478,7 +22788,7 @@ ${res.url}
22478
22788
  }
22479
22789
  if (configured.some((r) => r.dir === dir)) {
22480
22790
  try {
22481
- writeFileSync24(cfg.configFile, planRepoRemove(configText, cfgBase, route.route, dir).text);
22791
+ writeFileSync25(cfg.configFile, planRepoRemove(configText, cfgBase, route.route, dir).text);
22482
22792
  } catch (e) {
22483
22793
  if (e instanceof ReposError) fail(e.message);
22484
22794
  throw e;
@@ -22486,29 +22796,29 @@ ${res.url}
22486
22796
  }
22487
22797
  if (row) {
22488
22798
  await tracker.deleteRepoRow(row.id);
22489
- if (existsSync36(resolvedFile)) {
22490
- const raw = JSON.parse(readFileSync32(resolvedFile, "utf8"));
22799
+ if (existsSync37(resolvedFile)) {
22800
+ const raw = JSON.parse(readFileSync33(resolvedFile, "utf8"));
22491
22801
  if (raw.repoNames) delete raw.repoNames[row.id];
22492
22802
  if (raw.repoRemotes) delete raw.repoRemotes[row.id];
22493
- writeFileSync24(resolvedFile, `${JSON.stringify(raw, null, 2)}
22803
+ writeFileSync25(resolvedFile, `${JSON.stringify(raw, null, 2)}
22494
22804
  `);
22495
22805
  }
22496
22806
  }
22497
- if (existsSync36(join40(dir, ".git")) && basename12(dir) === SAMPLE_REPO) rmSync16(dir, { recursive: true, force: true });
22807
+ if (existsSync37(join41(dir, ".git")) && basename12(dir) === SAMPLE_REPO) rmSync17(dir, { recursive: true, force: true });
22498
22808
  say("removed", { dir, closed }, `removed the sample repo${closed.length ? `, closed ${closed.join(", ")}` : ""}`);
22499
22809
  break;
22500
22810
  }
22501
22811
  const projectId = route.resolved.areaId ?? resolveRepoProject(await tracker.projectRows(), value("project"));
22502
- const fresh = !existsSync36(join40(dir, ".git"));
22503
- if (existsSync36(dir) && fresh && readdirSync17(dir).length > 0) fail(`${dir} exists and is not a git checkout \u2014 move it aside`);
22812
+ const fresh = !existsSync37(join41(dir, ".git"));
22813
+ if (existsSync37(dir) && fresh && readdirSync17(dir).length > 0) fail(`${dir} exists and is not a git checkout \u2014 move it aside`);
22504
22814
  if (dryRun) {
22505
22815
  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`);
22506
22816
  break;
22507
22817
  }
22508
22818
  if (fresh) {
22509
- mkdirSync23(dir, { recursive: true });
22510
- writeFileSync24(join40(dir, "README.md"), SAMPLE_README);
22511
- writeFileSync24(join40(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
22819
+ mkdirSync24(dir, { recursive: true });
22820
+ writeFileSync25(join41(dir, "README.md"), SAMPLE_README);
22821
+ writeFileSync25(join41(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
22512
22822
  const g = (...a) => {
22513
22823
  const r = spawnSync7("git", a, { cwd: dir, encoding: "utf8" });
22514
22824
  if (r.status !== 0) fail(`git ${a[0]} failed in ${dir}: ${(r.stderr || r.stdout).trim()}`);
@@ -22526,17 +22836,17 @@ ${res.url}
22526
22836
  spec: { platform: "unix", releaseMode: "integrate", ciProvider: "none" }
22527
22837
  });
22528
22838
  repoId = (await tracker.createRepoRow(payload)).id;
22529
- if (existsSync36(resolvedFile)) {
22530
- const raw = JSON.parse(readFileSync32(resolvedFile, "utf8"));
22839
+ if (existsSync37(resolvedFile)) {
22840
+ const raw = JSON.parse(readFileSync33(resolvedFile, "utf8"));
22531
22841
  raw.repoNames = { ...raw.repoNames, [repoId]: SAMPLE_REPO };
22532
22842
  raw.repoRemotes = { ...raw.repoRemotes, [repoId]: SAMPLE_REMOTE };
22533
- writeFileSync24(resolvedFile, `${JSON.stringify(raw, null, 2)}
22843
+ writeFileSync25(resolvedFile, `${JSON.stringify(raw, null, 2)}
22534
22844
  `);
22535
22845
  }
22536
22846
  }
22537
22847
  if (!configured.some((r) => r.dir === dir)) {
22538
22848
  try {
22539
- writeFileSync24(cfg.configFile, planRepoAdd({ text: configText, base: cfgBase, route: route.route, path: dir, name: SAMPLE_REPO }).text);
22849
+ writeFileSync25(cfg.configFile, planRepoAdd({ text: configText, base: cfgBase, route: route.route, path: dir, name: SAMPLE_REPO }).text);
22540
22850
  } catch (e) {
22541
22851
  if (e instanceof ReposError) fail(e.message);
22542
22852
  throw e;
@@ -22871,7 +23181,7 @@ watermark: ${state.watermark()}
22871
23181
  }
22872
23182
  for (const r of repos) {
22873
23183
  const remote = r.config.branch.remote;
22874
- if (existsSync36(r.dir)) {
23184
+ if (existsSync37(r.dir)) {
22875
23185
  pushAuth.push({
22876
23186
  route: c.route,
22877
23187
  repo: r.name,
@@ -22948,7 +23258,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
22948
23258
  }
22949
23259
  try {
22950
23260
  const evFile = eventFileFor(cfg.ship.stateDir);
22951
- const evSize = existsSync36(evFile) ? statSync11(evFile).size : 0;
23261
+ const evSize = existsSync37(evFile) ? statSync11(evFile).size : 0;
22952
23262
  process.stdout.write(
22953
23263
  evSize > cfg.ship.eventsRotateBytes * 4 ? `events log: WARN ${evFile} is ${Math.round(evSize / 1048576)} MB (> 4x eventsRotateBytes); run \`crew reap\` to rotate it
22954
23264
  ` : "events log: ok\n"
@@ -22959,7 +23269,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
22959
23269
  const unitPlan = planInstall(cfg.ship, CREW_HOME, host, detectSystemd(), "run");
22960
23270
  const abandons = unitAbandonsAgents(unitPlan, (p) => {
22961
23271
  try {
22962
- return readFileSync32(p, "utf8");
23272
+ return readFileSync33(p, "utf8");
22963
23273
  } catch {
22964
23274
  return null;
22965
23275
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.88.0",
3
+ "version": "0.90.0",
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",