@tablation/crew 0.83.0 → 0.85.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.
- package/dist/cli.js +364 -19
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -34,6 +34,29 @@ function doctorBaseReport(r, base, health) {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// src/periscopes.ts
|
|
38
|
+
import { lookup } from "dns/promises";
|
|
39
|
+
function periscopeRoutes(cfg) {
|
|
40
|
+
return cfg.routes.filter((r) => r.enabled && r.hostPeriscopes).map((r) => r.route);
|
|
41
|
+
}
|
|
42
|
+
function periscopeSummary(cfg) {
|
|
43
|
+
const routes = periscopeRoutes(cfg);
|
|
44
|
+
const limits = `max ${cfg.ship.maxPeriscopes}, idle ${cfg.ship.periscopeIdleMinutes} min`;
|
|
45
|
+
if (routes.length === 0) return `off (no enabled route sets hostPeriscopes: true; ${limits})`;
|
|
46
|
+
return `ON for ${routes.join(", ")} (host ${cfg.ship.previewHost ?? "NOT SET (ship.previewHost)"}, ${limits})`;
|
|
47
|
+
}
|
|
48
|
+
async function previewHostProblem(cfg, resolve6 = (h) => lookup(h)) {
|
|
49
|
+
if (periscopeRoutes(cfg).length === 0) return void 0;
|
|
50
|
+
const host = cfg.ship.previewHost;
|
|
51
|
+
if (!host) return "ship.previewHost is not set \u2014 a route hosts periscopes, so Passengers have no host to be sent to";
|
|
52
|
+
try {
|
|
53
|
+
await resolve6(host);
|
|
54
|
+
} catch {
|
|
55
|
+
return `ship.previewHost "${host}" does not resolve from this machine`;
|
|
56
|
+
}
|
|
57
|
+
return void 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
37
60
|
// src/config.ts
|
|
38
61
|
import { readFileSync as readFileSync8, existsSync as existsSync7 } from "fs";
|
|
39
62
|
import { resolve, dirname as dirname3, isAbsolute, join as join9, basename as basename3 } from "path";
|
|
@@ -2034,6 +2057,8 @@ var ROLE_LABEL = {
|
|
|
2034
2057
|
};
|
|
2035
2058
|
var ConfigError = class extends Error {
|
|
2036
2059
|
};
|
|
2060
|
+
var DEFAULT_MAX_PERISCOPES = 3;
|
|
2061
|
+
var DEFAULT_PERISCOPE_IDLE_MINUTES = 120;
|
|
2037
2062
|
function parseCloneScheme(v) {
|
|
2038
2063
|
if (v === void 0 || v === null || v === "") return void 0;
|
|
2039
2064
|
if (v === "ssh" || v === "https") return v;
|
|
@@ -2065,7 +2090,10 @@ var SHIP_KEYS = /* @__PURE__ */ new Set([
|
|
|
2065
2090
|
"relayHttpPort",
|
|
2066
2091
|
"relayPublicDomain",
|
|
2067
2092
|
"passengerImage",
|
|
2068
|
-
"cloneScheme"
|
|
2093
|
+
"cloneScheme",
|
|
2094
|
+
"previewHost",
|
|
2095
|
+
"maxPeriscopes",
|
|
2096
|
+
"periscopeIdleMinutes"
|
|
2069
2097
|
]);
|
|
2070
2098
|
var ROUTE_KEYS = /* @__PURE__ */ new Set([
|
|
2071
2099
|
"route",
|
|
@@ -2088,6 +2116,7 @@ var ROUTE_KEYS = /* @__PURE__ */ new Set([
|
|
|
2088
2116
|
"resolved",
|
|
2089
2117
|
"promptSet",
|
|
2090
2118
|
"hostPassengers",
|
|
2119
|
+
"hostPeriscopes",
|
|
2091
2120
|
"toolchain"
|
|
2092
2121
|
]);
|
|
2093
2122
|
function parseWorktreeName(v, where, file) {
|
|
@@ -2399,6 +2428,14 @@ Create the first of those:
|
|
|
2399
2428
|
if (!Number.isInteger(streamRetentionDays) || streamRetentionDays < 1) {
|
|
2400
2429
|
missing.add(`ship.streamRetentionDays must be a positive integer (got "${shipRaw.streamRetentionDays}")`);
|
|
2401
2430
|
}
|
|
2431
|
+
const maxPeriscopes = shipRaw.maxPeriscopes === void 0 ? DEFAULT_MAX_PERISCOPES : Number(shipRaw.maxPeriscopes);
|
|
2432
|
+
if (!Number.isInteger(maxPeriscopes) || maxPeriscopes < 1) {
|
|
2433
|
+
missing.add(`ship.maxPeriscopes must be a positive integer (got "${shipRaw.maxPeriscopes}")`);
|
|
2434
|
+
}
|
|
2435
|
+
const periscopeIdleMinutes = shipRaw.periscopeIdleMinutes === void 0 ? DEFAULT_PERISCOPE_IDLE_MINUTES : Number(shipRaw.periscopeIdleMinutes);
|
|
2436
|
+
if (!Number.isInteger(periscopeIdleMinutes) || periscopeIdleMinutes < 1) {
|
|
2437
|
+
missing.add(`ship.periscopeIdleMinutes must be a positive integer (got "${shipRaw.periscopeIdleMinutes}")`);
|
|
2438
|
+
}
|
|
2402
2439
|
const eventsRotateBytes = shipRaw.eventsRotateBytes === void 0 ? DEFAULT_EVENTS_ROTATE_BYTES : Number(shipRaw.eventsRotateBytes);
|
|
2403
2440
|
if (!Number.isInteger(eventsRotateBytes) || eventsRotateBytes < 1) {
|
|
2404
2441
|
missing.add(`ship.eventsRotateBytes must be a positive integer (got "${shipRaw.eventsRotateBytes}")`);
|
|
@@ -2468,7 +2505,10 @@ Create the first of those:
|
|
|
2468
2505
|
relayHttpPort: shipRaw.relayHttpPort !== void 0 ? Number(shipRaw.relayHttpPort) : 443,
|
|
2469
2506
|
relayPublicDomain: shipRaw.relayPublicDomain ? String(shipRaw.relayPublicDomain) : void 0,
|
|
2470
2507
|
passengerImage: shipRaw.passengerImage ? String(shipRaw.passengerImage) : void 0,
|
|
2471
|
-
cloneScheme: parseCloneScheme(shipRaw.cloneScheme)
|
|
2508
|
+
cloneScheme: parseCloneScheme(shipRaw.cloneScheme),
|
|
2509
|
+
previewHost: shipRaw.previewHost ? String(shipRaw.previewHost).trim() || void 0 : void 0,
|
|
2510
|
+
maxPeriscopes,
|
|
2511
|
+
periscopeIdleMinutes
|
|
2472
2512
|
},
|
|
2473
2513
|
routes,
|
|
2474
2514
|
crewHome,
|
|
@@ -2569,6 +2609,9 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome, t
|
|
|
2569
2609
|
missing.add(`${where}.toolchain "${toolchain}" is not defined in ship.toolchains (have: ${[...toolchainNames].join(", ")})`);
|
|
2570
2610
|
}
|
|
2571
2611
|
const route = missing.req(c?.route, `${where}.route`);
|
|
2612
|
+
if (c?.hostPeriscopes === true && c?.hostPassengers !== true) {
|
|
2613
|
+
missing.add(`${where} (route "${route}") sets hostPeriscopes: true without hostPassengers: true \u2014 a Passenger must be able to board before they can open a periscope`);
|
|
2614
|
+
}
|
|
2572
2615
|
const routeParts = route ? route.split("/") : [];
|
|
2573
2616
|
const routeWellFormed = routeParts.length === 2 && routeParts.every(Boolean);
|
|
2574
2617
|
if (route && !routeWellFormed) {
|
|
@@ -2643,7 +2686,8 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome, t
|
|
|
2643
2686
|
},
|
|
2644
2687
|
release: parseReleaseOverride(c?.release, where, file),
|
|
2645
2688
|
branch: parseBranchOverride(c?.branch, where, file),
|
|
2646
|
-
hostPassengers: c?.hostPassengers === true
|
|
2689
|
+
hostPassengers: c?.hostPassengers === true,
|
|
2690
|
+
hostPeriscopes: c?.hostPeriscopes === true
|
|
2647
2691
|
};
|
|
2648
2692
|
const resolvedFile = routeWellFormed ? readResolvedFile(stateDir, route) : void 0;
|
|
2649
2693
|
result.resolved = parseResolved(resolvedFile ?? c?.resolved, missing, where);
|
|
@@ -5562,7 +5606,7 @@ function validateContract(c) {
|
|
|
5562
5606
|
}
|
|
5563
5607
|
var EVENT_LOG_MISSING = "the Issues table has no event log \u2014 crew writes its audit trail there (CREW-1574). In the app, open the Issues table's Data & lifecycle tab and turn on Event log, then rerun.";
|
|
5564
5608
|
var PERISCOPE_COLUMNS = [
|
|
5565
|
-
"
|
|
5609
|
+
"issue_id",
|
|
5566
5610
|
"ship_id",
|
|
5567
5611
|
"url",
|
|
5568
5612
|
"host",
|
|
@@ -6835,6 +6879,32 @@ var Tracker = class {
|
|
|
6835
6879
|
const have = await this.columnNames(modelId);
|
|
6836
6880
|
return { present: true, modelId, missing: PERISCOPE_COLUMNS.filter((c) => !have.has(c)) };
|
|
6837
6881
|
}
|
|
6882
|
+
/**
|
|
6883
|
+
* How many `live` Periscopes rows this ship holds (EPIC-077). Zero when the
|
|
6884
|
+
* workspace has no Periscopes table or this ship has no Ships row yet (the
|
|
6885
|
+
* row's `ship_id` could not name it), so the cap only ever counts rows it
|
|
6886
|
+
* can attribute.
|
|
6887
|
+
*/
|
|
6888
|
+
async livePeriscopeCount() {
|
|
6889
|
+
const modelId = this.route.resolved?.periscopesModelId;
|
|
6890
|
+
const { shipId } = await this.authorshipProbe();
|
|
6891
|
+
if (!modelId || !shipId) return 0;
|
|
6892
|
+
const rows = await this.client.records.list(modelId, {
|
|
6893
|
+
filters: encodeFilters([
|
|
6894
|
+
{ columnName: "ship_id", operator: "EQ", value: shipId },
|
|
6895
|
+
{ columnName: "state", operator: "EQ", value: "live" }
|
|
6896
|
+
]),
|
|
6897
|
+
limit: 200
|
|
6898
|
+
});
|
|
6899
|
+
return rows.length;
|
|
6900
|
+
}
|
|
6901
|
+
/** Records a Periscopes row for this ship (EPIC-077); `ship_id` is stamped here, never by the caller. */
|
|
6902
|
+
async createPeriscope(fields) {
|
|
6903
|
+
const modelId = this.route.resolved?.periscopesModelId;
|
|
6904
|
+
if (!modelId) throw new Error("this workspace has no Periscopes table");
|
|
6905
|
+
const { shipId } = await this.authorshipProbe();
|
|
6906
|
+
return this.client.records.create(modelId, { ...fields, ...shipId ? { ship_id: shipId } : {} });
|
|
6907
|
+
}
|
|
6838
6908
|
/**
|
|
6839
6909
|
* The authorship columns this workspace is missing, as `table.column` names
|
|
6840
6910
|
* for `crew doctor` to report (CREW-1371). Empty on a fully updated install.
|
|
@@ -7762,7 +7832,7 @@ function skillMarkerNames(text) {
|
|
|
7762
7832
|
}
|
|
7763
7833
|
return names;
|
|
7764
7834
|
}
|
|
7765
|
-
function expandSkillMarkers(text,
|
|
7835
|
+
function expandSkillMarkers(text, lookup2, opts = {}) {
|
|
7766
7836
|
const maxDepth = opts.maxDepth ?? MAX_INCLUDE_DEPTH;
|
|
7767
7837
|
const maxBytes = opts.maxBytes ?? MAX_INCLUDED_BYTES;
|
|
7768
7838
|
const warnings = [];
|
|
@@ -7778,7 +7848,7 @@ function expandSkillMarkers(text, lookup, opts = {}) {
|
|
|
7778
7848
|
warnings.push(`skill include "${name}" left literal: nested deeper than ${maxDepth} levels`);
|
|
7779
7849
|
return literal;
|
|
7780
7850
|
}
|
|
7781
|
-
const body =
|
|
7851
|
+
const body = lookup2(name);
|
|
7782
7852
|
if (body === void 0) throw new SkillIncludeError(`no skill named "${name}" for the include ${literal}`);
|
|
7783
7853
|
const trimmed = body.replace(/^\n+/, "").replace(/\n+$/, "");
|
|
7784
7854
|
const bytes = Buffer.byteLength(trimmed, "utf8");
|
|
@@ -9058,8 +9128,8 @@ function spawnAgent(plan, emit, hintMoved) {
|
|
|
9058
9128
|
const meta = { pid: child.pid ?? null };
|
|
9059
9129
|
obs.onClaim = () => writeRunMeta(id, startedAt, obs.liveTicket, meta);
|
|
9060
9130
|
if (child.pid !== void 0) writeRunMeta(id, startedAt, obs.liveTicket, meta);
|
|
9061
|
-
const
|
|
9062
|
-
const timer = setInterval(() => obs.feed(
|
|
9131
|
+
const tail2 = new FileTail(plan.streamPath);
|
|
9132
|
+
const timer = setInterval(() => obs.feed(tail2.poll()), TAIL_POLL_MS);
|
|
9063
9133
|
let spawnFailed = false;
|
|
9064
9134
|
child.on("error", (err) => {
|
|
9065
9135
|
spawnFailed = true;
|
|
@@ -9068,7 +9138,7 @@ function spawnAgent(plan, emit, hintMoved) {
|
|
|
9068
9138
|
});
|
|
9069
9139
|
child.on("close", async (code) => {
|
|
9070
9140
|
clearInterval(timer);
|
|
9071
|
-
obs.feed(
|
|
9141
|
+
obs.feed(tail2.poll());
|
|
9072
9142
|
obs.end();
|
|
9073
9143
|
await endSink(eventsSink);
|
|
9074
9144
|
if (spawnFailed) return;
|
|
@@ -9087,20 +9157,20 @@ async function reattachRun(streamPath, sc, emit, o = {}) {
|
|
|
9087
9157
|
const id = { ...sc, streamPath };
|
|
9088
9158
|
const eventsSink = openSink(sc.eventsPath);
|
|
9089
9159
|
const obs = new RunObserver(id, adapter, eventsSink, emit.cycle);
|
|
9090
|
-
const
|
|
9160
|
+
const tail2 = new FileTail(streamPath);
|
|
9091
9161
|
obs.muted = true;
|
|
9092
|
-
obs.feed(
|
|
9162
|
+
obs.feed(tail2.poll());
|
|
9093
9163
|
obs.muted = false;
|
|
9094
9164
|
const adopted = isAlive(sc);
|
|
9095
9165
|
if (adopted) {
|
|
9096
9166
|
o.onAdopted?.();
|
|
9097
9167
|
const pollMs = o.pollMs ?? TAIL_POLL_MS;
|
|
9098
9168
|
while (isAlive(sc)) {
|
|
9099
|
-
obs.feed(
|
|
9169
|
+
obs.feed(tail2.poll());
|
|
9100
9170
|
await new Promise((r) => setTimeout(r, pollMs));
|
|
9101
9171
|
}
|
|
9102
9172
|
}
|
|
9103
|
-
obs.feed(
|
|
9173
|
+
obs.feed(tail2.poll());
|
|
9104
9174
|
obs.end();
|
|
9105
9175
|
await endSink(eventsSink);
|
|
9106
9176
|
const interrupted = obs.sawResult ? void 0 : adopted ? "no-result" : "daemon-exit";
|
|
@@ -9648,7 +9718,9 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
|
|
|
9648
9718
|
"toolchain",
|
|
9649
9719
|
"workspace",
|
|
9650
9720
|
"workspace-id",
|
|
9651
|
-
"write"
|
|
9721
|
+
"write",
|
|
9722
|
+
"topic",
|
|
9723
|
+
"assignee"
|
|
9652
9724
|
]);
|
|
9653
9725
|
function positionalArgs(argv2) {
|
|
9654
9726
|
const out = [];
|
|
@@ -12988,7 +13060,7 @@ async function isolateAndRetry(o, name, output, merged, preMerge, decision, fres
|
|
|
12988
13060
|
}
|
|
12989
13061
|
if (culprits.length === 0 && truncated) {
|
|
12990
13062
|
let lo = 2, hi = applied.length;
|
|
12991
|
-
let
|
|
13063
|
+
let tail2 = lastLines(output, HOOK_FAILURE_TAIL_LINES);
|
|
12992
13064
|
while (lo < hi) {
|
|
12993
13065
|
const mid = Math.floor((lo + hi) / 2);
|
|
12994
13066
|
gitOk(o.cwd, ["reset", "--hard", preMerge]);
|
|
@@ -13003,12 +13075,12 @@ async function isolateAndRetry(o, name, output, merged, preMerge, decision, fres
|
|
|
13003
13075
|
const r = clean ? await hook(o, name) : null;
|
|
13004
13076
|
if (r && r.code !== 0) {
|
|
13005
13077
|
hi = mid;
|
|
13006
|
-
|
|
13078
|
+
tail2 = lastLines(r.output, HOOK_FAILURE_TAIL_LINES);
|
|
13007
13079
|
} else lo = mid + 1;
|
|
13008
13080
|
}
|
|
13009
13081
|
const owner = applied[hi - 1];
|
|
13010
13082
|
o.emit.warn(`${owner.branch} is the first branch whose prefix fails the ${name} hook (bisected over ${applied.length})`, { ticket: owner.ticket.issue_id });
|
|
13011
|
-
culprits.push({ c: owner, how: "pair", sibling: applied[hi - 2], tail });
|
|
13083
|
+
culprits.push({ c: owner, how: "pair", sibling: applied[hi - 2], tail: tail2 });
|
|
13012
13084
|
}
|
|
13013
13085
|
}
|
|
13014
13086
|
}
|
|
@@ -16329,6 +16401,139 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
|
|
|
16329
16401
|
// src/cli.ts
|
|
16330
16402
|
import { realpathSync as realpathSync3, readFileSync as readFileSync31, existsSync as existsSync35, mkdirSync as mkdirSync22, writeFileSync as writeFileSync23, rmSync as rmSync15, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync3, statSync as statSync10, readdirSync as readdirSync16 } from "fs";
|
|
16331
16403
|
import { createInterface } from "readline/promises";
|
|
16404
|
+
|
|
16405
|
+
// src/periscope-cmd.ts
|
|
16406
|
+
var PeriscopeError = class extends Error {
|
|
16407
|
+
};
|
|
16408
|
+
var DEFAULT_MAX_PERISCOPES2 = 3;
|
|
16409
|
+
var DEFAULT_PERISCOPE_IDLE_MINUTES2 = 120;
|
|
16410
|
+
function parsePeriscopeUpArgs(positional2, flags) {
|
|
16411
|
+
const rest = positional2.slice(2);
|
|
16412
|
+
if (rest.length === 0 || rest.length > 2) {
|
|
16413
|
+
throw new PeriscopeError("usage: crew periscope up [route] REPO [--topic TEXT] [--isolate] [--host HOST] [--assignee CREW_ROW_ID] [--json]");
|
|
16414
|
+
}
|
|
16415
|
+
const topic = flags.topic?.trim();
|
|
16416
|
+
return {
|
|
16417
|
+
...rest.length === 2 ? { route: rest[0] } : {},
|
|
16418
|
+
repo: rest[rest.length - 1],
|
|
16419
|
+
...topic ? { topic } : {},
|
|
16420
|
+
isolate: flags.isolate,
|
|
16421
|
+
...flags.host ? { host: flags.host } : {},
|
|
16422
|
+
...flags.assignee ? { assignee: flags.assignee } : {},
|
|
16423
|
+
json: flags.json
|
|
16424
|
+
};
|
|
16425
|
+
}
|
|
16426
|
+
function parseEnvLines(output) {
|
|
16427
|
+
const env = {};
|
|
16428
|
+
for (const line of output.split("\n")) {
|
|
16429
|
+
const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim());
|
|
16430
|
+
if (m) env[m[1]] = m[2].replace(/^"(.*)"$/, "$1");
|
|
16431
|
+
}
|
|
16432
|
+
return env;
|
|
16433
|
+
}
|
|
16434
|
+
function previewUrlFrom(output) {
|
|
16435
|
+
const last = output.split("\n").map((l) => l.trim()).filter(Boolean).pop();
|
|
16436
|
+
return last && /^https?:\/\/\S+$/i.test(last) ? last : void 0;
|
|
16437
|
+
}
|
|
16438
|
+
function periscopeCaps(l) {
|
|
16439
|
+
return {
|
|
16440
|
+
max: l.maxPeriscopes ?? DEFAULT_MAX_PERISCOPES2,
|
|
16441
|
+
idleMinutes: l.periscopeIdleMinutes ?? DEFAULT_PERISCOPE_IDLE_MINUTES2
|
|
16442
|
+
};
|
|
16443
|
+
}
|
|
16444
|
+
var tail = (s, n = 20) => s.trimEnd().split("\n").slice(-n).join("\n");
|
|
16445
|
+
function progressComment(url, handoff) {
|
|
16446
|
+
return [
|
|
16447
|
+
`Periscope is up: ${url}`,
|
|
16448
|
+
...handoff?.trim() ? ["", handoff.trim()] : []
|
|
16449
|
+
].join("\n");
|
|
16450
|
+
}
|
|
16451
|
+
async function periscopePreflight(d, repo) {
|
|
16452
|
+
if (!d.previewHook) {
|
|
16453
|
+
throw new PeriscopeError(`${repo} declares no \`preview\` hook, so it cannot host a periscope \u2014 add one to its .crew.yaml (see the repository spec page, "hooks.preview")`);
|
|
16454
|
+
}
|
|
16455
|
+
if (!d.hasPeriscopesTable) {
|
|
16456
|
+
throw new PeriscopeError("this workspace has no Periscopes table \u2014 update the Issues template and run `crew connect`");
|
|
16457
|
+
}
|
|
16458
|
+
const live = await d.liveCount();
|
|
16459
|
+
if (live >= d.caps.max) {
|
|
16460
|
+
throw new PeriscopeError(`this ship already hosts ${live} live periscope(s) (ship.maxPeriscopes is ${d.caps.max}) \u2014 discard one first`);
|
|
16461
|
+
}
|
|
16462
|
+
}
|
|
16463
|
+
async function runPeriscopeUp(d, args) {
|
|
16464
|
+
await periscopePreflight(d, args.repo);
|
|
16465
|
+
const topic = args.topic ?? (await d.askTopic())?.trim();
|
|
16466
|
+
if (!topic) throw new PeriscopeError("a topic is required: pass --topic TEXT");
|
|
16467
|
+
const unsafe = d.baseProblem();
|
|
16468
|
+
if (unsafe) throw new PeriscopeError(`DO NOT CUT A WORKTREE FROM THIS BASE \u2014 ${unsafe}`);
|
|
16469
|
+
const draft = await d.fileDraft(`Periscope: ${topic}`);
|
|
16470
|
+
d.say("filed", `filed draft ${draft.tag}`, { ticket: draft.tag, ticketRecordId: draft.id });
|
|
16471
|
+
let wt;
|
|
16472
|
+
const abandon = async (why) => {
|
|
16473
|
+
if (wt) await d.teardown(wt.path, wt.branch).catch(() => {
|
|
16474
|
+
});
|
|
16475
|
+
await d.closeDraft(draft.id, `Periscope abandoned: ${why.split("\n")[0]}`).catch(() => {
|
|
16476
|
+
});
|
|
16477
|
+
throw new PeriscopeError(why);
|
|
16478
|
+
};
|
|
16479
|
+
try {
|
|
16480
|
+
wt = d.cutWorktree(draft);
|
|
16481
|
+
} catch (e) {
|
|
16482
|
+
return abandon(e.message);
|
|
16483
|
+
}
|
|
16484
|
+
d.say("worktree", `cut ${wt.path} on ${wt.branch}`, { worktree: wt.path, branch: wt.branch });
|
|
16485
|
+
const prepared = await d.prepare(wt.path);
|
|
16486
|
+
if (prepared && prepared.code !== 0) {
|
|
16487
|
+
return abandon(`setup failed (exit ${prepared.code})
|
|
16488
|
+
${tail(prepared.output)}`);
|
|
16489
|
+
}
|
|
16490
|
+
const env = { CREW_TICKET: draft.tag, CREW_BRANCH: wt.branch };
|
|
16491
|
+
const ports = await d.runHook("ports", wt.path, env);
|
|
16492
|
+
if (ports) Object.assign(env, parseEnvLines(ports.output));
|
|
16493
|
+
if (args.isolate) {
|
|
16494
|
+
const iso = await d.runHook("isolate", wt.path, env);
|
|
16495
|
+
if (iso && iso.code !== 0) return abandon(`isolate failed (exit ${iso.code})
|
|
16496
|
+
${tail(iso.output)}`);
|
|
16497
|
+
if (iso) Object.assign(env, parseEnvLines(iso.output));
|
|
16498
|
+
}
|
|
16499
|
+
const handoff = await d.runHook("handoff", wt.path, env);
|
|
16500
|
+
if (handoff && handoff.code !== 0) return abandon(`handoff failed (exit ${handoff.code})
|
|
16501
|
+
${tail(handoff.output)}`);
|
|
16502
|
+
const preview = await d.runHook("preview", wt.path, { ...env, CREW_PREVIEW_HOST: d.host });
|
|
16503
|
+
if (!preview || preview.code !== 0) {
|
|
16504
|
+
return abandon(`preview exited ${preview?.code ?? "without running"}
|
|
16505
|
+
${tail(preview?.output ?? "")}`);
|
|
16506
|
+
}
|
|
16507
|
+
const url = previewUrlFrom(preview.output);
|
|
16508
|
+
if (!url) return abandon(`preview printed no URL as its last line
|
|
16509
|
+
${tail(preview.output)}`);
|
|
16510
|
+
d.say("preview", `preview at ${url}`, { url });
|
|
16511
|
+
const now = d.now();
|
|
16512
|
+
let row;
|
|
16513
|
+
try {
|
|
16514
|
+
row = await d.createRow({
|
|
16515
|
+
issue_id: draft.id,
|
|
16516
|
+
url,
|
|
16517
|
+
host: d.host,
|
|
16518
|
+
worktree: wt.path,
|
|
16519
|
+
branch: wt.branch,
|
|
16520
|
+
state: "live",
|
|
16521
|
+
isolated: args.isolate,
|
|
16522
|
+
started_at: now.toISOString(),
|
|
16523
|
+
last_activity_at: now.toISOString(),
|
|
16524
|
+
expires_at: new Date(now.getTime() + d.caps.idleMinutes * 6e4).toISOString()
|
|
16525
|
+
});
|
|
16526
|
+
} catch (e) {
|
|
16527
|
+
return abandon(`recording the Periscopes row failed: ${e.message}`);
|
|
16528
|
+
}
|
|
16529
|
+
d.say("recorded", "recorded the Periscopes row", { periscopeRecordId: row.id });
|
|
16530
|
+
await d.comment(draft.id, progressComment(url, handoff?.output)).catch((e) => {
|
|
16531
|
+
d.say("warning", `progress comment not posted: ${e.message}`);
|
|
16532
|
+
});
|
|
16533
|
+
return { ticket: draft.tag, ticketRecordId: draft.id, worktree: wt.path, branch: wt.branch, url, periscopeRecordId: row.id };
|
|
16534
|
+
}
|
|
16535
|
+
|
|
16536
|
+
// src/cli.ts
|
|
16332
16537
|
import { dirname as dirOf, resolve as resolvePath } from "path";
|
|
16333
16538
|
|
|
16334
16539
|
// src/ssh-keys.ts
|
|
@@ -17789,6 +17994,8 @@ function usage(problem, requested = false) {
|
|
|
17789
17994
|
crew reap [route] kill orphaned servers, drop worktrees for closed tickets
|
|
17790
17995
|
crew drop [route] NNN remove a merged ticket's worktree and branch
|
|
17791
17996
|
crew rotate-passenger-url [route] force a Host Passengers tunnel to reconnect with a fresh public URL
|
|
17997
|
+
crew periscope up [route] REPO --topic TEXT [--isolate] [--host H] [--assignee ID] [--dry-run] [--json]
|
|
17998
|
+
file a draft ticket, cut and prepare its worktree, start the repo's preview hook and record a Periscopes row (alias: start)
|
|
17792
17999
|
crew worktree [route] NNN cut (or refresh) this run's worktree for a ticket from the remote; prints its path
|
|
17793
18000
|
crew unassign [route] NNN hand back a session's ticket \u2014 clears assignee, next cycle picks it up
|
|
17794
18001
|
crew sync [route] fast-forward the checkout and its worktrees from the remote
|
|
@@ -17881,6 +18088,7 @@ var KNOWN_COMMANDS = /* @__PURE__ */ new Set([
|
|
|
17881
18088
|
"merge",
|
|
17882
18089
|
"passengers",
|
|
17883
18090
|
"pause",
|
|
18091
|
+
"periscope",
|
|
17884
18092
|
"poll",
|
|
17885
18093
|
"ports",
|
|
17886
18094
|
"projects",
|
|
@@ -18084,7 +18292,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
18084
18292
|
const DAEMON_SUBCOMMANDS = /* @__PURE__ */ new Set(["start", "stop", "status", "restart"]);
|
|
18085
18293
|
const worktreeArgs = command === "worktree" ? splitWorktreeArgs(positional, cfg.routes.map((r) => r.route)) : void 0;
|
|
18086
18294
|
const named = worktreeArgs ? worktreeArgs.route : command === "daemon" && DAEMON_SUBCOMMANDS.has(positional[1] ?? "") ? positional[2] : positional[1];
|
|
18087
|
-
const fleetWide = command === "inbox" || command === "connect" || command === "disconnect" || command === "agents" || command === "skills" || command === "logbook" || command === "repos" || command === "ship" || command === "projects" || command === "whoami" || releaseFleetWide || command === "pause" || command === "resume" || command === "reload" || FLEET_CAPABLE.has(command) && !named && cfg.routes.length > 1 || worktreeArgs !== void 0 && !named && cfg.routes.length > 1;
|
|
18295
|
+
const fleetWide = command === "inbox" || command === "connect" || command === "disconnect" || command === "agents" || command === "skills" || command === "logbook" || command === "repos" || command === "ship" || command === "projects" || command === "periscope" || command === "whoami" || releaseFleetWide || command === "pause" || command === "resume" || command === "reload" || FLEET_CAPABLE.has(command) && !named && cfg.routes.length > 1 || worktreeArgs !== void 0 && !named && cfg.routes.length > 1;
|
|
18088
18296
|
let route;
|
|
18089
18297
|
try {
|
|
18090
18298
|
route = fleetWide ? cfg.routes[0] : findRoute(cfg, named);
|
|
@@ -20385,6 +20593,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
20385
20593
|
platform: cfg.ship.platform,
|
|
20386
20594
|
machineId,
|
|
20387
20595
|
hostPassengers: firstRun?.answers.hostPassengers ?? existingRoute?.hostPassengers === true,
|
|
20596
|
+
hostPeriscopes: existingRoute?.hostPeriscopes === true,
|
|
20388
20597
|
sshPublicKey,
|
|
20389
20598
|
lastSyncedName: (r) => readSyncedName(resolvedPathFor(cfg.ship.stateDir, r))
|
|
20390
20599
|
};
|
|
@@ -21164,7 +21373,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
21164
21373
|
const hitsWt = /* @__PURE__ */ new Map();
|
|
21165
21374
|
for (const cand of candidatesWt) {
|
|
21166
21375
|
const tr = new Tracker(cand, cfg.ship);
|
|
21167
|
-
const hit = (await tr.openTickets()).find((t) => t[tr.contract.columns.key] === `ISSUE-${nWt}`);
|
|
21376
|
+
const hit = (await tr.openTickets()).find((t) => t[tr.contract.columns.key] === `ISSUE-${nWt}`) ?? (await tr.ticketsByNumbers([nWt])).find((t) => t[tr.contract.columns.key] === `ISSUE-${nWt}` && t[tr.contract.columns.status] === tr.contract.statuses.draft);
|
|
21168
21377
|
if (hit) hitsWt.set(cand.route, hit);
|
|
21169
21378
|
lookedWt.push({
|
|
21170
21379
|
route: cand.route,
|
|
@@ -21211,6 +21420,131 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
21211
21420
|
if (!planIsUsable(planWt)) process.exitCode = 1;
|
|
21212
21421
|
break;
|
|
21213
21422
|
}
|
|
21423
|
+
case "periscope": {
|
|
21424
|
+
const subP = positional[1];
|
|
21425
|
+
if (subP !== "up" && subP !== "start") {
|
|
21426
|
+
process.stderr.write("crew periscope up [route] REPO --topic TEXT [--isolate] [--host H] [--assignee ID] [--dry-run] [--json]\n");
|
|
21427
|
+
process.exit(2);
|
|
21428
|
+
}
|
|
21429
|
+
const jsonP = flag("json");
|
|
21430
|
+
const say = (event, message, data = {}) => process.stderr.write(jsonP ? `${JSON.stringify({ ...data, event, message })}
|
|
21431
|
+
` : `${message}
|
|
21432
|
+
`);
|
|
21433
|
+
try {
|
|
21434
|
+
const a = parsePeriscopeUpArgs(positional, { topic: value("topic"), host: value("host"), assignee: value("assignee"), isolate: flag("isolate"), json: jsonP });
|
|
21435
|
+
const candidatesP = a.route ? [findRoute(cfg, a.route)] : cfg.routes.filter((r) => r.enabled);
|
|
21436
|
+
const servedP = [];
|
|
21437
|
+
for (const cand of candidatesP) {
|
|
21438
|
+
const hit = (await resolvedRepos(cand)).find((r) => r.name === a.repo);
|
|
21439
|
+
if (hit) servedP.push({ route: cand, repo: hit });
|
|
21440
|
+
}
|
|
21441
|
+
if (servedP.length !== 1) {
|
|
21442
|
+
throw new PeriscopeError(servedP.length === 0 ? `no route serves a repository named "${a.repo}"` : `"${a.repo}" is served by more than one route (${servedP.map((x) => x.route.route).join(", ")}) \u2014 name the route`);
|
|
21443
|
+
}
|
|
21444
|
+
const { route: routeP, repo: repoP } = servedP[0];
|
|
21445
|
+
if (!routeP.resolved) throw new PeriscopeError(`route ${routeP.route} is not connected \u2014 run \`crew connect\` first`);
|
|
21446
|
+
const trackerP = new Tracker(routeP, cfg.ship);
|
|
21447
|
+
const cP = trackerP.contract;
|
|
21448
|
+
const periscopes = await trackerP.periscopesStatus();
|
|
21449
|
+
const caps = periscopeCaps(cfg.ship);
|
|
21450
|
+
const shellP = resolveShell(repoP.config.shell ?? cfg.ship.shell);
|
|
21451
|
+
const pairId = a.assignee ?? routeP.resolved.seats.pair;
|
|
21452
|
+
if (!pairId) throw new PeriscopeError(`route ${routeP.route} has no Pair seat \u2014 pass --assignee CREW_ROW_ID`);
|
|
21453
|
+
const crewP = await trackerP.crewRows();
|
|
21454
|
+
const operatorName = crewP.find((r) => r.id === routeP.resolved.operator)?.name ?? "Pair";
|
|
21455
|
+
const memberId = routeP.resolved.seats.pair ?? routeP.resolved.operator;
|
|
21456
|
+
const deps = {
|
|
21457
|
+
previewHook: repoP.config.hooks.preview,
|
|
21458
|
+
hasPeriscopesTable: periscopes.present,
|
|
21459
|
+
liveCount: () => trackerP.livePeriscopeCount(),
|
|
21460
|
+
caps,
|
|
21461
|
+
baseProblem: () => {
|
|
21462
|
+
const h = baseHealth(repoP);
|
|
21463
|
+
return h.unsafe ? h.detail : void 0;
|
|
21464
|
+
},
|
|
21465
|
+
askTopic: async () => {
|
|
21466
|
+
if (!process.stdin.isTTY || jsonP) return void 0;
|
|
21467
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
21468
|
+
try {
|
|
21469
|
+
return await rl.question("Periscope topic: ");
|
|
21470
|
+
} finally {
|
|
21471
|
+
rl.close();
|
|
21472
|
+
}
|
|
21473
|
+
},
|
|
21474
|
+
fileDraft: async (title) => {
|
|
21475
|
+
const t = await trackerP.fileTicket({
|
|
21476
|
+
title,
|
|
21477
|
+
description: `A periscope: a throwaway preview of ${a.repo} for a viewer, opened by \`crew periscope up\`.`,
|
|
21478
|
+
report_type: "feature",
|
|
21479
|
+
reporter_name: operatorName,
|
|
21480
|
+
[cP.columns.status]: cP.statuses.draft,
|
|
21481
|
+
[cP.columns.assignee]: pairId,
|
|
21482
|
+
...routeP.resolved.areaId ? { [cP.columns.slice]: routeP.resolved.areaId } : {},
|
|
21483
|
+
...repoP.id ? { [cP.columns.repo]: repoP.id } : {}
|
|
21484
|
+
}, memberId);
|
|
21485
|
+
return { id: t.id, tag: t.issue_tag ?? t.issue_id, issueId: t.issue_id, prefix: t.project_issue_prefix };
|
|
21486
|
+
},
|
|
21487
|
+
cutWorktree: (draft) => {
|
|
21488
|
+
const plan = planRunWorktree(repoP.dir, repoP.config, { issue_id: draft.issueId, issue_tag: draft.tag, project_issue_prefix: draft.prefix }, void 0);
|
|
21489
|
+
if (!planIsUsable(plan)) throw new PeriscopeError(`cannot use the worktree: ${describeRunWorktreePlan(plan)}`);
|
|
21490
|
+
const path = applyRunWorktree(repoP.dir, repoP.config, plan);
|
|
21491
|
+
if (!path) throw new PeriscopeError("the worktree could not be cut");
|
|
21492
|
+
return { path, branch: plan.branch ?? "" };
|
|
21493
|
+
},
|
|
21494
|
+
prepare: async (path) => {
|
|
21495
|
+
for (const f of repoP.config.worktrees.copy) {
|
|
21496
|
+
const src = join38(repoP.dir, f);
|
|
21497
|
+
if (!existsSync35(src)) continue;
|
|
21498
|
+
mkdirSync22(dirOf(join38(path, f)), { recursive: true });
|
|
21499
|
+
copyFileSync2(src, join38(path, f));
|
|
21500
|
+
}
|
|
21501
|
+
const setup = repoP.config.hooks.setup;
|
|
21502
|
+
return setup ? runScript(setup, { cwd: path, shell: shellP, onLine: (l) => {
|
|
21503
|
+
if (l.trim()) say("setup", l.trim());
|
|
21504
|
+
} }) : null;
|
|
21505
|
+
},
|
|
21506
|
+
runHook: async (name, cwd, env) => {
|
|
21507
|
+
const script = repoP.config.hooks[name];
|
|
21508
|
+
return script ? runScript(script, { cwd, env, shell: shellP, timeoutMs: name === "preview" ? 18e4 : void 0 }) : null;
|
|
21509
|
+
},
|
|
21510
|
+
createRow: (fields) => trackerP.createPeriscope(fields),
|
|
21511
|
+
comment: (ticketId, body) => trackerP.postComment(ticketId, body, memberId),
|
|
21512
|
+
teardown: async (path, branch) => {
|
|
21513
|
+
const n = Number(path.match(/(\d+)\/?$/)?.[1]);
|
|
21514
|
+
if (Number.isFinite(n)) await killGently(pidsInWorktree(path, n));
|
|
21515
|
+
gitOk(repoP.dir, ["worktree", "remove", "--force", path]);
|
|
21516
|
+
gitOk(repoP.dir, ["branch", "-D", branch]);
|
|
21517
|
+
},
|
|
21518
|
+
closeDraft: async (ticketId, note) => {
|
|
21519
|
+
const closed = cP.statuses.resolved.includes("closed_obsolete") ? "closed_obsolete" : cP.statuses.wontFix;
|
|
21520
|
+
await trackerP.updateTicket(ticketId, { [cP.columns.status]: closed, [cP.columns.resolutionNote]: note });
|
|
21521
|
+
},
|
|
21522
|
+
now: () => /* @__PURE__ */ new Date(),
|
|
21523
|
+
host: a.host ?? "localhost",
|
|
21524
|
+
say
|
|
21525
|
+
};
|
|
21526
|
+
if (dryRun) {
|
|
21527
|
+
await periscopePreflight(deps, a.repo);
|
|
21528
|
+
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)`);
|
|
21529
|
+
break;
|
|
21530
|
+
}
|
|
21531
|
+
const res = await runPeriscopeUp(deps, a);
|
|
21532
|
+
if (jsonP) process.stdout.write(`${JSON.stringify({ event: "done", ...res })}
|
|
21533
|
+
`);
|
|
21534
|
+
else process.stdout.write(`${res.ticket}
|
|
21535
|
+
${res.worktree}
|
|
21536
|
+
${res.url}
|
|
21537
|
+
`);
|
|
21538
|
+
} catch (e) {
|
|
21539
|
+
if (e instanceof PeriscopeError || e instanceof ConfigError) {
|
|
21540
|
+
process.stderr.write(`crew periscope: ${e.message}
|
|
21541
|
+
`);
|
|
21542
|
+
process.exit(1);
|
|
21543
|
+
}
|
|
21544
|
+
throw e;
|
|
21545
|
+
}
|
|
21546
|
+
break;
|
|
21547
|
+
}
|
|
21214
21548
|
case "unassign": {
|
|
21215
21549
|
emit.enter("select");
|
|
21216
21550
|
const rawUnassign = positional[2] ?? positional[1];
|
|
@@ -21848,6 +22182,7 @@ ${owed.map((l) => ` ${l}
|
|
|
21848
22182
|
`ship: ${cfg.ship.name} (${cfg.ship.platform})
|
|
21849
22183
|
route: ${route.route} -> ${route.dir}
|
|
21850
22184
|
enabled: ${route.enabled}
|
|
22185
|
+
periscopes: ${periscopeSummary(cfg)}
|
|
21851
22186
|
crew: ${state.isPaused() ? "paused" : "active"}
|
|
21852
22187
|
release: ${state.releaseSkipReason() ? "paused" : "active"}
|
|
21853
22188
|
watermark: ${state.watermark()}
|
|
@@ -21965,6 +22300,8 @@ watermark: ${state.watermark()}
|
|
|
21965
22300
|
}
|
|
21966
22301
|
}
|
|
21967
22302
|
for (const message of roleCapWarnings(cfg.ship)) problems.push({ severity: "note", kind: "role_cap_above_ship_cap", message });
|
|
22303
|
+
const previewProblem2 = await previewHostProblem(cfg);
|
|
22304
|
+
if (previewProblem2) problems.push({ severity: "blocker", kind: "preview_host", message: previewProblem2 });
|
|
21968
22305
|
problems.push(...attentionProblems(openShipAttention(state), problems));
|
|
21969
22306
|
if (problems.some((p) => p.severity === "blocker")) process.exitCode = 1;
|
|
21970
22307
|
process.stdout.write(`${JSON.stringify({ offline: flag("offline"), pushAuth, tools: toolRows, problems, passed }, null, 2)}
|
|
@@ -21978,6 +22315,14 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
|
|
|
21978
22315
|
);
|
|
21979
22316
|
for (const w of roleCapWarnings(cfg.ship)) process.stdout.write(`role concurrency: WARN ${w}
|
|
21980
22317
|
`);
|
|
22318
|
+
process.stdout.write(`periscopes: ${periscopeSummary(cfg)}
|
|
22319
|
+
`);
|
|
22320
|
+
const previewProblem = await previewHostProblem(cfg);
|
|
22321
|
+
if (previewProblem) {
|
|
22322
|
+
process.stdout.write(` BLOCKER ${previewProblem}
|
|
22323
|
+
`);
|
|
22324
|
+
process.exitCode = 1;
|
|
22325
|
+
}
|
|
21981
22326
|
try {
|
|
21982
22327
|
const evFile = eventFileFor(cfg.ship.stateDir);
|
|
21983
22328
|
const evSize = existsSync35(evFile) ? statSync10(evFile).size : 0;
|
package/package.json
CHANGED