@tablation/crew 0.57.0 → 0.58.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/README.md +1 -0
- package/dist/cli.js +270 -45
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -239,6 +239,7 @@ crew watch [route] live view of what the crew is doing
|
|
|
239
239
|
crew status [route] paused/running state, led by what the operator owes (needs_info assigned to them, needs_planning, ships needing attention)
|
|
240
240
|
crew doctor [route] [--fix] preflight; --fix enables clean routes, offers crew install
|
|
241
241
|
crew selftest [route] [--toolchain NAME] [--json] launch each toolchain as a run would and prove it reaches the tracker
|
|
242
|
+
crew sample-ticket [route] [--remove] [--dry-run] [--json] file one ticket on a throwaway repo that walks the dev, QA and release loop
|
|
242
243
|
crew ports [route] which checkout owns which ports, and what is up
|
|
243
244
|
crew reap [route] kill servers left behind by removed worktrees
|
|
244
245
|
crew drop [route] NNN remove a merged ticket's worktree and branch
|
package/dist/cli.js
CHANGED
|
@@ -5104,7 +5104,7 @@ function doctorSelftestLines(stored, toolchains) {
|
|
|
5104
5104
|
}
|
|
5105
5105
|
|
|
5106
5106
|
// src/cli.ts
|
|
5107
|
-
import { resolve as resolve5, dirname as
|
|
5107
|
+
import { resolve as resolve5, dirname as dirname15, join as join29, basename as basename10 } from "path";
|
|
5108
5108
|
import { fileURLToPath } from "url";
|
|
5109
5109
|
import { hostname as hostname2, homedir as homedir8 } from "os";
|
|
5110
5110
|
import { spawnSync as spawnSync6 } from "child_process";
|
|
@@ -7201,6 +7201,25 @@ var Tracker = class {
|
|
|
7201
7201
|
if (!model) throw new Error("this workspace has no Repos table");
|
|
7202
7202
|
return this.client.records.create(model, fields);
|
|
7203
7203
|
}
|
|
7204
|
+
/** Every `Repos` row as `{ id, name }`, for finding one by name. */
|
|
7205
|
+
async repoRows() {
|
|
7206
|
+
const model = this.route.resolved?.reposModelId;
|
|
7207
|
+
if (!model) return [];
|
|
7208
|
+
return this.client.records.list(model, { limit: 200 });
|
|
7209
|
+
}
|
|
7210
|
+
/** Delete a `Repos` row (`crew sample-ticket --remove`). */
|
|
7211
|
+
async deleteRepoRow(id) {
|
|
7212
|
+
const model = this.route.resolved?.reposModelId;
|
|
7213
|
+
if (!model) throw new Error("this workspace has no Repos table");
|
|
7214
|
+
await this.client.records.remove(model, id);
|
|
7215
|
+
}
|
|
7216
|
+
/** Every ticket filed against one `Repos` row, any status. */
|
|
7217
|
+
async ticketsForRepo(repoId) {
|
|
7218
|
+
return this.client.records.list(this.models.issues, {
|
|
7219
|
+
filters: encodeFilters([{ columnName: this.contract.columns.repo, operator: "EQ", value: repoId }]),
|
|
7220
|
+
limit: 200
|
|
7221
|
+
});
|
|
7222
|
+
}
|
|
7204
7223
|
/**
|
|
7205
7224
|
* The Ships rows of this workspace, if it has a Ships table.
|
|
7206
7225
|
*
|
|
@@ -12508,9 +12527,77 @@ async function showLogEntry(route, entryId, opts = {}) {
|
|
|
12508
12527
|
};
|
|
12509
12528
|
}
|
|
12510
12529
|
|
|
12530
|
+
// src/sample-ticket.ts
|
|
12531
|
+
import { basename as basename7, dirname as dirname11, join as join20 } from "path";
|
|
12532
|
+
var SAMPLE_REPO = "crew-sample";
|
|
12533
|
+
var SAMPLE_REMOTE = `local/${SAMPLE_REPO}`;
|
|
12534
|
+
var SAMPLE_MARKER = "sample: true";
|
|
12535
|
+
var SAMPLE_CREW_YAML = `version: 1
|
|
12536
|
+
platform: unix
|
|
12537
|
+
branch:
|
|
12538
|
+
base: main
|
|
12539
|
+
hooks:
|
|
12540
|
+
test: "true"
|
|
12541
|
+
build: "true"
|
|
12542
|
+
release:
|
|
12543
|
+
mode: integrate
|
|
12544
|
+
versioning: none
|
|
12545
|
+
changelog: false
|
|
12546
|
+
`;
|
|
12547
|
+
var SAMPLE_README = `# crew-sample
|
|
12548
|
+
|
|
12549
|
+
A throwaway repository created by \`crew sample-ticket\`. It exists so the crew
|
|
12550
|
+
has something safe to work on: its test and build hooks always pass, it has no
|
|
12551
|
+
remote, and \`crew sample-ticket --remove\` deletes it.
|
|
12552
|
+
`;
|
|
12553
|
+
function sampleDir(existing, reposBase, workspace) {
|
|
12554
|
+
if (existing.length > 0) return join20(dirname11(existing[0]), SAMPLE_REPO);
|
|
12555
|
+
return join20(reposBase, workspace, SAMPLE_REPO);
|
|
12556
|
+
}
|
|
12557
|
+
var SAMPLE_TITLE = "Sample: add a documented `greet` function with a test";
|
|
12558
|
+
function sampleDescription() {
|
|
12559
|
+
return [
|
|
12560
|
+
SAMPLE_MARKER,
|
|
12561
|
+
"",
|
|
12562
|
+
"> This is a sample ticket filed by `crew sample-ticket` on a throwaway repository. Nothing here is real work.",
|
|
12563
|
+
"",
|
|
12564
|
+
"## What",
|
|
12565
|
+
"",
|
|
12566
|
+
"Add `greet.js` exporting `greet(name)`, which returns `Hello, <name>!` (and `Hello, world!` for no name),",
|
|
12567
|
+
"with a JSDoc comment, and `greet.test.js` covering both cases using `node:test`.",
|
|
12568
|
+
"",
|
|
12569
|
+
"## Acceptance",
|
|
12570
|
+
"",
|
|
12571
|
+
'- `greet("Ada")` returns `Hello, Ada!`; `greet()` returns `Hello, world!`.',
|
|
12572
|
+
"- The test file runs with `node --test` and passes.",
|
|
12573
|
+
"- Add a line to the README saying the function exists.",
|
|
12574
|
+
"",
|
|
12575
|
+
"## Notes",
|
|
12576
|
+
"",
|
|
12577
|
+
"The repo has no remote: leave the branch local. The hooks in `.crew.yaml` always pass, so QA is checking the",
|
|
12578
|
+
"diff against the acceptance list above."
|
|
12579
|
+
].join("\n");
|
|
12580
|
+
}
|
|
12581
|
+
function isSampleTicket(description) {
|
|
12582
|
+
return (description ?? "").trimStart().startsWith(SAMPLE_MARKER);
|
|
12583
|
+
}
|
|
12584
|
+
function planSampleTicket(i) {
|
|
12585
|
+
return {
|
|
12586
|
+
title: SAMPLE_TITLE,
|
|
12587
|
+
description: sampleDescription(),
|
|
12588
|
+
report_type: "feature",
|
|
12589
|
+
severity: "s4",
|
|
12590
|
+
reporter_name: "crew sample-ticket",
|
|
12591
|
+
status: i.approvedStatus,
|
|
12592
|
+
project_id: i.projectId,
|
|
12593
|
+
repo_id: i.repoId,
|
|
12594
|
+
...i.filedBy ? { filed_by_id: i.filedBy } : {}
|
|
12595
|
+
};
|
|
12596
|
+
}
|
|
12597
|
+
|
|
12511
12598
|
// src/repos-cmd.ts
|
|
12512
12599
|
import { homedir as homedir7 } from "os";
|
|
12513
|
-
import { basename as
|
|
12600
|
+
import { basename as basename8, join as join21, resolve as resolve3 } from "path";
|
|
12514
12601
|
import { isMap, isSeq, parseDocument, stringify as stringify2 } from "yaml";
|
|
12515
12602
|
var ReposError = class extends Error {
|
|
12516
12603
|
};
|
|
@@ -12582,7 +12669,7 @@ function configuredRepos(text, route, base) {
|
|
|
12582
12669
|
});
|
|
12583
12670
|
}
|
|
12584
12671
|
const dir = m.get("dir");
|
|
12585
|
-
return dir ? [{ name:
|
|
12672
|
+
return dir ? [{ name: basename8(String(dir)), dir: expandDir(String(dir), base) }] : [];
|
|
12586
12673
|
}
|
|
12587
12674
|
function planRepoAdd(i) {
|
|
12588
12675
|
const doc = parseDocument(i.text);
|
|
@@ -12594,7 +12681,7 @@ function planRepoAdd(i) {
|
|
|
12594
12681
|
throw new ReposError(`${target} is already configured on route ${i.route}`);
|
|
12595
12682
|
}
|
|
12596
12683
|
const matched = matchRepoName(i.origin, i.resolved);
|
|
12597
|
-
const name = matched ?? i.name ?? normalizeRemote(i.origin)?.split("/").pop() ??
|
|
12684
|
+
const name = matched ?? i.name ?? normalizeRemote(i.origin)?.split("/").pop() ?? basename8(target);
|
|
12598
12685
|
let warning;
|
|
12599
12686
|
if (!matched) {
|
|
12600
12687
|
const known = Object.values(i.resolved?.repoNames ?? {});
|
|
@@ -12621,6 +12708,28 @@ function planRepoAdd(i) {
|
|
|
12621
12708
|
}
|
|
12622
12709
|
return { text: String(doc), mode, name, block: renderBlock(m), warning, unmatched: !matched };
|
|
12623
12710
|
}
|
|
12711
|
+
function planRepoRemove(text, base, route, path) {
|
|
12712
|
+
const doc = parseDocument(text);
|
|
12713
|
+
if (doc.errors.length > 0) throw new ReposError(`config is not valid YAML \u2014 ${doc.errors[0].message}`);
|
|
12714
|
+
const m = routeMapOf(doc, route);
|
|
12715
|
+
const target = expandDir(path, base);
|
|
12716
|
+
const existing = configuredRepos(text, route, base);
|
|
12717
|
+
const hit = existing.find((e) => e.dir === target);
|
|
12718
|
+
if (!hit) throw new ReposError(`${target} is not configured on route ${route}`);
|
|
12719
|
+
const repos = m.get("repos", true);
|
|
12720
|
+
if (isMap(repos)) {
|
|
12721
|
+
repos.delete(hit.name);
|
|
12722
|
+
const left = existing.filter((e) => e !== hit);
|
|
12723
|
+
if (left.length === 1) {
|
|
12724
|
+
const pair = m.items.find((p) => p.key.value === "repos");
|
|
12725
|
+
pair.key = doc.createNode("dir");
|
|
12726
|
+
pair.value = doc.createNode(String(repos.get(left[0].name)));
|
|
12727
|
+
} else if (left.length === 0) m.delete("repos");
|
|
12728
|
+
} else {
|
|
12729
|
+
m.delete("dir");
|
|
12730
|
+
}
|
|
12731
|
+
return { text: String(doc), name: hit.name };
|
|
12732
|
+
}
|
|
12624
12733
|
function renderBlock(m) {
|
|
12625
12734
|
return stringify2([m.toJSON()]).trimEnd();
|
|
12626
12735
|
}
|
|
@@ -12686,7 +12795,7 @@ function planRepoClone(i) {
|
|
|
12686
12795
|
}
|
|
12687
12796
|
const ship = doc.get("ship", true);
|
|
12688
12797
|
const basePath = String(m.get("reposBasePath") ?? (isMap(ship) ? ship.get("reposBasePath") : void 0) ?? "~/Crew");
|
|
12689
|
-
const target = i.dir ? expandDir(i.dir, i.base) :
|
|
12798
|
+
const target = i.dir ? expandDir(i.dir, i.base) : join21(expandDir(basePath, i.base), i.route.split("/")[0], name);
|
|
12690
12799
|
const add = planRepoAdd({ text: i.text, base: i.base, route: i.route, path: target, origin: remote, resolved: i.resolved, name });
|
|
12691
12800
|
return { ...add, remote, target };
|
|
12692
12801
|
}
|
|
@@ -12730,7 +12839,7 @@ function schedulerInstalled(plan, exists, crontab) {
|
|
|
12730
12839
|
|
|
12731
12840
|
// src/worktree-sweep.ts
|
|
12732
12841
|
import { existsSync as existsSync21, readFileSync as readFileSync15, readdirSync as readdirSync5 } from "fs";
|
|
12733
|
-
import { basename as
|
|
12842
|
+
import { basename as basename9, dirname as dirname13, join as join22, resolve as resolve4 } from "path";
|
|
12734
12843
|
function planWorktreeSweep(target, tickets, contract) {
|
|
12735
12844
|
const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
|
|
12736
12845
|
const verified = contract.statuses.verified;
|
|
@@ -12761,7 +12870,7 @@ function ticketForWorktree(w, byNumber, contract) {
|
|
|
12761
12870
|
const t = num ? byNumber.get(num) : void 0;
|
|
12762
12871
|
if (t && ticketStems(t).has(stem)) return t;
|
|
12763
12872
|
}
|
|
12764
|
-
const dir =
|
|
12873
|
+
const dir = basename9(w.path);
|
|
12765
12874
|
const m = DIR_NUMBER.exec(dir);
|
|
12766
12875
|
if (m && byNumber.has(m[1])) return byNumber.get(m[1]);
|
|
12767
12876
|
if (!w.branch) {
|
|
@@ -12777,15 +12886,15 @@ function worktreeTicketNumbers(target) {
|
|
|
12777
12886
|
const n = /-(\d+)$/.exec(BRANCH_STEM.exec(w.branch)?.[1] ?? "")?.[1];
|
|
12778
12887
|
if (n) nums.add(n);
|
|
12779
12888
|
}
|
|
12780
|
-
const m = DIR_NUMBER_SUFFIXED.exec(
|
|
12889
|
+
const m = DIR_NUMBER_SUFFIXED.exec(basename9(w.path));
|
|
12781
12890
|
if (m) nums.add(m[1]);
|
|
12782
12891
|
}
|
|
12783
12892
|
for (const d of siblingWorktreeDirs(target)) nums.add(d.number);
|
|
12784
12893
|
return [...nums];
|
|
12785
12894
|
}
|
|
12786
12895
|
function siblingWorktreeDirs(target) {
|
|
12787
|
-
const parent =
|
|
12788
|
-
const stem = `${
|
|
12896
|
+
const parent = dirname13(target.dir.replace(/[/\\]+$/, ""));
|
|
12897
|
+
const stem = `${basename9(target.dir.replace(/[/\\]+$/, ""))}-`;
|
|
12789
12898
|
const registered = new Set(worktrees(target.dir).map((w) => resolve4(w.path)));
|
|
12790
12899
|
let names;
|
|
12791
12900
|
try {
|
|
@@ -12797,7 +12906,7 @@ function siblingWorktreeDirs(target) {
|
|
|
12797
12906
|
for (const name of names) {
|
|
12798
12907
|
if (!name.startsWith(stem)) continue;
|
|
12799
12908
|
const m = DIR_NUMBER_SUFFIXED.exec(name);
|
|
12800
|
-
const path =
|
|
12909
|
+
const path = join22(parent, name);
|
|
12801
12910
|
if (m && !registered.has(resolve4(path))) out.push({ path, number: m[1] });
|
|
12802
12911
|
}
|
|
12803
12912
|
return out;
|
|
@@ -12810,7 +12919,7 @@ function orphanedWorktreeDirs(target, tickets, contract) {
|
|
|
12810
12919
|
}
|
|
12811
12920
|
function warnOrphanedWorktreeDirs(paths, log, held) {
|
|
12812
12921
|
for (const p of paths) {
|
|
12813
|
-
const number = DIR_NUMBER_SUFFIXED.exec(
|
|
12922
|
+
const number = DIR_NUMBER_SUFFIXED.exec(basename9(p))?.[1];
|
|
12814
12923
|
const when = held && number ? noteHeldWorktree(held.state, { repo: held.repo, path: p, ticket: number, status: "deployed", reason: "orphaned directory" }) : "first";
|
|
12815
12924
|
if (when) {
|
|
12816
12925
|
log.warn(`orphaned directory ${p}: its ticket is deployed but it is not a registered worktree; the sweep will not delete it \u2014 remove it by hand`, { step: "worktree" });
|
|
@@ -12954,7 +13063,7 @@ function isTicketBranch(name, base, stems) {
|
|
|
12954
13063
|
}
|
|
12955
13064
|
function sweepCandidatesExist(cwd, base) {
|
|
12956
13065
|
const linked = worktrees(cwd).slice(1);
|
|
12957
|
-
if (linked.some((w) => DIR_NUMBER_SUFFIXED.test(
|
|
13066
|
+
if (linked.some((w) => DIR_NUMBER_SUFFIXED.test(basename9(w.path)) || w.branch && BRANCH_STEM.test(w.branch))) return true;
|
|
12958
13067
|
if (siblingWorktreeDirs({ name: "", dir: cwd }).length) return true;
|
|
12959
13068
|
return localBranches(cwd).some((b) => b !== base && /-\d+(?:-.*)?$/.test(b));
|
|
12960
13069
|
}
|
|
@@ -13006,13 +13115,13 @@ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
|
|
|
13006
13115
|
|
|
13007
13116
|
// src/stream-sweep.ts
|
|
13008
13117
|
import { existsSync as existsSync22, readdirSync as readdirSync6, rmSync as rmSync9, statSync as statSync7 } from "fs";
|
|
13009
|
-
import { join as
|
|
13118
|
+
import { join as join23 } from "path";
|
|
13010
13119
|
var DAY_MS2 = 24 * 60 * 60 * 1e3;
|
|
13011
13120
|
function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
|
|
13012
|
-
const dir =
|
|
13121
|
+
const dir = join23(stateDir, "streams");
|
|
13013
13122
|
if (!existsSync22(dir)) return [];
|
|
13014
13123
|
const cutoff = now - retentionDays * DAY_MS2;
|
|
13015
|
-
return readdirSync6(dir).map((name) =>
|
|
13124
|
+
return readdirSync6(dir).map((name) => join23(dir, name)).filter((path) => {
|
|
13016
13125
|
try {
|
|
13017
13126
|
return statSync7(path).mtimeMs < cutoff;
|
|
13018
13127
|
} catch {
|
|
@@ -13040,7 +13149,7 @@ function applyStreamSweep(paths, dryRun, log) {
|
|
|
13040
13149
|
function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
|
|
13041
13150
|
if (!existsSync22(stateDir)) return [];
|
|
13042
13151
|
const cutoff = now - retentionDays * DAY_MS2;
|
|
13043
|
-
return readdirSync6(stateDir).filter((name) => ROTATED_EVENTS_RE.test(name)).map((name) =>
|
|
13152
|
+
return readdirSync6(stateDir).filter((name) => ROTATED_EVENTS_RE.test(name)).map((name) => join23(stateDir, name)).filter((path) => {
|
|
13044
13153
|
try {
|
|
13045
13154
|
return statSync7(path).mtimeMs < cutoff;
|
|
13046
13155
|
} catch {
|
|
@@ -13050,24 +13159,24 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
|
|
|
13050
13159
|
}
|
|
13051
13160
|
|
|
13052
13161
|
// src/cli.ts
|
|
13053
|
-
import { readFileSync as readFileSync21, existsSync as existsSync28, mkdirSync as mkdirSync14, writeFileSync as writeFileSync15, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync2, statSync as statSync9, readdirSync as readdirSync11 } from "fs";
|
|
13162
|
+
import { readFileSync as readFileSync21, existsSync as existsSync28, mkdirSync as mkdirSync14, writeFileSync as writeFileSync15, rmSync as rmSync11, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync2, statSync as statSync9, readdirSync as readdirSync11 } from "fs";
|
|
13054
13163
|
import { createInterface } from "readline/promises";
|
|
13055
13164
|
import { dirname as dirOf, resolve as resolvePath } from "path";
|
|
13056
13165
|
|
|
13057
13166
|
// src/ssh-keys.ts
|
|
13058
13167
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
13059
13168
|
import { existsSync as existsSync23, mkdirSync as mkdirSync10, readFileSync as readFileSync16, chmodSync } from "fs";
|
|
13060
|
-
import { dirname as
|
|
13169
|
+
import { dirname as dirname14, join as join24 } from "path";
|
|
13061
13170
|
var SshKeyError = class extends Error {
|
|
13062
13171
|
};
|
|
13063
13172
|
function shipSshKeyPathFor(stateDir) {
|
|
13064
|
-
return
|
|
13173
|
+
return join24(stateDir, "ssh", "id_ed25519");
|
|
13065
13174
|
}
|
|
13066
13175
|
function ensureShipSshKeypair(stateDir) {
|
|
13067
13176
|
const privateKeyPath = shipSshKeyPathFor(stateDir);
|
|
13068
13177
|
const publicKeyPath = `${privateKeyPath}.pub`;
|
|
13069
13178
|
if (!existsSync23(privateKeyPath)) {
|
|
13070
|
-
mkdirSync10(
|
|
13179
|
+
mkdirSync10(dirname14(privateKeyPath), { recursive: true });
|
|
13071
13180
|
try {
|
|
13072
13181
|
execFileSync3("ssh-keygen", [
|
|
13073
13182
|
"-t",
|
|
@@ -13308,7 +13417,7 @@ function syncAllPassengerCheckouts(cfg) {
|
|
|
13308
13417
|
import { spawn as spawn4 } from "child_process";
|
|
13309
13418
|
import { existsSync as existsSync24, mkdirSync as mkdirSync11, readFileSync as readFileSync17, writeFileSync as writeFileSync12, unlinkSync as unlinkSync3, readdirSync as readdirSync7 } from "fs";
|
|
13310
13419
|
import { randomInt } from "crypto";
|
|
13311
|
-
import { join as
|
|
13420
|
+
import { join as join25 } from "path";
|
|
13312
13421
|
var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
|
|
13313
13422
|
var defaultSpawn = (cmd, args) => spawn4(cmd, args, DEFAULT_SPAWN_OPTIONS);
|
|
13314
13423
|
var SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
@@ -13375,7 +13484,7 @@ function startTunnel(opts) {
|
|
|
13375
13484
|
};
|
|
13376
13485
|
}
|
|
13377
13486
|
function tunnelStatePath(stateDir, workspaceId) {
|
|
13378
|
-
return
|
|
13487
|
+
return join25(stateDir, "passengers", `${workspaceId}.json`);
|
|
13379
13488
|
}
|
|
13380
13489
|
function readPersistedTunnel(stateDir, workspaceId) {
|
|
13381
13490
|
const path = tunnelStatePath(stateDir, workspaceId);
|
|
@@ -13388,7 +13497,7 @@ function readPersistedTunnel(stateDir, workspaceId) {
|
|
|
13388
13497
|
}
|
|
13389
13498
|
function writePersistedTunnel(stateDir, state) {
|
|
13390
13499
|
const path = tunnelStatePath(stateDir, state.workspaceId);
|
|
13391
|
-
mkdirSync11(
|
|
13500
|
+
mkdirSync11(join25(stateDir, "passengers"), { recursive: true });
|
|
13392
13501
|
writeFileSync12(path, `${JSON.stringify(state, null, 2)}
|
|
13393
13502
|
`);
|
|
13394
13503
|
}
|
|
@@ -13486,7 +13595,7 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
|
|
|
13486
13595
|
}
|
|
13487
13596
|
}
|
|
13488
13597
|
function readAllPersistedWorkspaceIds(stateDir) {
|
|
13489
|
-
const dir =
|
|
13598
|
+
const dir = join25(stateDir, "passengers");
|
|
13490
13599
|
if (!existsSync24(dir)) return [];
|
|
13491
13600
|
return readdirSync7(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
|
|
13492
13601
|
}
|
|
@@ -13609,7 +13718,7 @@ import { createServer as createHttpServer } from "http";
|
|
|
13609
13718
|
import { spawn as spawn5 } from "child_process";
|
|
13610
13719
|
import { randomBytes } from "crypto";
|
|
13611
13720
|
import { existsSync as existsSync25, mkdirSync as mkdirSync12, readFileSync as readFileSync18, writeFileSync as writeFileSync13, unlinkSync as unlinkSync4, readdirSync as readdirSync8 } from "fs";
|
|
13612
|
-
import { join as
|
|
13721
|
+
import { join as join26 } from "path";
|
|
13613
13722
|
|
|
13614
13723
|
// src/secret-compare.ts
|
|
13615
13724
|
import { timingSafeEqual } from "crypto";
|
|
@@ -13698,7 +13807,7 @@ var defaultSpawn2 = (cmd, args, env) => {
|
|
|
13698
13807
|
return child;
|
|
13699
13808
|
};
|
|
13700
13809
|
function syncDaemonStatePath(stateDir, workspaceId) {
|
|
13701
|
-
return
|
|
13810
|
+
return join26(stateDir, "passengers", `${workspaceId}-sync.json`);
|
|
13702
13811
|
}
|
|
13703
13812
|
function readPersistedSyncDaemon(stateDir, workspaceId) {
|
|
13704
13813
|
const path = syncDaemonStatePath(stateDir, workspaceId);
|
|
@@ -13711,7 +13820,7 @@ function readPersistedSyncDaemon(stateDir, workspaceId) {
|
|
|
13711
13820
|
}
|
|
13712
13821
|
function writePersistedSyncDaemon(stateDir, state) {
|
|
13713
13822
|
const path = syncDaemonStatePath(stateDir, state.workspaceId);
|
|
13714
|
-
mkdirSync12(
|
|
13823
|
+
mkdirSync12(join26(stateDir, "passengers"), { recursive: true });
|
|
13715
13824
|
writeFileSync13(path, `${JSON.stringify(state, null, 2)}
|
|
13716
13825
|
`);
|
|
13717
13826
|
}
|
|
@@ -13732,7 +13841,7 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
|
|
|
13732
13841
|
}
|
|
13733
13842
|
}
|
|
13734
13843
|
function readAllPersistedSyncWorkspaceIds(stateDir) {
|
|
13735
|
-
const dir =
|
|
13844
|
+
const dir = join26(stateDir, "passengers");
|
|
13736
13845
|
if (!existsSync25(dir)) return [];
|
|
13737
13846
|
const suffix = "-sync.json";
|
|
13738
13847
|
return readdirSync8(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
|
|
@@ -13791,11 +13900,11 @@ function syncPassengerSyncDaemons(plans, cfg, stateDir, deps = {}) {
|
|
|
13791
13900
|
|
|
13792
13901
|
// src/daemon.ts
|
|
13793
13902
|
import { existsSync as existsSync27, mkdirSync as mkdirSync13, readFileSync as readFileSync20, readdirSync as readdirSync10, rmSync as rmSync10, statSync as statSync8, writeFileSync as writeFileSync14 } from "fs";
|
|
13794
|
-
import { join as
|
|
13903
|
+
import { join as join28 } from "path";
|
|
13795
13904
|
|
|
13796
13905
|
// src/interrupted.ts
|
|
13797
13906
|
import { existsSync as existsSync26, readdirSync as readdirSync9, readFileSync as readFileSync19 } from "fs";
|
|
13798
|
-
import { join as
|
|
13907
|
+
import { join as join27 } from "path";
|
|
13799
13908
|
var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
|
|
13800
13909
|
function interruptedComment(b) {
|
|
13801
13910
|
const tree = b.dirty === null ? "The worktree could not be checked." : b.dirty ? "The worktree has uncommitted changes \u2014 they are this seat's own, from that run." : "The worktree is clean.";
|
|
@@ -13822,13 +13931,13 @@ function runFinished(streamPath) {
|
|
|
13822
13931
|
}
|
|
13823
13932
|
}
|
|
13824
13933
|
function orphanedStreams(o) {
|
|
13825
|
-
const dir =
|
|
13934
|
+
const dir = join27(o.stateDir, "streams");
|
|
13826
13935
|
if (!existsSync26(dir)) return [];
|
|
13827
13936
|
const ownerAlive = o.isOwnerAlive ?? pidAlive;
|
|
13828
13937
|
const out = [];
|
|
13829
13938
|
for (const name of readdirSync9(dir)) {
|
|
13830
13939
|
if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
|
|
13831
|
-
const streamPath =
|
|
13940
|
+
const streamPath = join27(dir, name);
|
|
13832
13941
|
if (runFinished(streamPath)) continue;
|
|
13833
13942
|
const sidecar = readRunSidecar(streamPath);
|
|
13834
13943
|
if (!sidecar || sidecar.route !== o.routeLabel) continue;
|
|
@@ -13928,7 +14037,7 @@ function latestMtime(path) {
|
|
|
13928
14037
|
if (!stat.isDirectory()) return 0;
|
|
13929
14038
|
let latest = stat.mtimeMs;
|
|
13930
14039
|
for (const entry of readdirSync10(path, { withFileTypes: true })) {
|
|
13931
|
-
latest = Math.max(latest, latestMtime(
|
|
14040
|
+
latest = Math.max(latest, latestMtime(join28(path, entry.name)));
|
|
13932
14041
|
}
|
|
13933
14042
|
return latest;
|
|
13934
14043
|
}
|
|
@@ -13940,10 +14049,10 @@ function makeStaleChecker(paths, opts = {}) {
|
|
|
13940
14049
|
return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync27(opts.reloadSentinel);
|
|
13941
14050
|
}
|
|
13942
14051
|
function reloadSentinelFor(stateDir) {
|
|
13943
|
-
return
|
|
14052
|
+
return join28(stateDir, "reload-requested");
|
|
13944
14053
|
}
|
|
13945
14054
|
function daemonStampFor(stateDir) {
|
|
13946
|
-
return
|
|
14055
|
+
return join28(stateDir, "daemon.started");
|
|
13947
14056
|
}
|
|
13948
14057
|
function noteDaemonStarted(stateDir, now = Date.now()) {
|
|
13949
14058
|
mkdirSync13(stateDir, { recursive: true });
|
|
@@ -14309,7 +14418,7 @@ async function buildEnvironment(route, ship, ticket) {
|
|
|
14309
14418
|
}
|
|
14310
14419
|
|
|
14311
14420
|
// src/cli.ts
|
|
14312
|
-
var CREW_HOME = isCompiledBinary(import.meta.url) ?
|
|
14421
|
+
var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname15(process.execPath) : resolve5(dirname15(fileURLToPath(import.meta.url)), "..");
|
|
14313
14422
|
var RELEASE_LOCK_TTL_MS = 20 * 60 * 1e3;
|
|
14314
14423
|
var CYCLE_SECONDS2 = 120;
|
|
14315
14424
|
var STALE_CYCLES = 3;
|
|
@@ -14442,6 +14551,7 @@ function usage() {
|
|
|
14442
14551
|
crew reload make the running daemon re-read crew.yaml (--now: restart it immediately)
|
|
14443
14552
|
crew doctor [route] [--fix] preflight; --fix enables clean routes and offers crew install
|
|
14444
14553
|
crew selftest [route] [--toolchain NAME] [--json] launch each toolchain as a run would and prove it reaches the tracker
|
|
14554
|
+
crew sample-ticket [route] [--remove] [--dry-run] [--json] file one ticket on a throwaway repo that walks the dev, QA and release loop
|
|
14445
14555
|
crew ports [route] which checkout owns which ports, and what is up
|
|
14446
14556
|
crew reap [route] kill orphaned servers, drop worktrees for closed tickets
|
|
14447
14557
|
crew drop [route] NNN remove a merged ticket's worktree and branch
|
|
@@ -14865,7 +14975,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
14865
14975
|
}
|
|
14866
14976
|
if (host === "linux" && hasSystemd) {
|
|
14867
14977
|
const label = labelFor(CREW_HOME, "run");
|
|
14868
|
-
const staleTimer =
|
|
14978
|
+
const staleTimer = join29(homedir8(), ".config", "systemd", "user", `${label}.timer`);
|
|
14869
14979
|
if (existsSync28(staleTimer)) {
|
|
14870
14980
|
process.stdout.write(
|
|
14871
14981
|
`removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
|
|
@@ -15923,7 +16033,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
15923
16033
|
process.exit(1);
|
|
15924
16034
|
}
|
|
15925
16035
|
requireArmed("run the daemon loop");
|
|
15926
|
-
const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [
|
|
16036
|
+
const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join29(CREW_HOME, "src"), join29(CREW_HOME, "dist"), join29(CREW_HOME, "bin")];
|
|
15927
16037
|
const configPaths = daemonConfigPaths(cfg);
|
|
15928
16038
|
watchPaths.push(...configPaths);
|
|
15929
16039
|
noteDaemonStarted(cfg.ship.stateDir);
|
|
@@ -16228,7 +16338,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
16228
16338
|
}
|
|
16229
16339
|
const originOf = (dir) => gitOk(dir, ["remote", "get-url", "origin"]) ?? void 0;
|
|
16230
16340
|
const text = configText;
|
|
16231
|
-
const base =
|
|
16341
|
+
const base = dirname15(cfg.configFile);
|
|
16232
16342
|
try {
|
|
16233
16343
|
if (sub === "list") {
|
|
16234
16344
|
const repos = configuredRepos(text, routeName, base);
|
|
@@ -16752,7 +16862,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
16752
16862
|
process.stderr.write(`(dry run) would write resolved ids to ${resolvedPath}
|
|
16753
16863
|
`);
|
|
16754
16864
|
} else {
|
|
16755
|
-
mkdirSync14(
|
|
16865
|
+
mkdirSync14(dirname15(resolvedPath), { recursive: true });
|
|
16756
16866
|
if (hadPreviousFile) copyFileSync2(resolvedPath, `${resolvedPath}.bak`);
|
|
16757
16867
|
const tmpPath = `${resolvedPath}.tmp${process.pid}`;
|
|
16758
16868
|
writeFileSync15(tmpPath, `${JSON.stringify(resolved, null, 2)}
|
|
@@ -16790,7 +16900,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
16790
16900
|
process.stderr.write(`(dry run) would write the minted API key to ${apiKeyFile}
|
|
16791
16901
|
`);
|
|
16792
16902
|
} else {
|
|
16793
|
-
mkdirSync14(
|
|
16903
|
+
mkdirSync14(dirname15(apiKeyFile), { recursive: true });
|
|
16794
16904
|
writeFileSync15(apiKeyFile, `${apiKeyVar}=${apiKey}
|
|
16795
16905
|
`, { mode: 384 });
|
|
16796
16906
|
process.stderr.write(`API key written to ${apiKeyFile}
|
|
@@ -16800,7 +16910,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
16800
16910
|
const keyPersisted = keychainBacked || apiKeyFile !== void 0;
|
|
16801
16911
|
const wizard = firstRun;
|
|
16802
16912
|
const writeFirstRun = wizard !== void 0 && !dryRun && keyPersisted;
|
|
16803
|
-
const routeDir = value("dir") ?? (writeFirstRun ?
|
|
16913
|
+
const routeDir = value("dir") ?? (writeFirstRun ? join29(DEFAULT_REPOS_BASE_PATH, found.workspaceSlug) : void 0);
|
|
16804
16914
|
const routeBlock = renderConnection(
|
|
16805
16915
|
found,
|
|
16806
16916
|
route2,
|
|
@@ -17431,6 +17541,121 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
17431
17541
|
}
|
|
17432
17542
|
break;
|
|
17433
17543
|
}
|
|
17544
|
+
case "sample-ticket": {
|
|
17545
|
+
const json = flag("json");
|
|
17546
|
+
const say = (event, extra, human) => process.stdout.write(json ? `${JSON.stringify({ event, ...extra })}
|
|
17547
|
+
` : `${human}
|
|
17548
|
+
`);
|
|
17549
|
+
const fail = (msg) => {
|
|
17550
|
+
if (json) process.stdout.write(`${JSON.stringify({ event: "error", message: msg })}
|
|
17551
|
+
`);
|
|
17552
|
+
else process.stderr.write(`crew sample-ticket: ${msg}
|
|
17553
|
+
`);
|
|
17554
|
+
return process.exit(2);
|
|
17555
|
+
};
|
|
17556
|
+
if (!route.resolved) fail(`route ${route.route} is not connected \u2014 run \`crew connect\` first`);
|
|
17557
|
+
const tracker = new Tracker(route, cfg.ship);
|
|
17558
|
+
const configText = readFileSync21(cfg.configFile, "utf8");
|
|
17559
|
+
const cfgBase = dirname15(cfg.configFile);
|
|
17560
|
+
const configured = configuredRepos(configText, route.route, cfgBase);
|
|
17561
|
+
const dir = configured.find((r) => r.name === SAMPLE_REPO)?.dir ?? sampleDir(configured.map((r) => r.dir), route.reposBasePath, route.route.split("/")[0]);
|
|
17562
|
+
const resolvedFile = resolvedPathFor(cfg.ship.stateDir, route.route);
|
|
17563
|
+
const rows = await tracker.repoRows();
|
|
17564
|
+
const row = rows.find((r) => r.name === SAMPLE_REPO);
|
|
17565
|
+
if (flag("remove")) {
|
|
17566
|
+
const closed = [];
|
|
17567
|
+
if (row) {
|
|
17568
|
+
const wontFix = tracker.contract.statuses.wontFix;
|
|
17569
|
+
const resolved = new Set(tracker.contract.statuses.resolved);
|
|
17570
|
+
for (const t of await tracker.ticketsForRepo(row.id)) {
|
|
17571
|
+
if (resolved.has(t.status) || !isSampleTicket(t.description)) continue;
|
|
17572
|
+
if (!dryRun) {
|
|
17573
|
+
await tracker.updateTicket(t.id, {
|
|
17574
|
+
[tracker.contract.columns.status]: wontFix,
|
|
17575
|
+
[tracker.contract.columns.resolutionNote]: "Sample ticket removed by `crew sample-ticket --remove`; obsolete."
|
|
17576
|
+
});
|
|
17577
|
+
}
|
|
17578
|
+
closed.push(t.issue_tag ?? t.issue_id);
|
|
17579
|
+
}
|
|
17580
|
+
}
|
|
17581
|
+
if (dryRun) {
|
|
17582
|
+
say("dry-run", { dir, row: row?.id ?? null, closed }, `would close ${closed.join(", ") || "no tickets"}, delete the Repos row, detach and delete ${dir}`);
|
|
17583
|
+
break;
|
|
17584
|
+
}
|
|
17585
|
+
if (configured.some((r) => r.dir === dir)) {
|
|
17586
|
+
try {
|
|
17587
|
+
writeFileSync15(cfg.configFile, planRepoRemove(configText, cfgBase, route.route, dir).text);
|
|
17588
|
+
} catch (e) {
|
|
17589
|
+
if (e instanceof ReposError) fail(e.message);
|
|
17590
|
+
throw e;
|
|
17591
|
+
}
|
|
17592
|
+
}
|
|
17593
|
+
if (row) {
|
|
17594
|
+
await tracker.deleteRepoRow(row.id);
|
|
17595
|
+
if (existsSync28(resolvedFile)) {
|
|
17596
|
+
const raw = JSON.parse(readFileSync21(resolvedFile, "utf8"));
|
|
17597
|
+
if (raw.repoNames) delete raw.repoNames[row.id];
|
|
17598
|
+
if (raw.repoRemotes) delete raw.repoRemotes[row.id];
|
|
17599
|
+
writeFileSync15(resolvedFile, `${JSON.stringify(raw, null, 2)}
|
|
17600
|
+
`);
|
|
17601
|
+
}
|
|
17602
|
+
}
|
|
17603
|
+
if (existsSync28(join29(dir, ".git")) && basename10(dir) === SAMPLE_REPO) rmSync11(dir, { recursive: true, force: true });
|
|
17604
|
+
say("removed", { dir, closed }, `removed the sample repo${closed.length ? `, closed ${closed.join(", ")}` : ""}`);
|
|
17605
|
+
break;
|
|
17606
|
+
}
|
|
17607
|
+
const projectId = route.resolved.areaId ?? resolveRepoProject(await tracker.projectRows(), value("project"));
|
|
17608
|
+
const fresh = !existsSync28(join29(dir, ".git"));
|
|
17609
|
+
if (existsSync28(dir) && fresh && readdirSync11(dir).length > 0) fail(`${dir} exists and is not a git checkout \u2014 move it aside`);
|
|
17610
|
+
if (dryRun) {
|
|
17611
|
+
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`);
|
|
17612
|
+
break;
|
|
17613
|
+
}
|
|
17614
|
+
if (fresh) {
|
|
17615
|
+
mkdirSync14(dir, { recursive: true });
|
|
17616
|
+
writeFileSync15(join29(dir, "README.md"), SAMPLE_README);
|
|
17617
|
+
writeFileSync15(join29(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
|
|
17618
|
+
const g = (...a) => {
|
|
17619
|
+
const r = spawnSync6("git", a, { cwd: dir, encoding: "utf8" });
|
|
17620
|
+
if (r.status !== 0) fail(`git ${a[0]} failed in ${dir}: ${(r.stderr || r.stdout).trim()}`);
|
|
17621
|
+
};
|
|
17622
|
+
g("init", "-b", "main");
|
|
17623
|
+
g("add", ".");
|
|
17624
|
+
g("-c", "user.name=crew", "-c", "user.email=crew@localhost", "commit", "-m", "Initial commit");
|
|
17625
|
+
}
|
|
17626
|
+
let repoId = row?.id;
|
|
17627
|
+
if (!repoId) {
|
|
17628
|
+
const payload = planRepoRow({
|
|
17629
|
+
name: SAMPLE_REPO,
|
|
17630
|
+
origin: SAMPLE_REMOTE,
|
|
17631
|
+
projectId,
|
|
17632
|
+
spec: { platform: "unix", releaseMode: "integrate", ciProvider: "none" }
|
|
17633
|
+
});
|
|
17634
|
+
repoId = (await tracker.createRepoRow(payload)).id;
|
|
17635
|
+
if (existsSync28(resolvedFile)) {
|
|
17636
|
+
const raw = JSON.parse(readFileSync21(resolvedFile, "utf8"));
|
|
17637
|
+
raw.repoNames = { ...raw.repoNames, [repoId]: SAMPLE_REPO };
|
|
17638
|
+
raw.repoRemotes = { ...raw.repoRemotes, [repoId]: SAMPLE_REMOTE };
|
|
17639
|
+
writeFileSync15(resolvedFile, `${JSON.stringify(raw, null, 2)}
|
|
17640
|
+
`);
|
|
17641
|
+
}
|
|
17642
|
+
}
|
|
17643
|
+
if (!configured.some((r) => r.dir === dir)) {
|
|
17644
|
+
try {
|
|
17645
|
+
writeFileSync15(cfg.configFile, planRepoAdd({ text: configText, base: cfgBase, route: route.route, path: dir, name: SAMPLE_REPO }).text);
|
|
17646
|
+
} catch (e) {
|
|
17647
|
+
if (e instanceof ReposError) fail(e.message);
|
|
17648
|
+
throw e;
|
|
17649
|
+
}
|
|
17650
|
+
}
|
|
17651
|
+
const ticket = await tracker.fileTicket(
|
|
17652
|
+
planSampleTicket({ projectId, repoId, approvedStatus: tracker.contract.statuses.approved }),
|
|
17653
|
+
route.resolved.seats.dev ?? ""
|
|
17654
|
+
);
|
|
17655
|
+
const tag = ticket.issue_tag ?? ticket.issue_id;
|
|
17656
|
+
say("filed", { ticket: tag, id: ticket.id, dir, repoId }, `filed ${tag} against ${dir}; follow it with \`crew watch\``);
|
|
17657
|
+
break;
|
|
17658
|
+
}
|
|
17434
17659
|
case "selftest": {
|
|
17435
17660
|
const wanted = value("toolchain");
|
|
17436
17661
|
const all = shipToolchains(cfg.ship);
|
|
@@ -17609,7 +17834,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
17609
17834
|
// `synthesis-tabl-123`. `worktreePrefix` above is kept for
|
|
17610
17835
|
// config-provenance display; this is the field that actually
|
|
17611
17836
|
// describes where a worktree lands by default.
|
|
17612
|
-
worktreeDirPattern: r.config.worktrees.name ?? (r.config.provenance["worktrees.prefix"] !== "default" ? `${r.config.worktrees.prefix}<number>` : `${
|
|
17837
|
+
worktreeDirPattern: r.config.worktrees.name ?? (r.config.provenance["worktrees.prefix"] !== "default" ? `${r.config.worktrees.prefix}<number>` : `${basename10(r.dir.replace(/[/\\]+$/, ""))}-<branch>`),
|
|
17613
17838
|
worktreeParent: resolvePath(r.dir, "..")
|
|
17614
17839
|
})),
|
|
17615
17840
|
waiting: waiting ? { ticket: waiting.ticket, since: waiting.since, streak: fairness.streak() } : null
|
|
@@ -17742,7 +17967,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
|
|
|
17742
17967
|
);
|
|
17743
17968
|
for (const r of await resolvedRepos(route)) {
|
|
17744
17969
|
const { config } = r;
|
|
17745
|
-
const worktreePattern = config.worktrees.name ?? (config.provenance["worktrees.prefix"] !== "default" ? `${config.worktrees.prefix}<number>` : `${
|
|
17970
|
+
const worktreePattern = config.worktrees.name ?? (config.provenance["worktrees.prefix"] !== "default" ? `${config.worktrees.prefix}<number>` : `${basename10(r.dir.replace(/[/\\]+$/, ""))}-${effectiveBranchTemplate(config, void 0)}`);
|
|
17746
17971
|
process.stdout.write(
|
|
17747
17972
|
`${`repo ${r.name}:`.padEnd(19)}${r.dir}
|
|
17748
17973
|
worktrees at ../${worktreePattern} (${config.provenance["worktrees.name"] ?? config.provenance["worktrees.prefix"] ?? "default"}), branch ${config.branch.name}
|
package/package.json
CHANGED