@helmsmith/workbrokr 0.0.0-stage → 0.1.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/LICENSE +201 -0
- package/README.md +66 -2
- package/SKILL.md +56 -0
- package/bin/work.mjs +3 -0
- package/bin/workbrokr.mjs +3 -0
- package/dist/cli.js +2468 -0
- package/dist/cli.js.map +1 -0
- package/package.json +44 -3
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2468 @@
|
|
|
1
|
+
// ../../platform/core/cli-kit-lib/src/auth-provider.ts
|
|
2
|
+
var noopAuthProvider = {
|
|
3
|
+
async getToken() {
|
|
4
|
+
throw new Error(
|
|
5
|
+
"noopAuthProvider.getToken() called \u2014 wire a real AuthProvider via createCli({ auth })"
|
|
6
|
+
);
|
|
7
|
+
},
|
|
8
|
+
async whoami() {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// ../../platform/core/cli-kit-lib/src/create-cli.ts
|
|
14
|
+
import { Command } from "commander";
|
|
15
|
+
function createCli(opts) {
|
|
16
|
+
const program2 = new Command();
|
|
17
|
+
program2.name(opts.name).version(opts.version);
|
|
18
|
+
if (opts.description) program2.description(opts.description);
|
|
19
|
+
program2.configureOutput({
|
|
20
|
+
outputError: (str, write) => write(str)
|
|
21
|
+
});
|
|
22
|
+
return { program: program2, auth: opts.auth ?? noopAuthProvider };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/commands/audit.ts
|
|
26
|
+
import chalk3 from "chalk";
|
|
27
|
+
|
|
28
|
+
// ../../platform/core/workkit-lib/src/config.ts
|
|
29
|
+
import {
|
|
30
|
+
appendFileSync,
|
|
31
|
+
existsSync,
|
|
32
|
+
mkdirSync as mkdirSync2,
|
|
33
|
+
readFileSync,
|
|
34
|
+
realpathSync as realpathSync2,
|
|
35
|
+
writeFileSync
|
|
36
|
+
} from "fs";
|
|
37
|
+
import { join } from "path";
|
|
38
|
+
|
|
39
|
+
// ../../platform/core/workkit-lib/src/errors.ts
|
|
40
|
+
var REFUSALS = /* @__PURE__ */ new Set([
|
|
41
|
+
"MANIFEST_INVALID",
|
|
42
|
+
"AREA_UNKNOWN",
|
|
43
|
+
"TYPE_UNKNOWN",
|
|
44
|
+
"MODE_UNKNOWN",
|
|
45
|
+
"MAIN_CHECKOUT",
|
|
46
|
+
"WORKTREE_INVALID",
|
|
47
|
+
"PROTECTED_BRANCH",
|
|
48
|
+
"EFFORT_OPEN",
|
|
49
|
+
"EFFORT_NOT_FOUND",
|
|
50
|
+
"SCOPE_CONFLICT",
|
|
51
|
+
"COMMONS_LOCKED",
|
|
52
|
+
"NO_FREE_SLOT",
|
|
53
|
+
"LEASE_NOT_FOUND",
|
|
54
|
+
"PORT_RANGE_TAKEN",
|
|
55
|
+
"SYNC_CONFLICT",
|
|
56
|
+
"DIRTY_WORKTREE",
|
|
57
|
+
"UNPUSHED"
|
|
58
|
+
]);
|
|
59
|
+
function exitCodeFor(code) {
|
|
60
|
+
return REFUSALS.has(code) ? 2 : 1;
|
|
61
|
+
}
|
|
62
|
+
var WorkkitError = class extends Error {
|
|
63
|
+
code;
|
|
64
|
+
details;
|
|
65
|
+
constructor(code, message, details) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = "WorkkitError";
|
|
68
|
+
this.code = code;
|
|
69
|
+
if (details) this.details = details;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
function toErrorBody(err) {
|
|
73
|
+
if (err instanceof WorkkitError) {
|
|
74
|
+
return err.details ? { code: err.code, message: err.message, details: err.details } : { code: err.code, message: err.message };
|
|
75
|
+
}
|
|
76
|
+
return { code: "INTERNAL", message: err instanceof Error ? err.message : String(err) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ../../platform/core/workkit-lib/src/git.ts
|
|
80
|
+
import { spawnSync } from "child_process";
|
|
81
|
+
import { mkdirSync, realpathSync } from "fs";
|
|
82
|
+
import { dirname } from "path";
|
|
83
|
+
var Git = class {
|
|
84
|
+
constructor(cwd) {
|
|
85
|
+
this.cwd = cwd;
|
|
86
|
+
}
|
|
87
|
+
cwd;
|
|
88
|
+
tryRun(args) {
|
|
89
|
+
const r = spawnSync("git", args, { cwd: this.cwd, encoding: "utf8" });
|
|
90
|
+
return { code: r.status ?? 1, out: r.stdout ?? "", err: r.stderr ?? "" };
|
|
91
|
+
}
|
|
92
|
+
run(args) {
|
|
93
|
+
const r = this.tryRun(args);
|
|
94
|
+
if (r.code !== 0) {
|
|
95
|
+
throw new WorkkitError(
|
|
96
|
+
"GIT_FAILED",
|
|
97
|
+
`git ${args.join(" ")} (in ${this.cwd}): ${r.err.trim() || r.out.trim()}`,
|
|
98
|
+
{
|
|
99
|
+
args,
|
|
100
|
+
cwd: this.cwd
|
|
101
|
+
}
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return r.out;
|
|
105
|
+
}
|
|
106
|
+
lines(args) {
|
|
107
|
+
return this.run(args).split("\n").map((l) => l.trim()).filter(Boolean);
|
|
108
|
+
}
|
|
109
|
+
/** The checkout's top level (symlinks resolved). */
|
|
110
|
+
root() {
|
|
111
|
+
return realpathSync(this.run(["rev-parse", "--show-toplevel"]).trim());
|
|
112
|
+
}
|
|
113
|
+
/** The main checkout's top level, from any worktree of the repo. */
|
|
114
|
+
mainRoot() {
|
|
115
|
+
const common = realpathSync(
|
|
116
|
+
this.run(["rev-parse", "--path-format=absolute", "--git-common-dir"]).trim()
|
|
117
|
+
);
|
|
118
|
+
return dirname(common);
|
|
119
|
+
}
|
|
120
|
+
/** The main checkout is the one whose git dir IS the common dir (compared as absolute realpaths). */
|
|
121
|
+
isMainCheckout() {
|
|
122
|
+
const gitDir = realpathSync(
|
|
123
|
+
this.run(["rev-parse", "--path-format=absolute", "--git-dir"]).trim()
|
|
124
|
+
);
|
|
125
|
+
const common = realpathSync(
|
|
126
|
+
this.run(["rev-parse", "--path-format=absolute", "--git-common-dir"]).trim()
|
|
127
|
+
);
|
|
128
|
+
return gitDir === common;
|
|
129
|
+
}
|
|
130
|
+
currentBranch() {
|
|
131
|
+
return this.run(["branch", "--show-current"]).trim();
|
|
132
|
+
}
|
|
133
|
+
branchExists(name) {
|
|
134
|
+
return this.tryRun(["rev-parse", "--verify", "-q", `refs/heads/${name}`]).code === 0;
|
|
135
|
+
}
|
|
136
|
+
remoteBranchExists(name) {
|
|
137
|
+
return this.run(["ls-remote", "--heads", "origin", name]).trim().length > 0;
|
|
138
|
+
}
|
|
139
|
+
fetch(branch) {
|
|
140
|
+
this.run(["fetch", "-q", "origin", branch]);
|
|
141
|
+
}
|
|
142
|
+
hasUpstream() {
|
|
143
|
+
return this.tryRun(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).code === 0;
|
|
144
|
+
}
|
|
145
|
+
/** Commits on HEAD that origin does not have: against the upstream, else the same-named remote branch, else origin/<base>. */
|
|
146
|
+
unpushedCount(base) {
|
|
147
|
+
let ref = `origin/${base}`;
|
|
148
|
+
if (this.hasUpstream()) ref = "@{u}";
|
|
149
|
+
else if (this.remoteBranchExists(this.currentBranch())) ref = `origin/${this.currentBranch()}`;
|
|
150
|
+
return this.lines(["rev-list", `${ref}..HEAD`]).length;
|
|
151
|
+
}
|
|
152
|
+
isDirty() {
|
|
153
|
+
return this.run(["status", "--porcelain"]).trim().length > 0;
|
|
154
|
+
}
|
|
155
|
+
changedPathsStaged() {
|
|
156
|
+
return this.lines(["diff", "--cached", "--name-only"]);
|
|
157
|
+
}
|
|
158
|
+
changedPaths(from, to) {
|
|
159
|
+
return this.lines(["diff", "--name-only", "--no-renames", from, to]);
|
|
160
|
+
}
|
|
161
|
+
/** Paths this branch changed since it diverged from origin/<base> (three-dot), without the base's own changes. */
|
|
162
|
+
changedSince(base) {
|
|
163
|
+
return this.lines(["diff", "--name-only", "--no-renames", `origin/${base}...HEAD`]);
|
|
164
|
+
}
|
|
165
|
+
commitsNotOn(base) {
|
|
166
|
+
return this.lines(["rev-list", `origin/${base}..HEAD`]);
|
|
167
|
+
}
|
|
168
|
+
rev(ref) {
|
|
169
|
+
return this.run(["rev-parse", ref]).trim();
|
|
170
|
+
}
|
|
171
|
+
/** Fast-forward when HEAD has no own commits, otherwise merge; never rebase. A conflict is left in place. */
|
|
172
|
+
syncFrom(base) {
|
|
173
|
+
const target = `origin/${base}`;
|
|
174
|
+
const before = this.rev("HEAD");
|
|
175
|
+
const targetRev = this.rev(target);
|
|
176
|
+
if (before === targetRev) return { kind: "up-to-date", before, after: before, conflicted: [] };
|
|
177
|
+
if (this.tryRun(["merge-base", "--is-ancestor", "HEAD", target]).code === 0) {
|
|
178
|
+
this.run(["merge", "--ff-only", "-q", target]);
|
|
179
|
+
return { kind: "ff", before, after: this.rev("HEAD"), conflicted: [] };
|
|
180
|
+
}
|
|
181
|
+
if (this.tryRun(["merge-base", "--is-ancestor", target, "HEAD"]).code === 0) {
|
|
182
|
+
return { kind: "up-to-date", before, after: before, conflicted: [] };
|
|
183
|
+
}
|
|
184
|
+
const merged = this.tryRun(["merge", "--no-edit", "-q", target]);
|
|
185
|
+
if (merged.code === 0)
|
|
186
|
+
return { kind: "merge", before, after: this.rev("HEAD"), conflicted: [] };
|
|
187
|
+
const conflicted = this.lines(["diff", "--name-only", "--diff-filter=U"]);
|
|
188
|
+
return { kind: "conflict", before, after: before, conflicted };
|
|
189
|
+
}
|
|
190
|
+
worktreeAdd(path, branch, startPoint) {
|
|
191
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
192
|
+
if (this.branchExists(branch)) this.run(["worktree", "add", "-q", path, branch]);
|
|
193
|
+
else
|
|
194
|
+
this.run(["worktree", "add", "-q", "--no-track", "-b", branch, path, startPoint ?? "HEAD"]);
|
|
195
|
+
}
|
|
196
|
+
worktreeRemove(path) {
|
|
197
|
+
this.run(["worktree", "remove", "--force", path]);
|
|
198
|
+
}
|
|
199
|
+
checkout(branch, createFrom) {
|
|
200
|
+
if (this.branchExists(branch)) this.run(["checkout", "-q", branch]);
|
|
201
|
+
else this.run(["checkout", "-q", "--no-track", "-b", branch, createFrom ?? "HEAD"]);
|
|
202
|
+
}
|
|
203
|
+
push(branch) {
|
|
204
|
+
this.run(["push", "-q", "-u", "origin", branch]);
|
|
205
|
+
}
|
|
206
|
+
deleteLocalBranch(branch) {
|
|
207
|
+
this.run(["branch", "-q", "-D", branch]);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// ../../platform/core/workkit-lib/src/config.ts
|
|
212
|
+
function renderTemplate(s, vars) {
|
|
213
|
+
return s.replace(/\$\{([A-Z0-9_]+)\}/g, (_, name) => {
|
|
214
|
+
if (!(name in vars)) {
|
|
215
|
+
throw new WorkkitError(
|
|
216
|
+
"MANIFEST_INVALID",
|
|
217
|
+
`template references ${name}, which this effort does not define`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return vars[name];
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
function slotVars(m, o) {
|
|
224
|
+
const vars = {
|
|
225
|
+
SLOT: o.slot === void 0 ? "" : String(o.slot),
|
|
226
|
+
PROJECT: m.project,
|
|
227
|
+
AREA: o.area ?? "",
|
|
228
|
+
BRANCH: o.branch ?? "",
|
|
229
|
+
WORKTREE: o.worktree ?? "",
|
|
230
|
+
LOG_DIR: o.logDir ?? "",
|
|
231
|
+
PORT_STORYBOOK: o.storybookPort === void 0 ? "" : String(o.storybookPort)
|
|
232
|
+
};
|
|
233
|
+
for (const service of Object.keys(m.ports.services)) {
|
|
234
|
+
const p = o.ports[service];
|
|
235
|
+
vars[`PORT_${service.toUpperCase()}`] = p === void 0 ? "" : String(p);
|
|
236
|
+
}
|
|
237
|
+
return vars;
|
|
238
|
+
}
|
|
239
|
+
function ensureExcluded(worktree, patterns) {
|
|
240
|
+
const r = new Git(worktree).tryRun(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
241
|
+
if (r.code !== 0) return;
|
|
242
|
+
const common = realpathSync2(r.out.trim());
|
|
243
|
+
const file = join(common, "info", "exclude");
|
|
244
|
+
mkdirSync2(join(common, "info"), { recursive: true });
|
|
245
|
+
const present = new Set(
|
|
246
|
+
existsSync(file) ? readFileSync(file, "utf8").split("\n").map((l) => l.trim()) : []
|
|
247
|
+
);
|
|
248
|
+
const missing = patterns.filter((p) => !present.has(p));
|
|
249
|
+
if (missing.length === 0) return;
|
|
250
|
+
appendFileSync(
|
|
251
|
+
file,
|
|
252
|
+
`${present.size ? "\n" : ""}# written by @helmsmith/workkit: per-worktree config and logs
|
|
253
|
+
${missing.join("\n")}
|
|
254
|
+
`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
function emitWorktreeConfig(a) {
|
|
258
|
+
ensureExcluded(a.worktree, [
|
|
259
|
+
".slot",
|
|
260
|
+
a.m.env.file,
|
|
261
|
+
`${a.m.logs.dir.replace(/\/+$/, "")}/`,
|
|
262
|
+
`${a.m.worktreeRoot.replace(/\/+$/, "")}/`
|
|
263
|
+
]);
|
|
264
|
+
const logDir = join(a.worktree, a.m.logs.dir);
|
|
265
|
+
mkdirSync2(logDir, { recursive: true });
|
|
266
|
+
const vars = slotVars(a.m, { ...a, logDir });
|
|
267
|
+
const envFile = join(a.worktree, a.m.env.file);
|
|
268
|
+
const lines = Object.entries(a.m.env.template).map(([k, v]) => `${k}=${renderTemplate(v, vars)}`);
|
|
269
|
+
writeFileSync(envFile, `${lines.join("\n")}
|
|
270
|
+
`);
|
|
271
|
+
let slotFile;
|
|
272
|
+
if (a.slot !== void 0) {
|
|
273
|
+
slotFile = join(a.worktree, ".slot");
|
|
274
|
+
writeFileSync(slotFile, `${a.slot}
|
|
275
|
+
`);
|
|
276
|
+
}
|
|
277
|
+
return slotFile ? { envFile, slotFile, logDir, vars } : { envFile, logDir, vars };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ../../platform/core/workkit-lib/src/guards.ts
|
|
281
|
+
import { existsSync as existsSync2, realpathSync as realpathSync3 } from "fs";
|
|
282
|
+
|
|
283
|
+
// ../../platform/core/workkit-lib/src/scopes.ts
|
|
284
|
+
import picomatch from "picomatch";
|
|
285
|
+
function areaVars(area) {
|
|
286
|
+
const parts = area.split("/");
|
|
287
|
+
return { group: parts.slice(0, -1).join("/"), section: parts[parts.length - 1] };
|
|
288
|
+
}
|
|
289
|
+
function fill(template, vars) {
|
|
290
|
+
let missing = false;
|
|
291
|
+
const out = template.replace(/\{(group|section|module)\}/g, (_, name) => {
|
|
292
|
+
const v = vars[name];
|
|
293
|
+
if (v === void 0 || v === "") {
|
|
294
|
+
missing = true;
|
|
295
|
+
return "";
|
|
296
|
+
}
|
|
297
|
+
return v;
|
|
298
|
+
});
|
|
299
|
+
return missing ? null : out;
|
|
300
|
+
}
|
|
301
|
+
function expandScopes(m, area, mode) {
|
|
302
|
+
const spec = m.areas[area];
|
|
303
|
+
if (!spec)
|
|
304
|
+
throw new WorkkitError(
|
|
305
|
+
"AREA_UNKNOWN",
|
|
306
|
+
`unknown area "${area}" (known: ${Object.keys(m.areas).join(", ")})`
|
|
307
|
+
);
|
|
308
|
+
const vars = { ...areaVars(area), module: spec.module };
|
|
309
|
+
const out = [];
|
|
310
|
+
for (const t of m.scopes.frontend) {
|
|
311
|
+
const g = fill(t, vars);
|
|
312
|
+
if (g) out.push(g);
|
|
313
|
+
}
|
|
314
|
+
if (mode === "fullstack") {
|
|
315
|
+
if (!spec.module) {
|
|
316
|
+
throw new WorkkitError(
|
|
317
|
+
"MANIFEST_INVALID",
|
|
318
|
+
`area "${area}" has no module, so a fullstack effort cannot hold a backend scope; add areas["${area}"].module or use --mode frontend`
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
for (const t of m.scopes.backend) {
|
|
322
|
+
const g = fill(t, vars);
|
|
323
|
+
if (g) out.push(g);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
out.push(...spec.extraScopes ?? []);
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
function literalPrefix(glob) {
|
|
330
|
+
const i = glob.search(/[*?[{]/);
|
|
331
|
+
return i < 0 ? glob : glob.slice(0, i);
|
|
332
|
+
}
|
|
333
|
+
function globsOverlap(a, b) {
|
|
334
|
+
const pairs = [];
|
|
335
|
+
for (const x of a) {
|
|
336
|
+
const px = literalPrefix(x);
|
|
337
|
+
for (const y of b) {
|
|
338
|
+
const py = literalPrefix(y);
|
|
339
|
+
if (px.startsWith(py) || py.startsWith(px)) pairs.push(`${x} ~ ${y}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return pairs;
|
|
343
|
+
}
|
|
344
|
+
function pathsMatching(paths, globs) {
|
|
345
|
+
if (globs.length === 0) return [];
|
|
346
|
+
const isMatch = picomatch(globs, { dot: true });
|
|
347
|
+
return paths.filter((p) => isMatch(p));
|
|
348
|
+
}
|
|
349
|
+
function commonsTouched(m, paths) {
|
|
350
|
+
return Object.entries(m.commons).filter(([, globs]) => pathsMatching(paths, globs).length > 0).map(([name]) => name);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ../../platform/core/workkit-lib/src/guards.ts
|
|
354
|
+
function assertNotProtected(m, branch) {
|
|
355
|
+
if (m.protectedBranches.includes(branch)) {
|
|
356
|
+
throw new WorkkitError(
|
|
357
|
+
"PROTECTED_BRANCH",
|
|
358
|
+
`branch "${branch}" is protected (${m.protectedBranches.join(", ")}): work happens on <type>/<area> branches, never here`,
|
|
359
|
+
{ branch }
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
var same = (a, b) => {
|
|
364
|
+
try {
|
|
365
|
+
return realpathSync3(a) === realpathSync3(b);
|
|
366
|
+
} catch {
|
|
367
|
+
return a === b;
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
function checkCommit(c) {
|
|
371
|
+
const refusals = [];
|
|
372
|
+
if (c.m.protectedBranches.includes(c.branch)) {
|
|
373
|
+
refusals.push(
|
|
374
|
+
`branch "${c.branch}" is protected (${c.m.protectedBranches.join(", ")}): work happens on <type>/<area> branches, never here`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
const efforts = new Map(c.registry.efforts({ project: c.project }).map((e) => [e.id, e]));
|
|
378
|
+
for (const lease of c.registry.leases({ project: c.project, kind: "commons" })) {
|
|
379
|
+
if (lease.owner.session === c.ownerSession) continue;
|
|
380
|
+
const holder = efforts.get(lease.effort);
|
|
381
|
+
if (holder && c.checkoutRoot && existsSync2(holder.worktree) && same(holder.worktree, c.checkoutRoot))
|
|
382
|
+
continue;
|
|
383
|
+
const globs = c.m.commons[String(lease.value)] ?? [];
|
|
384
|
+
const hit = pathsMatching(c.stagedPaths, globs);
|
|
385
|
+
if (hit.length > 0) {
|
|
386
|
+
refusals.push(
|
|
387
|
+
`commons "${lease.value}" is write-locked by ${lease.owner.session} (effort ${lease.effort}, since ${lease.touched}); staged: ${hit.join(", ")}`
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return { refusals };
|
|
392
|
+
}
|
|
393
|
+
function checkPush(c) {
|
|
394
|
+
const refusals = [];
|
|
395
|
+
const warnings = [];
|
|
396
|
+
const destination = (c.remoteRef ?? `refs/heads/${c.branch}`).replace(/^refs\/heads\//, "");
|
|
397
|
+
if (c.m.protectedBranches.includes(destination)) {
|
|
398
|
+
refusals.push(
|
|
399
|
+
c.isDelete ? `refusing to delete protected branch "${destination}"` : `"${destination}" is protected (${c.m.protectedBranches.join(", ")}): nothing is pushed to it directly, open a PR from ${c.branch || "an area branch"}`
|
|
400
|
+
);
|
|
401
|
+
return { refusals, warnings };
|
|
402
|
+
}
|
|
403
|
+
if (c.isDelete) return { refusals, warnings };
|
|
404
|
+
const effort = c.registry.efforts({ project: c.project, state: "active" }).find((e) => e.branch === c.branch);
|
|
405
|
+
const commons = commonsTouched(c.m, c.changedPaths);
|
|
406
|
+
if (effort) {
|
|
407
|
+
const allCommonsGlobs = Object.values(c.m.commons).flat();
|
|
408
|
+
const inScope = new Set(pathsMatching(c.changedPaths, [...effort.scopes, ...allCommonsGlobs]));
|
|
409
|
+
const outside = c.changedPaths.filter((p) => !inScope.has(p));
|
|
410
|
+
if (outside.length > 0) {
|
|
411
|
+
warnings.push(
|
|
412
|
+
`${outside.length} changed path(s) are outside the effort's scope (${effort.area}): ${outside.join(", ")} \u2014 add a scope template or expect a merge collision`
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
for (const name of commons)
|
|
417
|
+
warnings.push(`commons ${name} touched: the other areas are told at \`work finish\``);
|
|
418
|
+
return { refusals, warnings };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ../../platform/core/workkit-lib/src/hooks.ts
|
|
422
|
+
import { chmodSync, lstatSync, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
423
|
+
import { join as join3 } from "path";
|
|
424
|
+
|
|
425
|
+
// ../../platform/core/workkit-lib/src/manifest.ts
|
|
426
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
427
|
+
import { join as join2 } from "path";
|
|
428
|
+
import { pathToFileURL } from "url";
|
|
429
|
+
import { z } from "zod";
|
|
430
|
+
var MANIFEST_FILES = [
|
|
431
|
+
"helmsmith.config.ts",
|
|
432
|
+
"helmsmith.config.mjs",
|
|
433
|
+
"helmsmith.config.json"
|
|
434
|
+
];
|
|
435
|
+
var BRANCH_NAME = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
436
|
+
var BRANCH_NAME_HINT = 'must be a plain ref name: letters, digits, ".", "_", "/", "-"';
|
|
437
|
+
var Service = z.object({
|
|
438
|
+
start: z.string().min(1),
|
|
439
|
+
stop: z.string().min(1),
|
|
440
|
+
health: z.string().min(1),
|
|
441
|
+
healthyCodes: z.array(z.number().int()).optional(),
|
|
442
|
+
reset: z.string().optional(),
|
|
443
|
+
healthTimeoutMs: z.number().int().positive().optional()
|
|
444
|
+
});
|
|
445
|
+
var Schema = z.object({
|
|
446
|
+
project: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
|
447
|
+
baseBranch: z.string().regex(BRANCH_NAME, BRANCH_NAME_HINT),
|
|
448
|
+
protectedBranches: z.array(z.string().regex(BRANCH_NAME, BRANCH_NAME_HINT)).optional(),
|
|
449
|
+
worktreeRoot: z.string().default(".claude/worktrees"),
|
|
450
|
+
branch: z.object({ types: z.array(z.string().regex(/^[a-z]+$/)).min(1) }).default({ types: ["feature", "chore", "fix"] }),
|
|
451
|
+
slots: z.object({
|
|
452
|
+
count: z.number().int().min(1).max(100),
|
|
453
|
+
reserved: z.array(z.number().int().min(0))
|
|
454
|
+
}).default({ count: 10, reserved: [0] }),
|
|
455
|
+
ports: z.object({
|
|
456
|
+
services: z.record(z.string(), z.object({ base: z.number().int().min(1024).max(65535) }))
|
|
457
|
+
}),
|
|
458
|
+
services: z.record(z.string(), Service),
|
|
459
|
+
storybook: z.object({ hostRoot: z.string() }).optional(),
|
|
460
|
+
logs: z.object({ dir: z.string() }).default({ dir: ".logs" }),
|
|
461
|
+
env: z.object({ file: z.string(), template: z.record(z.string(), z.string()) }).optional(),
|
|
462
|
+
refresh: z.array(
|
|
463
|
+
z.object({
|
|
464
|
+
when: z.string().optional(),
|
|
465
|
+
always: z.boolean().optional(),
|
|
466
|
+
run: z.string().optional(),
|
|
467
|
+
mark: z.string().optional()
|
|
468
|
+
})
|
|
469
|
+
).default([]),
|
|
470
|
+
scopes: z.object({ frontend: z.array(z.string()), backend: z.array(z.string()) }),
|
|
471
|
+
areas: z.record(
|
|
472
|
+
z.string().regex(/^[a-z0-9-]+(\/[a-z0-9-]+)*$/),
|
|
473
|
+
z.object({
|
|
474
|
+
module: z.string().optional(),
|
|
475
|
+
commons: z.array(z.string()).optional(),
|
|
476
|
+
extraScopes: z.array(z.string()).optional()
|
|
477
|
+
})
|
|
478
|
+
),
|
|
479
|
+
commons: z.record(z.string(), z.array(z.string())).default({}),
|
|
480
|
+
expiry: z.object({
|
|
481
|
+
slotHours: z.number().positive(),
|
|
482
|
+
commonsHours: z.number().positive(),
|
|
483
|
+
storybookHours: z.number().positive()
|
|
484
|
+
}).default({ slotHours: 12, commonsHours: 4, storybookHours: 2 }),
|
|
485
|
+
archive: z.object({
|
|
486
|
+
paths: z.array(z.string()),
|
|
487
|
+
keepDays: z.number().int().positive(),
|
|
488
|
+
keepPerArea: z.number().int().positive()
|
|
489
|
+
}).default({ paths: [], keepDays: 30, keepPerArea: 5 })
|
|
490
|
+
});
|
|
491
|
+
function templateVariables(m) {
|
|
492
|
+
const vars = /* @__PURE__ */ new Set([
|
|
493
|
+
"SLOT",
|
|
494
|
+
"LOG_DIR",
|
|
495
|
+
"PROJECT",
|
|
496
|
+
"AREA",
|
|
497
|
+
"BRANCH",
|
|
498
|
+
"WORKTREE",
|
|
499
|
+
"PORT_STORYBOOK"
|
|
500
|
+
]);
|
|
501
|
+
for (const s of Object.keys(m.ports.services)) vars.add(`PORT_${s.toUpperCase()}`);
|
|
502
|
+
return vars;
|
|
503
|
+
}
|
|
504
|
+
function defaultEnv(services) {
|
|
505
|
+
const template = { SLOT: "${SLOT}", LOG_DIR: "${LOG_DIR}" };
|
|
506
|
+
for (const s of services) template[`PORT_${s.toUpperCase()}`] = `\${PORT_${s.toUpperCase()}}`;
|
|
507
|
+
return { file: ".env.slot", template };
|
|
508
|
+
}
|
|
509
|
+
function validateManifest(raw) {
|
|
510
|
+
const parsed = Schema.safeParse(raw);
|
|
511
|
+
if (!parsed.success) {
|
|
512
|
+
throw new WorkkitError(
|
|
513
|
+
"MANIFEST_INVALID",
|
|
514
|
+
parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
const m = parsed.data;
|
|
518
|
+
const problems = [];
|
|
519
|
+
const areas = Object.keys(m.areas).sort();
|
|
520
|
+
for (const a of areas) {
|
|
521
|
+
for (const b of areas) {
|
|
522
|
+
if (a !== b && b.startsWith(`${a}/`)) {
|
|
523
|
+
problems.push(`area "${a}" is an ancestor of "${b}": areas must be leaves`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
for (const s of Object.keys(m.services)) {
|
|
528
|
+
if (!m.ports.services[s]) problems.push(`service "${s}" has no port base under ports.services`);
|
|
529
|
+
}
|
|
530
|
+
for (const s of Object.keys(m.ports.services)) {
|
|
531
|
+
if (!m.services[s] && s !== "storybook") problems.push(`port "${s}" names no service`);
|
|
532
|
+
}
|
|
533
|
+
for (const r of m.slots.reserved) {
|
|
534
|
+
if (r >= m.slots.count) problems.push(`reserved slot ${r} is outside 0..${m.slots.count - 1}`);
|
|
535
|
+
}
|
|
536
|
+
const env = m.env ?? defaultEnv(Object.keys(m.ports.services));
|
|
537
|
+
const allowed = templateVariables(m);
|
|
538
|
+
for (const [k, v] of Object.entries(env.template)) {
|
|
539
|
+
for (const [, name] of v.matchAll(/\$\{([A-Z0-9_]+)\}/g)) {
|
|
540
|
+
if (!allowed.has(name)) {
|
|
541
|
+
problems.push(
|
|
542
|
+
`env.template.${k} references ${name}, which no slot derives (allowed: ${[...allowed].join(", ")})`
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
for (const [area, spec] of Object.entries(m.areas)) {
|
|
548
|
+
for (const c of spec.commons ?? []) {
|
|
549
|
+
if (!m.commons[c])
|
|
550
|
+
problems.push(`area "${area}" names commons "${c}", which is not declared`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (problems.length) throw new WorkkitError("MANIFEST_INVALID", problems.join("; "));
|
|
554
|
+
return { ...m, protectedBranches: m.protectedBranches ?? [m.baseBranch], env };
|
|
555
|
+
}
|
|
556
|
+
async function loadManifest(repoRoot) {
|
|
557
|
+
for (const name of MANIFEST_FILES) {
|
|
558
|
+
const file = join2(repoRoot, name);
|
|
559
|
+
if (!existsSync3(file)) continue;
|
|
560
|
+
if (name.endsWith(".json")) return validateManifest(JSON.parse(readFileSync2(file, "utf8")));
|
|
561
|
+
const mod = await import(`${pathToFileURL(file).href}?t=${Date.now()}`);
|
|
562
|
+
return validateManifest(mod.default);
|
|
563
|
+
}
|
|
564
|
+
throw new WorkkitError(
|
|
565
|
+
"MANIFEST_NOT_FOUND",
|
|
566
|
+
`no ${MANIFEST_FILES.join(" | ")} in ${repoRoot}; run \`work init\``
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ../../platform/core/workkit-lib/src/hooks.ts
|
|
571
|
+
var HOOKS_DIR = ".helmsmith/hooks";
|
|
572
|
+
function preCommitScript(protectedBranches) {
|
|
573
|
+
return `#!/bin/sh
|
|
574
|
+
# Installed by \`work init\` (@helmsmith/workbrokr). The protected-branch rule lives here; the rest in workbrokr.
|
|
575
|
+
PROTECTED="${protectedBranches.join(" ")}"
|
|
576
|
+
branch=$(git symbolic-ref --short -q HEAD)
|
|
577
|
+
for p in $PROTECTED; do
|
|
578
|
+
if [ "$branch" = "$p" ]; then
|
|
579
|
+
echo "work: branch \\"$branch\\" is protected ($PROTECTED): work happens on <type>/<area> branches, never here" >&2
|
|
580
|
+
exit 2
|
|
581
|
+
fi
|
|
582
|
+
done
|
|
583
|
+
if ! command -v workbrokr >/dev/null 2>&1; then
|
|
584
|
+
echo "work: workbrokr is not on PATH; commons and scope checks skipped (npm i -g @helmsmith/workbrokr)" >&2
|
|
585
|
+
exit 0
|
|
586
|
+
fi
|
|
587
|
+
exec workbrokr hook pre-commit "$@"
|
|
588
|
+
`;
|
|
589
|
+
}
|
|
590
|
+
function prePushScript(protectedBranches) {
|
|
591
|
+
return `#!/bin/sh
|
|
592
|
+
# Installed by \`work init\` (@helmsmith/workbrokr). Refuses a push to, or a delete of, a protected branch here; the rest in workbrokr.
|
|
593
|
+
PROTECTED="${protectedBranches.join(" ")}"
|
|
594
|
+
input=$(cat)
|
|
595
|
+
printf '%s\\n' "$input" | while read -r lref lsha rref rsha; do
|
|
596
|
+
[ -n "$rref" ] || continue
|
|
597
|
+
dest=\${rref#refs/heads/}
|
|
598
|
+
for p in $PROTECTED; do
|
|
599
|
+
if [ "$dest" = "$p" ]; then
|
|
600
|
+
echo "work: \\"$dest\\" is protected ($PROTECTED): nothing is pushed to it directly, open a PR from an area branch" >&2
|
|
601
|
+
exit 2
|
|
602
|
+
fi
|
|
603
|
+
done
|
|
604
|
+
done || exit 2
|
|
605
|
+
if ! command -v workbrokr >/dev/null 2>&1; then
|
|
606
|
+
echo "work: workbrokr is not on PATH; scope and commons warnings skipped" >&2
|
|
607
|
+
exit 0
|
|
608
|
+
fi
|
|
609
|
+
printf '%s\\n' "$input" | exec workbrokr hook pre-push "$@"
|
|
610
|
+
`;
|
|
611
|
+
}
|
|
612
|
+
function installHooks(repoRoot, opts) {
|
|
613
|
+
for (const b of opts.protectedBranches) {
|
|
614
|
+
if (!BRANCH_NAME.test(b))
|
|
615
|
+
throw new WorkkitError("MANIFEST_INVALID", `protected branch "${b}" is not a plain ref name`);
|
|
616
|
+
}
|
|
617
|
+
const hooksDir = join3(repoRoot, HOOKS_DIR);
|
|
618
|
+
mkdirSync3(hooksDir, { recursive: true });
|
|
619
|
+
const files = [];
|
|
620
|
+
const scripts = [
|
|
621
|
+
["pre-commit", preCommitScript(opts.protectedBranches)],
|
|
622
|
+
["pre-push", prePushScript(opts.protectedBranches)]
|
|
623
|
+
];
|
|
624
|
+
for (const [name, body] of scripts) {
|
|
625
|
+
const file = join3(hooksDir, name);
|
|
626
|
+
let existing;
|
|
627
|
+
try {
|
|
628
|
+
existing = lstatSync(file);
|
|
629
|
+
} catch {
|
|
630
|
+
existing = void 0;
|
|
631
|
+
}
|
|
632
|
+
if (existing?.isSymbolicLink()) {
|
|
633
|
+
throw new WorkkitError("INTERNAL", `refusing to write hook ${file}: it is a symlink`, {
|
|
634
|
+
file
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
writeFileSync2(file, body);
|
|
638
|
+
chmodSync(file, 493);
|
|
639
|
+
files.push(file);
|
|
640
|
+
}
|
|
641
|
+
new Git(repoRoot).run(["config", "core.hooksPath", HOOKS_DIR]);
|
|
642
|
+
return { hooksDir, files };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ../../platform/core/workkit-lib/src/paths.ts
|
|
646
|
+
import { homedir } from "os";
|
|
647
|
+
import { join as join4 } from "path";
|
|
648
|
+
function homeDir(env = process.env) {
|
|
649
|
+
return env.HELMSMITH_HOME && env.HELMSMITH_HOME.length > 0 ? env.HELMSMITH_HOME : join4(homedir(), ".cache", "helmsmith");
|
|
650
|
+
}
|
|
651
|
+
var registryFile = (home) => join4(home, "registry.json");
|
|
652
|
+
var lockDir = (home) => join4(home, "registry.lock");
|
|
653
|
+
|
|
654
|
+
// ../../platform/core/workkit-lib/src/ports.ts
|
|
655
|
+
function portsForSlot(m, slot) {
|
|
656
|
+
return Object.fromEntries(
|
|
657
|
+
Object.entries(m.ports.services).map(([service, { base }]) => [service, base + slot])
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
function rangesFor(m) {
|
|
661
|
+
return Object.fromEntries(
|
|
662
|
+
Object.entries(m.ports.services).map(([service, { base }]) => [
|
|
663
|
+
service,
|
|
664
|
+
{ base, count: m.slots.count }
|
|
665
|
+
])
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
function rangesOverlap(a, b) {
|
|
669
|
+
return a.base < b.base + b.count && b.base < a.base + a.count;
|
|
670
|
+
}
|
|
671
|
+
function claimRanges(registry, m, repoRoot) {
|
|
672
|
+
const mine = rangesFor(m);
|
|
673
|
+
const others = Object.entries(registry.read().projects).filter(([name]) => name !== m.project);
|
|
674
|
+
for (const [service, range] of Object.entries(mine)) {
|
|
675
|
+
for (const [name, entry] of others) {
|
|
676
|
+
for (const [theirService, theirs] of Object.entries(entry.ranges)) {
|
|
677
|
+
if (rangesOverlap(range, theirs)) {
|
|
678
|
+
throw new WorkkitError(
|
|
679
|
+
"PORT_RANGE_TAKEN",
|
|
680
|
+
`ports ${range.base}-${range.base + range.count - 1} for "${service}" overlap project "${name}" (${theirService}: ${theirs.base}-${theirs.base + theirs.count - 1}); pick another base in ports.services.${service}`,
|
|
681
|
+
{ project: name, service, theirs }
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
registry.upsertProject(m.project, { root: repoRoot, ranges: mine });
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ../../platform/core/workkit-lib/src/refresh.ts
|
|
691
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
692
|
+
import picomatch2 from "picomatch";
|
|
693
|
+
function matchesWhen(glob) {
|
|
694
|
+
const isMatch = picomatch2(glob, { dot: true });
|
|
695
|
+
return (p) => isMatch(p);
|
|
696
|
+
}
|
|
697
|
+
function selectRefresh(entries, changedPaths) {
|
|
698
|
+
const runs = [];
|
|
699
|
+
const marks = [];
|
|
700
|
+
for (const e of entries) {
|
|
701
|
+
const hit = e.always === true || e.when !== void 0 && changedPaths.some(matchesWhen(e.when));
|
|
702
|
+
if (!hit) continue;
|
|
703
|
+
if (e.run) runs.push(e.run);
|
|
704
|
+
if (e.mark) marks.push(e.mark);
|
|
705
|
+
}
|
|
706
|
+
return { runs, marks };
|
|
707
|
+
}
|
|
708
|
+
function runRefresh(runs, cwd, log, env = process.env) {
|
|
709
|
+
for (const cmd of runs) {
|
|
710
|
+
log(`$ ${cmd}`);
|
|
711
|
+
const r = spawnSync2("sh", ["-c", cmd], { cwd, env, encoding: "utf8" });
|
|
712
|
+
if (r.stdout) log(r.stdout.trimEnd());
|
|
713
|
+
if (r.stderr) log(r.stderr.trimEnd());
|
|
714
|
+
if (r.status !== 0) {
|
|
715
|
+
throw new WorkkitError("INTERNAL", `refresh command failed (${r.status}): ${cmd}`, {
|
|
716
|
+
cmd,
|
|
717
|
+
status: r.status
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// ../../platform/core/workkit-lib/src/registry.ts
|
|
724
|
+
import { randomBytes } from "crypto";
|
|
725
|
+
import {
|
|
726
|
+
existsSync as existsSync4,
|
|
727
|
+
mkdirSync as mkdirSync4,
|
|
728
|
+
readFileSync as readFileSync3,
|
|
729
|
+
renameSync,
|
|
730
|
+
rmSync,
|
|
731
|
+
statSync,
|
|
732
|
+
writeFileSync as writeFileSync3
|
|
733
|
+
} from "fs";
|
|
734
|
+
var newId = (prefix) => `${prefix}_${randomBytes(4).toString("hex")}`;
|
|
735
|
+
var EMPTY = { version: 1, projects: {}, leases: [], efforts: [] };
|
|
736
|
+
var LOCK_STALE_MS = 3e4;
|
|
737
|
+
var LOCK_WAIT_MS = 5e3;
|
|
738
|
+
function sleep(ms) {
|
|
739
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
740
|
+
}
|
|
741
|
+
var Registry = class _Registry {
|
|
742
|
+
constructor(home, now2) {
|
|
743
|
+
this.home = home;
|
|
744
|
+
this.now = now2;
|
|
745
|
+
}
|
|
746
|
+
home;
|
|
747
|
+
now;
|
|
748
|
+
static open(home, now2 = () => /* @__PURE__ */ new Date()) {
|
|
749
|
+
mkdirSync4(home, { recursive: true });
|
|
750
|
+
return new _Registry(home, now2);
|
|
751
|
+
}
|
|
752
|
+
/** The registry as it is on disk. A file that does not parse is refused, never replaced. */
|
|
753
|
+
read() {
|
|
754
|
+
const file = registryFile(this.home);
|
|
755
|
+
if (!existsSync4(file)) return structuredClone(EMPTY);
|
|
756
|
+
let parsed;
|
|
757
|
+
try {
|
|
758
|
+
parsed = JSON.parse(readFileSync3(file, "utf8"));
|
|
759
|
+
} catch (err) {
|
|
760
|
+
throw new WorkkitError(
|
|
761
|
+
"INTERNAL",
|
|
762
|
+
`${file} is corrupt (${err instanceof Error ? err.message : String(err)}); nothing was changed. Move it aside (mv ${file} ${file}.corrupt-$(date +%s)) to start fresh, knowing its leases and running pids are lost`,
|
|
763
|
+
{ file }
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
const r = parsed;
|
|
767
|
+
if (!r || typeof r !== "object" || r.version !== 1) {
|
|
768
|
+
throw new WorkkitError(
|
|
769
|
+
"INTERNAL",
|
|
770
|
+
`${file} has an unknown registry version (${String(r?.version)}); this build reads version 1`,
|
|
771
|
+
{ file }
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
return {
|
|
775
|
+
version: 1,
|
|
776
|
+
projects: r.projects ?? {},
|
|
777
|
+
leases: r.leases ?? [],
|
|
778
|
+
efforts: r.efforts ?? [],
|
|
779
|
+
notices: r.notices ?? []
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
/** Lock, re-read, mutate, write atomically, unlock. The only way state changes. */
|
|
783
|
+
update(fn) {
|
|
784
|
+
const lock = lockDir(this.home);
|
|
785
|
+
const started = Date.now();
|
|
786
|
+
for (; ; ) {
|
|
787
|
+
try {
|
|
788
|
+
mkdirSync4(lock);
|
|
789
|
+
break;
|
|
790
|
+
} catch {
|
|
791
|
+
try {
|
|
792
|
+
if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) {
|
|
793
|
+
rmSync(lock, { recursive: true, force: true });
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
} catch {
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
if (Date.now() - started > LOCK_WAIT_MS) {
|
|
800
|
+
throw new WorkkitError(
|
|
801
|
+
"INTERNAL",
|
|
802
|
+
`registry lock ${lock} held for over ${LOCK_WAIT_MS} ms`
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
sleep(25);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
try {
|
|
809
|
+
const r = this.read();
|
|
810
|
+
const out = fn(r);
|
|
811
|
+
const file = registryFile(this.home);
|
|
812
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
813
|
+
writeFileSync3(tmp, `${JSON.stringify(r, null, 2)}
|
|
814
|
+
`);
|
|
815
|
+
renameSync(tmp, file);
|
|
816
|
+
return out;
|
|
817
|
+
} finally {
|
|
818
|
+
rmSync(lock, { recursive: true, force: true });
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
project(name) {
|
|
822
|
+
return this.read().projects[name];
|
|
823
|
+
}
|
|
824
|
+
upsertProject(name, entry) {
|
|
825
|
+
this.update((r) => {
|
|
826
|
+
r.projects[name] = entry;
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
leases(filter = {}) {
|
|
830
|
+
return this.read().leases.filter(
|
|
831
|
+
(l) => Object.entries(filter).every(([k, v]) => l[k] === v)
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
efforts(filter = {}) {
|
|
835
|
+
return this.read().efforts.filter(
|
|
836
|
+
(e) => Object.entries(filter).every(([k, v]) => e[k] === v)
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
activeEffortFor(project, area) {
|
|
840
|
+
return this.efforts({ project, area, state: "active" })[0];
|
|
841
|
+
}
|
|
842
|
+
/** An effort that is being started counts as open: nobody else may start on the area meanwhile. */
|
|
843
|
+
openEffortFor(project, area) {
|
|
844
|
+
return this.efforts({ project, area }).find(
|
|
845
|
+
(e) => e.state === "starting" || e.state === "active"
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
addNotice(project, area, notice) {
|
|
849
|
+
this.update((r) => {
|
|
850
|
+
r.notices = [...r.notices ?? [], { project, area, notice }];
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
notices(project, area) {
|
|
854
|
+
return (this.read().notices ?? []).filter((n) => n.project === project && n.area === area).map((n) => n.notice);
|
|
855
|
+
}
|
|
856
|
+
clearNotices(project, area) {
|
|
857
|
+
this.update((r) => {
|
|
858
|
+
r.notices = (r.notices ?? []).filter((n) => !(n.project === project && n.area === area));
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
allocateSlot(project, m, owner, effort, exclude = /* @__PURE__ */ new Set()) {
|
|
862
|
+
return this.update((r) => {
|
|
863
|
+
const taken = new Set(
|
|
864
|
+
r.leases.filter((l) => l.project === project && l.kind === "slot").map((l) => l.value)
|
|
865
|
+
);
|
|
866
|
+
const reserved = new Set(m.slots.reserved);
|
|
867
|
+
let slot = -1;
|
|
868
|
+
for (let n = 0; n < m.slots.count; n++) {
|
|
869
|
+
if (!taken.has(n) && !reserved.has(n) && !exclude.has(n)) {
|
|
870
|
+
slot = n;
|
|
871
|
+
break;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
if (slot < 0) {
|
|
875
|
+
throw new WorkkitError(
|
|
876
|
+
"NO_FREE_SLOT",
|
|
877
|
+
`no free slot in ${project} (0..${m.slots.count - 1}, reserved ${[...reserved].join(",")})`
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
const lease = this.lease(
|
|
881
|
+
project,
|
|
882
|
+
"slot",
|
|
883
|
+
slot,
|
|
884
|
+
owner,
|
|
885
|
+
effort,
|
|
886
|
+
m.expiry.slotHours,
|
|
887
|
+
portsForSlot(m, slot)
|
|
888
|
+
);
|
|
889
|
+
r.leases.push(lease);
|
|
890
|
+
return lease;
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
addLease(kind, project, value, owner, effort, hours, extra = {}) {
|
|
894
|
+
return this.update((r) => {
|
|
895
|
+
const l = { ...this.lease(project, kind, value, owner, effort, hours), ...extra };
|
|
896
|
+
r.leases.push(l);
|
|
897
|
+
return l;
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
lease(project, kind, value, owner, effort, hours, ports) {
|
|
901
|
+
const now2 = this.now();
|
|
902
|
+
return {
|
|
903
|
+
id: newId("lease"),
|
|
904
|
+
project,
|
|
905
|
+
kind,
|
|
906
|
+
value,
|
|
907
|
+
...ports ? { ports } : {},
|
|
908
|
+
owner,
|
|
909
|
+
effort,
|
|
910
|
+
created: now2.toISOString(),
|
|
911
|
+
touched: now2.toISOString(),
|
|
912
|
+
expires: new Date(now2.getTime() + hours * 36e5).toISOString()
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
touch(leaseId, hours) {
|
|
916
|
+
this.update((r) => {
|
|
917
|
+
const l = r.leases.find((x) => x.id === leaseId);
|
|
918
|
+
if (!l) throw new WorkkitError("LEASE_NOT_FOUND", leaseId);
|
|
919
|
+
const now2 = this.now();
|
|
920
|
+
l.touched = now2.toISOString();
|
|
921
|
+
l.expires = new Date(now2.getTime() + hours * 36e5).toISOString();
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
patchLease(leaseId, patch) {
|
|
925
|
+
this.update((r) => {
|
|
926
|
+
const i = r.leases.findIndex((x) => x.id === leaseId);
|
|
927
|
+
if (i < 0) throw new WorkkitError("LEASE_NOT_FOUND", leaseId);
|
|
928
|
+
r.leases[i] = { ...r.leases[i], ...patch };
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
release(leaseId) {
|
|
932
|
+
this.update((r) => {
|
|
933
|
+
r.leases = r.leases.filter((l) => l.id !== leaseId);
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
expired(now2 = this.now()) {
|
|
937
|
+
return this.read().leases.filter((l) => Date.parse(l.expires) < now2.getTime());
|
|
938
|
+
}
|
|
939
|
+
addEffort(e) {
|
|
940
|
+
this.update((r) => {
|
|
941
|
+
r.efforts.push(e);
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
updateEffort(id, patch) {
|
|
945
|
+
return this.update((r) => {
|
|
946
|
+
const i = r.efforts.findIndex((e) => e.id === id);
|
|
947
|
+
if (i < 0) throw new WorkkitError("EFFORT_NOT_FOUND", `no effort ${id}`);
|
|
948
|
+
r.efforts[i] = { ...r.efforts[i], ...patch };
|
|
949
|
+
return r.efforts[i];
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
removeEfforts(predicate) {
|
|
953
|
+
return this.update((r) => {
|
|
954
|
+
const before = r.efforts.length;
|
|
955
|
+
r.efforts = r.efforts.filter((e) => !predicate(e));
|
|
956
|
+
return before - r.efforts.length;
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
// ../../platform/core/workkit-lib/src/runner.ts
|
|
962
|
+
import { spawn, spawnSync as spawnSync3 } from "child_process";
|
|
963
|
+
import { closeSync, openSync } from "fs";
|
|
964
|
+
import { join as join5 } from "path";
|
|
965
|
+
var DEFAULT_HEALTH_TIMEOUT_MS = 18e4;
|
|
966
|
+
var HEALTH_POLL_MS = 1e3;
|
|
967
|
+
var PROBE_CAP_MS = 1e4;
|
|
968
|
+
var STOP_COMMAND_TIMEOUT_MS = 15e3;
|
|
969
|
+
var STOP_GRACE_MS = 5e3;
|
|
970
|
+
var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
971
|
+
function isAlive(pid) {
|
|
972
|
+
try {
|
|
973
|
+
process.kill(pid, 0);
|
|
974
|
+
return true;
|
|
975
|
+
} catch {
|
|
976
|
+
return false;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
function pidSignature(pid) {
|
|
980
|
+
const r = spawnSync3("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" });
|
|
981
|
+
return r.status === 0 ? (r.stdout ?? "").trim() : "";
|
|
982
|
+
}
|
|
983
|
+
function probe(spec, cwd, env, timeoutMs = PROBE_CAP_MS) {
|
|
984
|
+
const r = spawnSync3("sh", ["-c", spec.health], {
|
|
985
|
+
cwd,
|
|
986
|
+
env,
|
|
987
|
+
encoding: "utf8",
|
|
988
|
+
timeout: Math.max(100, timeoutMs)
|
|
989
|
+
});
|
|
990
|
+
const out = (r.stdout ?? "").trim();
|
|
991
|
+
const last = out.split(/\s+/).filter(Boolean).pop() ?? "";
|
|
992
|
+
if (r.error) return { healthy: false, last: `probe ${r.error.message}` };
|
|
993
|
+
if (r.status !== 0) return { healthy: false, last: (r.stderr ?? "").trim() || out };
|
|
994
|
+
if (spec.healthyCodes && spec.healthyCodes.length > 0)
|
|
995
|
+
return { healthy: spec.healthyCodes.includes(Number(last)), last };
|
|
996
|
+
return { healthy: true, last };
|
|
997
|
+
}
|
|
998
|
+
async function waitHealthy(o) {
|
|
999
|
+
const budget = o.timeoutMs ?? o.spec.healthTimeoutMs ?? DEFAULT_HEALTH_TIMEOUT_MS;
|
|
1000
|
+
const deadline = Date.now() + budget;
|
|
1001
|
+
let last = "";
|
|
1002
|
+
while (Date.now() < deadline) {
|
|
1003
|
+
const p = probe(o.spec, o.cwd, o.env, Math.min(PROBE_CAP_MS, deadline - Date.now()));
|
|
1004
|
+
if (p.healthy) return;
|
|
1005
|
+
last = p.last;
|
|
1006
|
+
if (Date.now() >= deadline) break;
|
|
1007
|
+
await sleep2(Math.min(HEALTH_POLL_MS, Math.max(0, deadline - Date.now())));
|
|
1008
|
+
}
|
|
1009
|
+
throw new WorkkitError(
|
|
1010
|
+
"HEALTH_TIMEOUT",
|
|
1011
|
+
`service "${o.name}" was not healthy within ${(budget / 1e3).toFixed(0)} s (last probe: ${last || "no output"})`,
|
|
1012
|
+
{ service: o.name, last }
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
function spawnService(name, spec, a) {
|
|
1016
|
+
const fd = openSync(join5(a.logDir, `${name}.log`), "a");
|
|
1017
|
+
try {
|
|
1018
|
+
const child = spawn("sh", ["-c", spec.start], {
|
|
1019
|
+
cwd: a.cwd,
|
|
1020
|
+
env: a.env,
|
|
1021
|
+
detached: true,
|
|
1022
|
+
stdio: ["ignore", fd, fd]
|
|
1023
|
+
});
|
|
1024
|
+
child.unref();
|
|
1025
|
+
if (child.pid === void 0) {
|
|
1026
|
+
throw new WorkkitError("SERVICE_FAILED", `service "${name}" did not spawn: ${spec.start}`, {
|
|
1027
|
+
service: name
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
return child.pid;
|
|
1031
|
+
} finally {
|
|
1032
|
+
closeSync(fd);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
async function startServices(a) {
|
|
1036
|
+
const pids = {};
|
|
1037
|
+
const signatures = {};
|
|
1038
|
+
for (const [name, spec] of Object.entries(a.m.services)) {
|
|
1039
|
+
a.onLog?.(`starting ${name}: ${spec.start}`);
|
|
1040
|
+
pids[name] = spawnService(name, spec, a);
|
|
1041
|
+
signatures[name] = pidSignature(pids[name]);
|
|
1042
|
+
a.onSpawn?.(name, pids[name], signatures[name]);
|
|
1043
|
+
try {
|
|
1044
|
+
await waitHealthy({ name, spec, cwd: a.cwd, env: a.env });
|
|
1045
|
+
} catch (err) {
|
|
1046
|
+
await stopServices({ m: a.m, cwd: a.cwd, env: a.env, pids, signatures, onLog: a.onLog });
|
|
1047
|
+
throw err;
|
|
1048
|
+
}
|
|
1049
|
+
a.onLog?.(`${name} healthy (pid ${pids[name]})`);
|
|
1050
|
+
}
|
|
1051
|
+
return { pids, signatures };
|
|
1052
|
+
}
|
|
1053
|
+
function killGroup(pid, signal) {
|
|
1054
|
+
try {
|
|
1055
|
+
process.kill(-pid, signal);
|
|
1056
|
+
} catch {
|
|
1057
|
+
try {
|
|
1058
|
+
process.kill(pid, signal);
|
|
1059
|
+
} catch {
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
async function stopServices(o) {
|
|
1064
|
+
const names = Object.keys(o.m.services).filter((n) => o.pids[n] !== void 0).reverse();
|
|
1065
|
+
if (!o.skipStopCommands) {
|
|
1066
|
+
for (const name of names) {
|
|
1067
|
+
const spec = o.m.services[name];
|
|
1068
|
+
o.onLog?.(`stopping ${name}: ${spec.stop}`);
|
|
1069
|
+
const r = spawnSync3("sh", ["-c", spec.stop], {
|
|
1070
|
+
cwd: o.cwd,
|
|
1071
|
+
env: o.env,
|
|
1072
|
+
encoding: "utf8",
|
|
1073
|
+
timeout: STOP_COMMAND_TIMEOUT_MS
|
|
1074
|
+
});
|
|
1075
|
+
if (r.error) o.onLog?.(`stop command for ${name} ${r.error.message}`);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
const ours = names.filter((n) => {
|
|
1079
|
+
const pid = o.pids[n];
|
|
1080
|
+
if (!isAlive(pid)) return false;
|
|
1081
|
+
const expected = o.signatures?.[n];
|
|
1082
|
+
if (expected === void 0) return true;
|
|
1083
|
+
const actual = pidSignature(pid);
|
|
1084
|
+
if (actual !== expected) {
|
|
1085
|
+
o.onLog?.(
|
|
1086
|
+
`not signalling pid ${pid} (${n}): it is no longer the process started for this lease`
|
|
1087
|
+
);
|
|
1088
|
+
return false;
|
|
1089
|
+
}
|
|
1090
|
+
return true;
|
|
1091
|
+
});
|
|
1092
|
+
const alive = () => ours.filter((n) => isAlive(o.pids[n]));
|
|
1093
|
+
if (alive().length === 0) return;
|
|
1094
|
+
for (const n of alive()) killGroup(o.pids[n], "SIGTERM");
|
|
1095
|
+
const deadline = Date.now() + STOP_GRACE_MS;
|
|
1096
|
+
while (alive().length > 0 && Date.now() < deadline) await sleep2(200);
|
|
1097
|
+
for (const n of alive()) killGroup(o.pids[n], "SIGKILL");
|
|
1098
|
+
const hardDeadline = Date.now() + STOP_GRACE_MS;
|
|
1099
|
+
while (alive().length > 0 && Date.now() < hardDeadline) await sleep2(100);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// ../../platform/core/workkit-lib/src/session.ts
|
|
1103
|
+
import { hostname, userInfo } from "os";
|
|
1104
|
+
function currentOwner(env = process.env) {
|
|
1105
|
+
const named = [env.WORK_SESSION, env.CLAUDE_SESSION_NAME, env.CLAUDE_CODE_SESSION_ID].find(
|
|
1106
|
+
(v) => v !== void 0 && v.length > 0
|
|
1107
|
+
);
|
|
1108
|
+
return { session: named ?? `${userInfo().username}@${hostname()}`, pid: process.pid };
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// src/lib/context.ts
|
|
1112
|
+
import chalk from "chalk";
|
|
1113
|
+
|
|
1114
|
+
// src/lib/storybrokr.ts
|
|
1115
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
1116
|
+
function run(args) {
|
|
1117
|
+
const r = spawnSync4("storybrokr", args, { encoding: "utf8" });
|
|
1118
|
+
if (r.error) {
|
|
1119
|
+
throw new WorkkitError(
|
|
1120
|
+
"STORYBROKR_FAILED",
|
|
1121
|
+
`storybrokr is not installed (npm i -g @helmsmith/storybrokr): ${r.error.message}`
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
return { code: r.status ?? 1, out: r.stdout ?? "", err: r.stderr ?? "" };
|
|
1125
|
+
}
|
|
1126
|
+
var storybrokrCli = {
|
|
1127
|
+
async up(story, hostRoot) {
|
|
1128
|
+
const r = run(["up", story, "--host", hostRoot, "--json"]);
|
|
1129
|
+
if (r.code !== 0) {
|
|
1130
|
+
throw new WorkkitError(
|
|
1131
|
+
"STORYBROKR_FAILED",
|
|
1132
|
+
`storybrokr up ${story} failed: ${r.err.trim() || r.out.trim()}`
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
const record = JSON.parse(r.out);
|
|
1136
|
+
return { id: record.id, port: record.port, url: record.url };
|
|
1137
|
+
},
|
|
1138
|
+
async down(id) {
|
|
1139
|
+
const r = run(["down", id]);
|
|
1140
|
+
if (r.code !== 0)
|
|
1141
|
+
throw new WorkkitError("STORYBROKR_FAILED", `storybrokr down ${id} failed: ${r.err.trim()}`);
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
|
|
1145
|
+
// src/lib/context.ts
|
|
1146
|
+
function resolveRepoRoot(cwd, override) {
|
|
1147
|
+
if (override) return override;
|
|
1148
|
+
return new Git(cwd).mainRoot();
|
|
1149
|
+
}
|
|
1150
|
+
async function buildCtx(opts = {}) {
|
|
1151
|
+
const repoRoot = resolveRepoRoot(opts.cwd ?? process.cwd(), opts.projectRoot);
|
|
1152
|
+
const manifest = await loadManifest(repoRoot);
|
|
1153
|
+
const home = homeDir();
|
|
1154
|
+
const registry = Registry.open(home);
|
|
1155
|
+
if (!registry.project(manifest.project)) claimRanges(registry, manifest, repoRoot);
|
|
1156
|
+
return {
|
|
1157
|
+
home,
|
|
1158
|
+
repoRoot,
|
|
1159
|
+
manifest,
|
|
1160
|
+
registry,
|
|
1161
|
+
owner: currentOwner(),
|
|
1162
|
+
runner: { start: startServices, stop: stopServices },
|
|
1163
|
+
storybrokr: storybrokrCli,
|
|
1164
|
+
log: opts.quiet ? () => {
|
|
1165
|
+
} : (line) => console.error(chalk.gray(line))
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// src/lib/effort.ts
|
|
1170
|
+
import {
|
|
1171
|
+
appendFileSync as appendFileSync2,
|
|
1172
|
+
cpSync,
|
|
1173
|
+
existsSync as existsSync5,
|
|
1174
|
+
mkdirSync as mkdirSync5,
|
|
1175
|
+
readdirSync,
|
|
1176
|
+
realpathSync as realpathSync4,
|
|
1177
|
+
rmSync as rmSync2,
|
|
1178
|
+
statSync as statSync2
|
|
1179
|
+
} from "fs";
|
|
1180
|
+
import { connect, createServer } from "net";
|
|
1181
|
+
import { dirname as dirname2, join as join6 } from "path";
|
|
1182
|
+
var worktreePathFor = (ctx, area) => join6(ctx.repoRoot, ctx.manifest.worktreeRoot, area);
|
|
1183
|
+
var branchFor = (type, area) => `${type}/${area}`;
|
|
1184
|
+
var now = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
1185
|
+
var STARTING_GRACE_MS = 30 * 6e4;
|
|
1186
|
+
var realpathOr = (p) => {
|
|
1187
|
+
try {
|
|
1188
|
+
return realpathSync4(p);
|
|
1189
|
+
} catch {
|
|
1190
|
+
return p;
|
|
1191
|
+
}
|
|
1192
|
+
};
|
|
1193
|
+
function hoursFor(m, kind) {
|
|
1194
|
+
return {
|
|
1195
|
+
slot: m.expiry.slotHours,
|
|
1196
|
+
storybook: m.expiry.storybookHours,
|
|
1197
|
+
commons: m.expiry.commonsHours
|
|
1198
|
+
}[kind];
|
|
1199
|
+
}
|
|
1200
|
+
function touchEffort(ctx, effort) {
|
|
1201
|
+
for (const l of ctx.registry.leases({ effort: effort.id }))
|
|
1202
|
+
ctx.registry.touch(l.id, hoursFor(ctx.manifest, l.kind));
|
|
1203
|
+
ctx.registry.updateEffort(effort.id, { lastActivity: now() });
|
|
1204
|
+
}
|
|
1205
|
+
function portBusy(port) {
|
|
1206
|
+
return new Promise((resolve3) => {
|
|
1207
|
+
const srv = createServer();
|
|
1208
|
+
srv.once("error", () => resolve3(true));
|
|
1209
|
+
srv.listen(port, "127.0.0.1", () => srv.close(() => resolve3(false)));
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
async function busyPorts(ports) {
|
|
1213
|
+
const out = [];
|
|
1214
|
+
for (const [service, port] of Object.entries(ports))
|
|
1215
|
+
if (await portBusy(port)) out.push(`${service}=${port}`);
|
|
1216
|
+
return out;
|
|
1217
|
+
}
|
|
1218
|
+
function portAnswers(port) {
|
|
1219
|
+
return new Promise((resolve3) => {
|
|
1220
|
+
const sock = connect({ port, host: "127.0.0.1" });
|
|
1221
|
+
const done = (v) => {
|
|
1222
|
+
sock.destroy();
|
|
1223
|
+
resolve3(v);
|
|
1224
|
+
};
|
|
1225
|
+
sock.setTimeout(300, () => done(false));
|
|
1226
|
+
sock.once("connect", () => done(true));
|
|
1227
|
+
sock.once("error", () => done(false));
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
async function anyPortAnswers(ports) {
|
|
1231
|
+
for (const port of Object.values(ports ?? {})) if (await portAnswers(port)) return true;
|
|
1232
|
+
return false;
|
|
1233
|
+
}
|
|
1234
|
+
function guardStart(ctx, o) {
|
|
1235
|
+
const m = ctx.manifest;
|
|
1236
|
+
const spec = m.areas[o.area];
|
|
1237
|
+
if (!spec)
|
|
1238
|
+
throw new WorkkitError(
|
|
1239
|
+
"AREA_UNKNOWN",
|
|
1240
|
+
`unknown area "${o.area}" (known: ${Object.keys(m.areas).join(", ")})`,
|
|
1241
|
+
{ area: o.area }
|
|
1242
|
+
);
|
|
1243
|
+
if (!m.branch.types.includes(o.type))
|
|
1244
|
+
throw new WorkkitError(
|
|
1245
|
+
"TYPE_UNKNOWN",
|
|
1246
|
+
`unknown type "${o.type}" (allowed: ${m.branch.types.join(", ")})`,
|
|
1247
|
+
{ type: o.type }
|
|
1248
|
+
);
|
|
1249
|
+
if (o.mode !== "frontend" && o.mode !== "fullstack")
|
|
1250
|
+
throw new WorkkitError("MODE_UNKNOWN", `unknown mode "${o.mode}" (frontend | fullstack)`);
|
|
1251
|
+
const branch = branchFor(o.type, o.area);
|
|
1252
|
+
assertNotProtected(m, branch);
|
|
1253
|
+
const scopes = expandScopes(m, o.area, o.mode);
|
|
1254
|
+
const commons = spec.commons ?? [];
|
|
1255
|
+
const warnings = [];
|
|
1256
|
+
if (commons.length > 0) {
|
|
1257
|
+
for (const e of ctx.registry.efforts({ project: m.project })) {
|
|
1258
|
+
if (e.area === o.area || e.state !== "active" && e.state !== "starting" || !existsSync5(e.worktree))
|
|
1259
|
+
continue;
|
|
1260
|
+
const changed = new Git(e.worktree).tryRun(["diff", "--name-only", "--no-renames", `origin/${m.baseBranch}...HEAD`]).out.split("\n").filter(Boolean);
|
|
1261
|
+
const hit = commonsTouched(m, changed).filter((c) => commons.includes(c));
|
|
1262
|
+
if (hit.length > 0)
|
|
1263
|
+
warnings.push(
|
|
1264
|
+
`${e.area} (${e.branch}, ${e.owner.session}) already has commits under commons ${hit.join(", ")}: coordinate or wait for its merge`
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
return { branch, scopes, commons, warnings };
|
|
1269
|
+
}
|
|
1270
|
+
function reserveEffort(ctx, o, branch, scopes, commons) {
|
|
1271
|
+
const m = ctx.manifest;
|
|
1272
|
+
const effort = {
|
|
1273
|
+
id: newId("eff"),
|
|
1274
|
+
project: m.project,
|
|
1275
|
+
area: o.area,
|
|
1276
|
+
type: o.type,
|
|
1277
|
+
mode: o.mode,
|
|
1278
|
+
branch,
|
|
1279
|
+
worktree: worktreePathFor(ctx, o.area),
|
|
1280
|
+
owner: ctx.owner,
|
|
1281
|
+
state: "starting",
|
|
1282
|
+
scopes,
|
|
1283
|
+
commons,
|
|
1284
|
+
leases: [],
|
|
1285
|
+
notices: [],
|
|
1286
|
+
started: now(),
|
|
1287
|
+
lastActivity: now()
|
|
1288
|
+
};
|
|
1289
|
+
ctx.registry.update((r) => {
|
|
1290
|
+
const open = r.efforts.filter(
|
|
1291
|
+
(e) => e.project === m.project && (e.state === "active" || e.state === "starting")
|
|
1292
|
+
);
|
|
1293
|
+
const onArea = open.find((e) => e.area === o.area);
|
|
1294
|
+
if (onArea) {
|
|
1295
|
+
throw new WorkkitError(
|
|
1296
|
+
"EFFORT_OPEN",
|
|
1297
|
+
`an effort is open on ${o.area} (${onArea.branch}, ${onArea.mode}) owned by ${onArea.owner.session} since ${onArea.started}; finish it, or pass --join to work in the same checkout`,
|
|
1298
|
+
{ owner: onArea.owner.session, effort: onArea.id, branch: onArea.branch }
|
|
1299
|
+
);
|
|
1300
|
+
}
|
|
1301
|
+
for (const e of open) {
|
|
1302
|
+
const pairs = globsOverlap(scopes, e.scopes);
|
|
1303
|
+
if (pairs.length > 0) {
|
|
1304
|
+
throw new WorkkitError(
|
|
1305
|
+
"SCOPE_CONFLICT",
|
|
1306
|
+
`scope overlaps the open effort on ${e.area} (${e.branch}, owner ${e.owner.session}): ${pairs.join("; ")}`,
|
|
1307
|
+
{ with: e.area, owner: e.owner.session, effort: e.id, pairs }
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
r.efforts.push(effort);
|
|
1312
|
+
});
|
|
1313
|
+
return effort;
|
|
1314
|
+
}
|
|
1315
|
+
function ensureWorktree(ctx, area, branch) {
|
|
1316
|
+
const m = ctx.manifest;
|
|
1317
|
+
const path = worktreePathFor(ctx, area);
|
|
1318
|
+
const main = new Git(ctx.repoRoot);
|
|
1319
|
+
main.fetch(m.baseBranch);
|
|
1320
|
+
if (!existsSync5(path)) {
|
|
1321
|
+
main.worktreeAdd(path, branch, `origin/${m.baseBranch}`);
|
|
1322
|
+
return new Git(path);
|
|
1323
|
+
}
|
|
1324
|
+
const registered = main.run(["worktree", "list", "--porcelain"]).split("\n").filter((l) => l.startsWith("worktree ")).map((l) => realpathOr(l.slice(9)));
|
|
1325
|
+
const real = realpathOr(path);
|
|
1326
|
+
const wg = new Git(path);
|
|
1327
|
+
const topLevel = wg.tryRun(["rev-parse", "--show-toplevel"]);
|
|
1328
|
+
if (!registered.includes(real) || topLevel.code !== 0 || realpathOr(topLevel.out.trim()) !== real || wg.isMainCheckout()) {
|
|
1329
|
+
throw new WorkkitError(
|
|
1330
|
+
"WORKTREE_INVALID",
|
|
1331
|
+
`${path} exists but is not this repository's worktree for ${area} (a leftover directory?); move it away, or \`git worktree prune\` and retry`,
|
|
1332
|
+
{ worktree: path }
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
const current = wg.currentBranch();
|
|
1336
|
+
if (current !== branch) {
|
|
1337
|
+
if (current.endsWith(`/${area}`) && wg.commitsNotOn(m.baseBranch).length > 0) {
|
|
1338
|
+
throw new WorkkitError(
|
|
1339
|
+
"EFFORT_OPEN",
|
|
1340
|
+
`${current} holds ${wg.commitsNotOn(m.baseBranch).length} commit(s) not yet on ${m.baseBranch}: ship or finish it first, or --join it`,
|
|
1341
|
+
{ branch: current, area }
|
|
1342
|
+
);
|
|
1343
|
+
}
|
|
1344
|
+
if (wg.isDirty())
|
|
1345
|
+
throw new WorkkitError(
|
|
1346
|
+
"DIRTY_WORKTREE",
|
|
1347
|
+
`${path} has uncommitted changes on ${current}; commit or park them before switching to ${branch}`,
|
|
1348
|
+
{ worktree: path }
|
|
1349
|
+
);
|
|
1350
|
+
wg.checkout(branch, `origin/${m.baseBranch}`);
|
|
1351
|
+
}
|
|
1352
|
+
return wg;
|
|
1353
|
+
}
|
|
1354
|
+
function appendLog(file, line) {
|
|
1355
|
+
appendFileSync2(file, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
|
|
1356
|
+
`);
|
|
1357
|
+
}
|
|
1358
|
+
async function startEffort(ctx, o) {
|
|
1359
|
+
const m = ctx.manifest;
|
|
1360
|
+
const { branch, scopes, commons, warnings } = guardStart(ctx, o);
|
|
1361
|
+
const notices = ctx.registry.notices(m.project, o.area);
|
|
1362
|
+
const open = ctx.registry.openEffortFor(m.project, o.area);
|
|
1363
|
+
if (open && o.join) {
|
|
1364
|
+
touchEffort(ctx, open);
|
|
1365
|
+
return {
|
|
1366
|
+
effort: open,
|
|
1367
|
+
leases: ctx.registry.leases({ effort: open.id }),
|
|
1368
|
+
sync: "skipped",
|
|
1369
|
+
refreshed: [],
|
|
1370
|
+
marks: [],
|
|
1371
|
+
joined: true,
|
|
1372
|
+
warnings,
|
|
1373
|
+
notices
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
const effort = reserveEffort(ctx, o, branch, scopes, commons);
|
|
1377
|
+
const leases = [];
|
|
1378
|
+
const abandon = async (stopPids) => {
|
|
1379
|
+
for (const l of ctx.registry.leases({ effort: effort.id })) {
|
|
1380
|
+
if (stopPids && l.kind === "slot" && l.pids && Object.keys(l.pids).length > 0) {
|
|
1381
|
+
await ctx.runner.stop({
|
|
1382
|
+
m,
|
|
1383
|
+
cwd: effort.worktree,
|
|
1384
|
+
env: process.env,
|
|
1385
|
+
pids: l.pids,
|
|
1386
|
+
signatures: l.signatures,
|
|
1387
|
+
onLog: ctx.log
|
|
1388
|
+
}).catch(() => void 0);
|
|
1389
|
+
}
|
|
1390
|
+
ctx.registry.release(l.id);
|
|
1391
|
+
}
|
|
1392
|
+
ctx.registry.removeEfforts((e) => e.id === effort.id);
|
|
1393
|
+
};
|
|
1394
|
+
const onSignal = () => {
|
|
1395
|
+
abandon(true).finally(() => process.exit(130));
|
|
1396
|
+
};
|
|
1397
|
+
process.once("SIGINT", onSignal);
|
|
1398
|
+
process.once("SIGTERM", onSignal);
|
|
1399
|
+
try {
|
|
1400
|
+
const wg = ensureWorktree(ctx, o.area, branch);
|
|
1401
|
+
const worktree = wg.root();
|
|
1402
|
+
ctx.registry.updateEffort(effort.id, { worktree });
|
|
1403
|
+
effort.worktree = worktree;
|
|
1404
|
+
const logDir = join6(worktree, m.logs.dir);
|
|
1405
|
+
mkdirSync5(logDir, { recursive: true });
|
|
1406
|
+
const refreshLog = join6(logDir, "refresh.log");
|
|
1407
|
+
let sync = "skipped";
|
|
1408
|
+
let changed = [];
|
|
1409
|
+
if (o.sync !== false) {
|
|
1410
|
+
wg.fetch(m.baseBranch);
|
|
1411
|
+
const r = wg.syncFrom(m.baseBranch);
|
|
1412
|
+
if (r.kind === "conflict") {
|
|
1413
|
+
throw new WorkkitError(
|
|
1414
|
+
"SYNC_CONFLICT",
|
|
1415
|
+
`merging origin/${m.baseBranch} into ${branch} conflicts in ${r.conflicted.join(", ")}; resolve in ${worktree}, commit, then rerun with --no-sync`,
|
|
1416
|
+
{ conflicted: r.conflicted, worktree }
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
sync = r.kind;
|
|
1420
|
+
if (r.kind === "ff" || r.kind === "merge") changed = wg.changedPaths(r.before, r.after);
|
|
1421
|
+
}
|
|
1422
|
+
const sel = selectRefresh(m.refresh, changed);
|
|
1423
|
+
runRefresh(sel.runs, worktree, (l) => {
|
|
1424
|
+
appendLog(refreshLog, l);
|
|
1425
|
+
ctx.log(l);
|
|
1426
|
+
});
|
|
1427
|
+
for (const name of commons)
|
|
1428
|
+
leases.push(
|
|
1429
|
+
ctx.registry.addLease(
|
|
1430
|
+
"commons",
|
|
1431
|
+
m.project,
|
|
1432
|
+
name,
|
|
1433
|
+
ctx.owner,
|
|
1434
|
+
effort.id,
|
|
1435
|
+
m.expiry.commonsHours
|
|
1436
|
+
)
|
|
1437
|
+
);
|
|
1438
|
+
if (o.mode === "fullstack") {
|
|
1439
|
+
const exclude = /* @__PURE__ */ new Set();
|
|
1440
|
+
let slot;
|
|
1441
|
+
const busyNotes = [];
|
|
1442
|
+
for (; ; ) {
|
|
1443
|
+
const candidate = ctx.registry.allocateSlot(m.project, m, ctx.owner, effort.id, exclude);
|
|
1444
|
+
const busy = await busyPorts(candidate.ports ?? {});
|
|
1445
|
+
if (busy.length === 0) {
|
|
1446
|
+
slot = candidate;
|
|
1447
|
+
break;
|
|
1448
|
+
}
|
|
1449
|
+
ctx.registry.release(candidate.id);
|
|
1450
|
+
exclude.add(candidate.value);
|
|
1451
|
+
busyNotes.push(`slot ${candidate.value}: ${busy.join(", ")} already in use`);
|
|
1452
|
+
ctx.log(busyNotes[busyNotes.length - 1]);
|
|
1453
|
+
}
|
|
1454
|
+
if (!slot) throw new WorkkitError("NO_FREE_SLOT", busyNotes.join("; "));
|
|
1455
|
+
leases.push(slot);
|
|
1456
|
+
if (busyNotes.length) warnings.push(...busyNotes);
|
|
1457
|
+
const cfg = emitWorktreeConfig({
|
|
1458
|
+
m,
|
|
1459
|
+
worktree,
|
|
1460
|
+
slot: slot.value,
|
|
1461
|
+
ports: slot.ports ?? {},
|
|
1462
|
+
area: o.area,
|
|
1463
|
+
branch
|
|
1464
|
+
});
|
|
1465
|
+
const env = { ...process.env, ...cfg.vars };
|
|
1466
|
+
if (sel.marks.includes("db-reset") && m.services.postgres?.reset) {
|
|
1467
|
+
runRefresh([m.services.postgres.reset], worktree, (l) => appendLog(refreshLog, l), env);
|
|
1468
|
+
}
|
|
1469
|
+
const pids = {};
|
|
1470
|
+
const signatures = {};
|
|
1471
|
+
const result = await ctx.runner.start({
|
|
1472
|
+
m,
|
|
1473
|
+
cwd: worktree,
|
|
1474
|
+
env,
|
|
1475
|
+
logDir,
|
|
1476
|
+
onLog: ctx.log,
|
|
1477
|
+
onSpawn: (service, pid, signature) => {
|
|
1478
|
+
pids[service] = pid;
|
|
1479
|
+
signatures[service] = signature;
|
|
1480
|
+
ctx.registry.patchLease(slot.id, { pids: { ...pids }, signatures: { ...signatures } });
|
|
1481
|
+
}
|
|
1482
|
+
});
|
|
1483
|
+
ctx.registry.patchLease(slot.id, {
|
|
1484
|
+
pids: result.pids,
|
|
1485
|
+
signatures: result.signatures ?? signatures
|
|
1486
|
+
});
|
|
1487
|
+
} else {
|
|
1488
|
+
let storybookPort;
|
|
1489
|
+
if (o.story) {
|
|
1490
|
+
const hostRoot = m.storybook ? join6(worktree, m.storybook.hostRoot) : worktree;
|
|
1491
|
+
const inst = await ctx.storybrokr.up(o.story, hostRoot);
|
|
1492
|
+
storybookPort = inst.port;
|
|
1493
|
+
leases.push(
|
|
1494
|
+
ctx.registry.addLease(
|
|
1495
|
+
"storybook",
|
|
1496
|
+
m.project,
|
|
1497
|
+
inst.id,
|
|
1498
|
+
ctx.owner,
|
|
1499
|
+
effort.id,
|
|
1500
|
+
m.expiry.storybookHours,
|
|
1501
|
+
{ ports: { storybook: inst.port } }
|
|
1502
|
+
)
|
|
1503
|
+
);
|
|
1504
|
+
}
|
|
1505
|
+
emitWorktreeConfig({ m, worktree, ports: {}, storybookPort, area: o.area, branch });
|
|
1506
|
+
}
|
|
1507
|
+
const active = ctx.registry.updateEffort(effort.id, {
|
|
1508
|
+
state: "active",
|
|
1509
|
+
leases: leases.map((l) => l.id),
|
|
1510
|
+
lastActivity: now()
|
|
1511
|
+
});
|
|
1512
|
+
return {
|
|
1513
|
+
effort: active,
|
|
1514
|
+
leases: ctx.registry.leases({ effort: effort.id }),
|
|
1515
|
+
sync,
|
|
1516
|
+
refreshed: sel.runs,
|
|
1517
|
+
marks: sel.marks,
|
|
1518
|
+
joined: false,
|
|
1519
|
+
warnings,
|
|
1520
|
+
notices
|
|
1521
|
+
};
|
|
1522
|
+
} catch (err) {
|
|
1523
|
+
await abandon(true);
|
|
1524
|
+
throw err;
|
|
1525
|
+
} finally {
|
|
1526
|
+
process.off("SIGINT", onSignal);
|
|
1527
|
+
process.off("SIGTERM", onSignal);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
function behindBase(ctx, worktree) {
|
|
1531
|
+
if (!existsSync5(worktree)) return 0;
|
|
1532
|
+
const g = new Git(worktree);
|
|
1533
|
+
g.tryRun(["fetch", "-q", "origin", ctx.manifest.baseBranch]);
|
|
1534
|
+
const r = g.tryRun(["rev-list", "--count", `HEAD..origin/${ctx.manifest.baseBranch}`]);
|
|
1535
|
+
return r.code === 0 ? Number(r.out.trim()) || 0 : 0;
|
|
1536
|
+
}
|
|
1537
|
+
function statusFor(ctx, area) {
|
|
1538
|
+
const m = ctx.manifest;
|
|
1539
|
+
const names = (area ? [area] : Object.keys(m.areas)).sort();
|
|
1540
|
+
const areas = names.map((name) => {
|
|
1541
|
+
const effort = ctx.registry.openEffortFor(m.project, name);
|
|
1542
|
+
if (effort && area) touchEffort(ctx, effort);
|
|
1543
|
+
const worktree = worktreePathFor(ctx, name);
|
|
1544
|
+
const out = {
|
|
1545
|
+
area: name,
|
|
1546
|
+
leases: effort ? ctx.registry.leases({ effort: effort.id }) : [],
|
|
1547
|
+
behindBase: behindBase(ctx, worktree),
|
|
1548
|
+
notices: ctx.registry.notices(m.project, name)
|
|
1549
|
+
};
|
|
1550
|
+
if (effort) out.effort = effort;
|
|
1551
|
+
if (existsSync5(worktree)) out.worktree = worktree;
|
|
1552
|
+
return out;
|
|
1553
|
+
});
|
|
1554
|
+
return { project: m.project, areas, leases: ctx.registry.leases({ project: m.project }) };
|
|
1555
|
+
}
|
|
1556
|
+
function slotEnv(m, slot) {
|
|
1557
|
+
return slotVars(m, { slot, ports: portsForSlot(m, slot) });
|
|
1558
|
+
}
|
|
1559
|
+
function effortEnv(ctx, area) {
|
|
1560
|
+
const m = ctx.manifest;
|
|
1561
|
+
const effort = ctx.registry.openEffortFor(m.project, area);
|
|
1562
|
+
if (!effort)
|
|
1563
|
+
throw new WorkkitError(
|
|
1564
|
+
"EFFORT_NOT_FOUND",
|
|
1565
|
+
`no open effort on ${area}; \`work start --area ${area} \u2026\` first`,
|
|
1566
|
+
{ area }
|
|
1567
|
+
);
|
|
1568
|
+
touchEffort(ctx, effort);
|
|
1569
|
+
const slot = ctx.registry.leases({ effort: effort.id, kind: "slot" })[0];
|
|
1570
|
+
const sb = ctx.registry.leases({ effort: effort.id, kind: "storybook" })[0];
|
|
1571
|
+
const vars = slotVars(m, {
|
|
1572
|
+
slot: slot ? slot.value : void 0,
|
|
1573
|
+
ports: slot?.ports ?? {},
|
|
1574
|
+
storybookPort: sb?.ports?.storybook,
|
|
1575
|
+
area,
|
|
1576
|
+
branch: effort.branch,
|
|
1577
|
+
worktree: effort.worktree,
|
|
1578
|
+
logDir: join6(effort.worktree, m.logs.dir)
|
|
1579
|
+
});
|
|
1580
|
+
return { ...vars, MODE: effort.mode };
|
|
1581
|
+
}
|
|
1582
|
+
function leaseEnv(ctx, effort, lease) {
|
|
1583
|
+
const vars = slotVars(ctx.manifest, {
|
|
1584
|
+
slot: lease.kind === "slot" ? lease.value : void 0,
|
|
1585
|
+
ports: lease.ports ?? {},
|
|
1586
|
+
area: effort.area,
|
|
1587
|
+
branch: effort.branch,
|
|
1588
|
+
worktree: effort.worktree,
|
|
1589
|
+
logDir: join6(effort.worktree, ctx.manifest.logs.dir)
|
|
1590
|
+
});
|
|
1591
|
+
return { ...process.env, ...vars };
|
|
1592
|
+
}
|
|
1593
|
+
async function releaseLease(ctx, effort, lease) {
|
|
1594
|
+
try {
|
|
1595
|
+
if (lease.kind === "slot" && lease.pids && Object.keys(lease.pids).length > 0) {
|
|
1596
|
+
const hasWorktree = effort !== void 0 && existsSync5(effort.worktree);
|
|
1597
|
+
await ctx.runner.stop({
|
|
1598
|
+
m: ctx.manifest,
|
|
1599
|
+
cwd: hasWorktree && effort ? effort.worktree : ctx.repoRoot,
|
|
1600
|
+
env: effort ? leaseEnv(ctx, effort, lease) : process.env,
|
|
1601
|
+
pids: lease.pids,
|
|
1602
|
+
signatures: lease.signatures,
|
|
1603
|
+
skipStopCommands: !hasWorktree,
|
|
1604
|
+
onLog: ctx.log
|
|
1605
|
+
});
|
|
1606
|
+
} else if (lease.kind === "storybook") {
|
|
1607
|
+
await ctx.storybrokr.down(String(lease.value));
|
|
1608
|
+
}
|
|
1609
|
+
} catch (err) {
|
|
1610
|
+
ctx.log(
|
|
1611
|
+
`while releasing ${lease.kind} ${lease.value}: ${err instanceof Error ? err.message : String(err)}`
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1614
|
+
ctx.registry.release(lease.id);
|
|
1615
|
+
}
|
|
1616
|
+
function leaveNotices(ctx, effort, commons) {
|
|
1617
|
+
if (commons.length === 0) return 0;
|
|
1618
|
+
const notice = {
|
|
1619
|
+
from: effort.branch,
|
|
1620
|
+
text: `commons ${commons.join(", ")} changed on ${effort.branch}: sync and re-verify`,
|
|
1621
|
+
at: now()
|
|
1622
|
+
};
|
|
1623
|
+
let count = 0;
|
|
1624
|
+
for (const area of Object.keys(ctx.manifest.areas)) {
|
|
1625
|
+
if (area === effort.area) continue;
|
|
1626
|
+
ctx.registry.addNotice(ctx.manifest.project, area, notice);
|
|
1627
|
+
count++;
|
|
1628
|
+
}
|
|
1629
|
+
return count;
|
|
1630
|
+
}
|
|
1631
|
+
async function finishEffort(ctx, o) {
|
|
1632
|
+
const m = ctx.manifest;
|
|
1633
|
+
const effort = ctx.registry.openEffortFor(m.project, o.area) ?? ctx.registry.efforts({ project: m.project, area: o.area, state: "parked" }).sort((a, b) => b.started.localeCompare(a.started))[0];
|
|
1634
|
+
if (!effort)
|
|
1635
|
+
throw new WorkkitError("EFFORT_NOT_FOUND", `no open or parked effort on ${o.area}`, {
|
|
1636
|
+
area: o.area
|
|
1637
|
+
});
|
|
1638
|
+
const g = new Git(effort.worktree);
|
|
1639
|
+
let parked = false;
|
|
1640
|
+
if (g.isDirty()) {
|
|
1641
|
+
if (!o.park) {
|
|
1642
|
+
throw new WorkkitError(
|
|
1643
|
+
"DIRTY_WORKTREE",
|
|
1644
|
+
`${effort.worktree} has uncommitted changes: commit them, or \`work finish --park\` to commit them as wip(${effort.area}) and push`,
|
|
1645
|
+
{ worktree: effort.worktree }
|
|
1646
|
+
);
|
|
1647
|
+
}
|
|
1648
|
+
g.run(["add", "-A"]);
|
|
1649
|
+
g.run(["commit", "-q", "-m", `wip(${effort.area}): parked ${now()}`]);
|
|
1650
|
+
parked = true;
|
|
1651
|
+
}
|
|
1652
|
+
for (const lease of ctx.registry.leases({ effort: effort.id }))
|
|
1653
|
+
await releaseLease(ctx, effort, lease);
|
|
1654
|
+
g.push(effort.branch);
|
|
1655
|
+
const notices = leaveNotices(ctx, effort, commonsTouched(m, g.changedSince(m.baseBranch)));
|
|
1656
|
+
const finished = ctx.registry.updateEffort(effort.id, { state: "finished", lastActivity: now() });
|
|
1657
|
+
return { effort: finished, pushed: true, parked, notices };
|
|
1658
|
+
}
|
|
1659
|
+
async function reapLeases(ctx, o = {}) {
|
|
1660
|
+
const m = ctx.manifest;
|
|
1661
|
+
const at = o.now ?? /* @__PURE__ */ new Date();
|
|
1662
|
+
const efforts = new Map(ctx.registry.efforts({ project: m.project }).map((e) => [e.id, e]));
|
|
1663
|
+
const leases = ctx.registry.leases({ project: m.project });
|
|
1664
|
+
const doomed = /* @__PURE__ */ new Set();
|
|
1665
|
+
for (const l of leases) {
|
|
1666
|
+
const effort = efforts.get(l.effort);
|
|
1667
|
+
if (effort?.state === "starting" && Date.now() - Date.parse(effort.started) < STARTING_GRACE_MS)
|
|
1668
|
+
continue;
|
|
1669
|
+
const expired = Date.parse(l.expires) < at.getTime();
|
|
1670
|
+
const orphaned = !effort || !existsSync5(effort.worktree);
|
|
1671
|
+
const garbage = orphaned && !await anyPortAnswers(l.ports);
|
|
1672
|
+
if (expired || garbage) doomed.add(l.effort);
|
|
1673
|
+
}
|
|
1674
|
+
const reaped = leases.filter((l) => doomed.has(l.effort));
|
|
1675
|
+
if (o.dryRun) return { reaped };
|
|
1676
|
+
for (const lease of reaped) await releaseLease(ctx, efforts.get(lease.effort), lease);
|
|
1677
|
+
for (const id of doomed) {
|
|
1678
|
+
const effort = efforts.get(id);
|
|
1679
|
+
if (effort && (effort.state === "active" || effort.state === "starting")) {
|
|
1680
|
+
ctx.registry.updateEffort(effort.id, {
|
|
1681
|
+
state: "parked",
|
|
1682
|
+
parkedReason: `idle: a lease expired by ${at.toISOString()}`,
|
|
1683
|
+
lastActivity: now()
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
return { reaped };
|
|
1688
|
+
}
|
|
1689
|
+
var ARCHIVE_DIR = ".slot-archive";
|
|
1690
|
+
function dirSize(path) {
|
|
1691
|
+
if (!existsSync5(path)) return 0;
|
|
1692
|
+
let total = 0;
|
|
1693
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
1694
|
+
const p = join6(path, entry.name);
|
|
1695
|
+
total += entry.isDirectory() ? dirSize(p) : statSync2(p).size;
|
|
1696
|
+
}
|
|
1697
|
+
return total;
|
|
1698
|
+
}
|
|
1699
|
+
function archiveWorktree(ctx, area, worktree) {
|
|
1700
|
+
const m = ctx.manifest;
|
|
1701
|
+
const areaRoot = join6(ctx.repoRoot, ARCHIVE_DIR, area);
|
|
1702
|
+
const stamp = now().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
|
|
1703
|
+
const target = join6(areaRoot, stamp);
|
|
1704
|
+
const archived = [];
|
|
1705
|
+
for (const rel of m.archive.paths) {
|
|
1706
|
+
const src = join6(worktree, rel);
|
|
1707
|
+
if (!existsSync5(src)) continue;
|
|
1708
|
+
const dst = join6(target, rel);
|
|
1709
|
+
mkdirSync5(dirname2(dst), { recursive: true });
|
|
1710
|
+
cpSync(src, dst, { recursive: true });
|
|
1711
|
+
archived.push(dst);
|
|
1712
|
+
}
|
|
1713
|
+
if (archived.length > 0) ensureExcluded(ctx.repoRoot, [`${ARCHIVE_DIR}/`]);
|
|
1714
|
+
if (existsSync5(areaRoot)) {
|
|
1715
|
+
const stamps = readdirSync(areaRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort().reverse();
|
|
1716
|
+
const cutoff = Date.now() - m.archive.keepDays * 864e5;
|
|
1717
|
+
stamps.forEach((d, i) => {
|
|
1718
|
+
const t = statSync2(join6(areaRoot, d)).mtimeMs;
|
|
1719
|
+
if (i >= m.archive.keepPerArea || t < cutoff)
|
|
1720
|
+
rmSync2(join6(areaRoot, d), { recursive: true, force: true });
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
return archived;
|
|
1724
|
+
}
|
|
1725
|
+
async function retireArea(ctx, o) {
|
|
1726
|
+
const m = ctx.manifest;
|
|
1727
|
+
if (!m.areas[o.area])
|
|
1728
|
+
throw new WorkkitError("AREA_UNKNOWN", `unknown area "${o.area}"`, { area: o.area });
|
|
1729
|
+
const open = ctx.registry.openEffortFor(m.project, o.area);
|
|
1730
|
+
if (open)
|
|
1731
|
+
throw new WorkkitError(
|
|
1732
|
+
"EFFORT_OPEN",
|
|
1733
|
+
`an effort is open on ${o.area} (${open.owner.session}); \`work finish\` it first`,
|
|
1734
|
+
{ owner: open.owner.session }
|
|
1735
|
+
);
|
|
1736
|
+
const worktree = worktreePathFor(ctx, o.area);
|
|
1737
|
+
const main = new Git(ctx.repoRoot);
|
|
1738
|
+
let archived = [];
|
|
1739
|
+
if (existsSync5(worktree)) {
|
|
1740
|
+
const g = new Git(worktree);
|
|
1741
|
+
if (g.isDirty())
|
|
1742
|
+
throw new WorkkitError(
|
|
1743
|
+
"UNPUSHED",
|
|
1744
|
+
`${worktree} has uncommitted changes; commit and push them (or \`work finish --park\`) before retiring`,
|
|
1745
|
+
{ worktree }
|
|
1746
|
+
);
|
|
1747
|
+
const unpushed = g.unpushedCount(m.baseBranch);
|
|
1748
|
+
if (unpushed > 0)
|
|
1749
|
+
throw new WorkkitError(
|
|
1750
|
+
"UNPUSHED",
|
|
1751
|
+
`${g.currentBranch()} has ${unpushed} commit(s) not on origin; push before retiring`,
|
|
1752
|
+
{ worktree, unpushed }
|
|
1753
|
+
);
|
|
1754
|
+
archived = archiveWorktree(ctx, o.area, worktree);
|
|
1755
|
+
main.worktreeRemove(worktree);
|
|
1756
|
+
}
|
|
1757
|
+
const branchesDeleted = [];
|
|
1758
|
+
const branchesKept = [];
|
|
1759
|
+
for (const type of m.branch.types) {
|
|
1760
|
+
const branch = `${type}/${o.area}`;
|
|
1761
|
+
if (!main.branchExists(branch)) continue;
|
|
1762
|
+
const onOrigin = main.remoteBranchExists(branch) && main.tryRun(["rev-list", "--count", `origin/${branch}..${branch}`]).out.trim() === "0";
|
|
1763
|
+
if (onOrigin) {
|
|
1764
|
+
main.deleteLocalBranch(branch);
|
|
1765
|
+
branchesDeleted.push(branch);
|
|
1766
|
+
} else branchesKept.push(branch);
|
|
1767
|
+
}
|
|
1768
|
+
ctx.registry.removeEfforts((e) => e.project === m.project && e.area === o.area);
|
|
1769
|
+
ctx.registry.clearNotices(m.project, o.area);
|
|
1770
|
+
return { archived, removed: worktree, branchesDeleted, branchesKept };
|
|
1771
|
+
}
|
|
1772
|
+
var BEHIND_LIMIT = 20;
|
|
1773
|
+
var NOTICE_STALE_MS = 7 * 864e5;
|
|
1774
|
+
var ARCHIVE_LIMIT_BYTES = 5 * 1024 ** 3;
|
|
1775
|
+
function registeredWorktrees(ctx) {
|
|
1776
|
+
const out = [];
|
|
1777
|
+
const porcelain = new Git(ctx.repoRoot).run(["worktree", "list", "--porcelain"]);
|
|
1778
|
+
let current = null;
|
|
1779
|
+
for (const line of porcelain.split("\n")) {
|
|
1780
|
+
if (line.startsWith("worktree ")) current = { path: line.slice(9), branch: "" };
|
|
1781
|
+
else if (line.startsWith("branch ") && current)
|
|
1782
|
+
current.branch = line.slice(7).replace(/^refs\/heads\//, "");
|
|
1783
|
+
else if (line === "" && current) {
|
|
1784
|
+
out.push(current);
|
|
1785
|
+
current = null;
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
if (current) out.push(current);
|
|
1789
|
+
const wtRoot = join6(realpathOr(ctx.repoRoot), ctx.manifest.worktreeRoot);
|
|
1790
|
+
return out.filter((w) => existsSync5(w.path) && realpathOr(w.path).startsWith(wtRoot));
|
|
1791
|
+
}
|
|
1792
|
+
async function auditProject(ctx) {
|
|
1793
|
+
const m = ctx.manifest;
|
|
1794
|
+
const findings = [];
|
|
1795
|
+
const types = m.branch.types.join("|");
|
|
1796
|
+
const areaOf = (branch) => {
|
|
1797
|
+
const match = new RegExp(`^(${types})/(.+)$`).exec(branch);
|
|
1798
|
+
return match && m.areas[match[2]] ? match[2] : null;
|
|
1799
|
+
};
|
|
1800
|
+
for (const w of registeredWorktrees(ctx)) {
|
|
1801
|
+
const area = areaOf(w.branch);
|
|
1802
|
+
if (!area) {
|
|
1803
|
+
findings.push({
|
|
1804
|
+
kind: "foreign-branch",
|
|
1805
|
+
detail: `${w.path} is on "${w.branch || "detached HEAD"}", not a <type>/<area> branch of a declared area`
|
|
1806
|
+
});
|
|
1807
|
+
continue;
|
|
1808
|
+
}
|
|
1809
|
+
const g = new Git(w.path);
|
|
1810
|
+
if (g.isDirty())
|
|
1811
|
+
findings.push({
|
|
1812
|
+
kind: "dirty",
|
|
1813
|
+
area,
|
|
1814
|
+
detail: `${w.path} has uncommitted changes on ${w.branch}`
|
|
1815
|
+
});
|
|
1816
|
+
const unpushed = g.unpushedCount(m.baseBranch);
|
|
1817
|
+
if (unpushed > 0)
|
|
1818
|
+
findings.push({
|
|
1819
|
+
kind: "unpushed",
|
|
1820
|
+
area,
|
|
1821
|
+
detail: `${w.branch} has ${unpushed} commit(s) not on origin`
|
|
1822
|
+
});
|
|
1823
|
+
const behind = behindBase(ctx, w.path);
|
|
1824
|
+
if (behind > BEHIND_LIMIT)
|
|
1825
|
+
findings.push({
|
|
1826
|
+
kind: "behind-base",
|
|
1827
|
+
area,
|
|
1828
|
+
detail: `${w.branch} is ${behind} commits behind ${m.baseBranch}`
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1831
|
+
const nowMs = Date.now();
|
|
1832
|
+
for (const l of ctx.registry.leases({ project: m.project })) {
|
|
1833
|
+
const half = (Date.parse(l.touched) + Date.parse(l.expires)) / 2;
|
|
1834
|
+
if (nowMs > half)
|
|
1835
|
+
findings.push({
|
|
1836
|
+
kind: "lease-aging",
|
|
1837
|
+
detail: `${l.kind} ${l.value} held by ${l.owner.session}, last used ${l.touched}, expires ${l.expires}`
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
for (const area of Object.keys(m.areas)) {
|
|
1841
|
+
for (const n of ctx.registry.notices(m.project, area)) {
|
|
1842
|
+
if (nowMs - Date.parse(n.at) > NOTICE_STALE_MS)
|
|
1843
|
+
findings.push({ kind: "notice-stale", area, detail: `${n.text} (since ${n.at})` });
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
const bytes = dirSize(join6(ctx.repoRoot, ARCHIVE_DIR));
|
|
1847
|
+
if (bytes > ARCHIVE_LIMIT_BYTES)
|
|
1848
|
+
findings.push({
|
|
1849
|
+
kind: "archive-size",
|
|
1850
|
+
detail: `${ARCHIVE_DIR} holds ${(bytes / 1024 ** 3).toFixed(1)} GB`
|
|
1851
|
+
});
|
|
1852
|
+
return findings;
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
// src/commands/_shared/output.ts
|
|
1856
|
+
import chalk2 from "chalk";
|
|
1857
|
+
function printJson(value) {
|
|
1858
|
+
console.log(JSON.stringify(value, null, 2));
|
|
1859
|
+
}
|
|
1860
|
+
function fail(err, json) {
|
|
1861
|
+
const body = toErrorBody(err);
|
|
1862
|
+
if (json) console.error(JSON.stringify(body));
|
|
1863
|
+
else {
|
|
1864
|
+
console.error(chalk2.red(`${body.code}: ${body.message}`));
|
|
1865
|
+
if (body.details) console.error(chalk2.gray(` ${JSON.stringify(body.details)}`));
|
|
1866
|
+
}
|
|
1867
|
+
process.exit(exitCodeFor(body.code));
|
|
1868
|
+
}
|
|
1869
|
+
function warn(line) {
|
|
1870
|
+
console.error(chalk2.yellow(`! ${line}`));
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
// src/commands/audit.ts
|
|
1874
|
+
function registerAudit(program2) {
|
|
1875
|
+
program2.command("audit").description(
|
|
1876
|
+
"List what would otherwise be forgotten: unsaved work, stray checkouts, aging leases, drift, stale notices"
|
|
1877
|
+
).option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the findings as JSON").action(async (o) => {
|
|
1878
|
+
try {
|
|
1879
|
+
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: true });
|
|
1880
|
+
const findings = await auditProject(ctx);
|
|
1881
|
+
if (o.json) printJson(findings);
|
|
1882
|
+
else if (findings.length === 0) console.log(chalk3.green("nothing forgotten"));
|
|
1883
|
+
else
|
|
1884
|
+
for (const f of findings)
|
|
1885
|
+
console.log(
|
|
1886
|
+
`${chalk3.yellow(f.kind.padEnd(14))} ${f.area ? `${f.area} ` : ""}${f.detail}`
|
|
1887
|
+
);
|
|
1888
|
+
} catch (err) {
|
|
1889
|
+
fail(err, Boolean(o.json));
|
|
1890
|
+
}
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
// src/commands/env.ts
|
|
1895
|
+
function registerEnv(program2) {
|
|
1896
|
+
program2.command("env").description("The slot-derived variables of an area's open effort, or of a slot number alone").option("--area <area>", "the area whose open effort to read").option(
|
|
1897
|
+
"--slot <n>",
|
|
1898
|
+
"compute the variables for this slot from the manifest alone",
|
|
1899
|
+
(v) => Number(v)
|
|
1900
|
+
).option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print as JSON (default: KEY=value lines)").option("--sh", "print KEY=value lines (the default)").action(async (o) => {
|
|
1901
|
+
try {
|
|
1902
|
+
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: true });
|
|
1903
|
+
let vars;
|
|
1904
|
+
if (o.area) vars = effortEnv(ctx, o.area);
|
|
1905
|
+
else if (o.slot !== void 0 && Number.isInteger(o.slot))
|
|
1906
|
+
vars = slotEnv(ctx.manifest, o.slot);
|
|
1907
|
+
else throw new WorkkitError("EFFORT_NOT_FOUND", "pass --area <area> or --slot <n>");
|
|
1908
|
+
if (o.json) printJson(vars);
|
|
1909
|
+
else for (const [k, v] of Object.entries(vars)) console.log(`${k}=${v}`);
|
|
1910
|
+
} catch (err) {
|
|
1911
|
+
fail(err, Boolean(o.json));
|
|
1912
|
+
}
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
// src/commands/finish.ts
|
|
1917
|
+
import chalk4 from "chalk";
|
|
1918
|
+
function registerFinish(program2) {
|
|
1919
|
+
program2.command("finish").description(
|
|
1920
|
+
"Stop the effort's runner, release its leases, push its branch; the worktree and branch stay"
|
|
1921
|
+
).requiredOption("--area <area>", "the area whose open effort to finish").option("--park", "commit uncommitted changes as wip(<area>) instead of refusing").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(async (o) => {
|
|
1922
|
+
try {
|
|
1923
|
+
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
|
|
1924
|
+
const r = await finishEffort(ctx, { area: o.area, park: o.park });
|
|
1925
|
+
if (o.json) printJson(r);
|
|
1926
|
+
else {
|
|
1927
|
+
console.log(
|
|
1928
|
+
`${chalk4.bold(r.effort.area)} finished: ${r.effort.branch} pushed${r.parked ? " (uncommitted changes parked as wip)" : ""}`
|
|
1929
|
+
);
|
|
1930
|
+
if (r.notices) console.log(` ${r.notices} area(s) told about commons changes`);
|
|
1931
|
+
}
|
|
1932
|
+
} catch (err) {
|
|
1933
|
+
fail(err, Boolean(o.json));
|
|
1934
|
+
}
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
// src/commands/hook.ts
|
|
1939
|
+
import { readFileSync as readFileSync4, realpathSync as realpathSync5 } from "fs";
|
|
1940
|
+
import { resolve } from "path";
|
|
1941
|
+
import chalk5 from "chalk";
|
|
1942
|
+
var ZEROS = /^0+$/;
|
|
1943
|
+
async function hookContext(cwd) {
|
|
1944
|
+
const git = new Git(cwd);
|
|
1945
|
+
const repoRoot = git.mainRoot();
|
|
1946
|
+
try {
|
|
1947
|
+
const manifest = await loadManifest(repoRoot);
|
|
1948
|
+
return { git, repoRoot, manifest, registry: Registry.open(homeDir()) };
|
|
1949
|
+
} catch (err) {
|
|
1950
|
+
if (err instanceof WorkkitError && err.code === "MANIFEST_NOT_FOUND") return null;
|
|
1951
|
+
throw err;
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
async function runPreCommit(cwd) {
|
|
1955
|
+
const c = await hookContext(cwd);
|
|
1956
|
+
if (!c) return 0;
|
|
1957
|
+
const root = c.git.root();
|
|
1958
|
+
touchByWorktree(c.registry, c.manifest, root);
|
|
1959
|
+
const { refusals } = checkCommit({
|
|
1960
|
+
m: c.manifest,
|
|
1961
|
+
registry: c.registry,
|
|
1962
|
+
git: c.git,
|
|
1963
|
+
project: c.manifest.project,
|
|
1964
|
+
branch: c.git.currentBranch(),
|
|
1965
|
+
stagedPaths: c.git.changedPathsStaged(),
|
|
1966
|
+
ownerSession: currentOwner().session,
|
|
1967
|
+
checkoutRoot: root
|
|
1968
|
+
});
|
|
1969
|
+
for (const r of refusals) console.error(chalk5.red(`work: ${r}`));
|
|
1970
|
+
return refusals.length ? 2 : 0;
|
|
1971
|
+
}
|
|
1972
|
+
async function runPrePush(cwd, stdin) {
|
|
1973
|
+
const c = await hookContext(cwd);
|
|
1974
|
+
if (!c) return 0;
|
|
1975
|
+
touchByWorktree(c.registry, c.manifest, c.git.root());
|
|
1976
|
+
let code = 0;
|
|
1977
|
+
for (const line of stdin.split("\n").filter(Boolean)) {
|
|
1978
|
+
const [localRef, localSha, remoteRef, remoteSha] = line.split(/\s+/);
|
|
1979
|
+
if (!localRef || !localSha || !remoteRef) continue;
|
|
1980
|
+
const isDelete = ZEROS.test(localSha);
|
|
1981
|
+
const branch = isDelete ? "" : localRef.replace(/^refs\/heads\//, "");
|
|
1982
|
+
let changed = [];
|
|
1983
|
+
if (!isDelete) {
|
|
1984
|
+
const from = remoteSha && !ZEROS.test(remoteSha) ? remoteSha : `origin/${c.manifest.baseBranch}`;
|
|
1985
|
+
const range = remoteSha && !ZEROS.test(remoteSha) ? `${from}..${localSha}` : `${from}...${localSha}`;
|
|
1986
|
+
changed = c.git.tryRun(["diff", "--name-only", "--no-renames", range]).out.split("\n").filter(Boolean);
|
|
1987
|
+
}
|
|
1988
|
+
const { refusals, warnings } = checkPush({
|
|
1989
|
+
m: c.manifest,
|
|
1990
|
+
registry: c.registry,
|
|
1991
|
+
git: c.git,
|
|
1992
|
+
project: c.manifest.project,
|
|
1993
|
+
branch,
|
|
1994
|
+
remoteRef,
|
|
1995
|
+
isDelete,
|
|
1996
|
+
changedPaths: changed
|
|
1997
|
+
});
|
|
1998
|
+
for (const w of warnings) console.error(chalk5.yellow(`work: ${w}`));
|
|
1999
|
+
for (const r of refusals) console.error(chalk5.red(`work: ${r}`));
|
|
2000
|
+
if (refusals.length) code = 2;
|
|
2001
|
+
}
|
|
2002
|
+
return code;
|
|
2003
|
+
}
|
|
2004
|
+
function touchByWorktree(registry, m, root) {
|
|
2005
|
+
const same2 = (p) => {
|
|
2006
|
+
try {
|
|
2007
|
+
return realpathSync5(p) === realpathSync5(root);
|
|
2008
|
+
} catch {
|
|
2009
|
+
return false;
|
|
2010
|
+
}
|
|
2011
|
+
};
|
|
2012
|
+
const hours = {
|
|
2013
|
+
slot: m.expiry.slotHours,
|
|
2014
|
+
storybook: m.expiry.storybookHours,
|
|
2015
|
+
commons: m.expiry.commonsHours
|
|
2016
|
+
};
|
|
2017
|
+
for (const e of registry.efforts({ project: m.project })) {
|
|
2018
|
+
if (e.state !== "active" && e.state !== "starting" || !same2(e.worktree)) continue;
|
|
2019
|
+
for (const l of registry.leases({ effort: e.id })) registry.touch(l.id, hours[l.kind]);
|
|
2020
|
+
registry.updateEffort(e.id, { lastActivity: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
function registerHook(program2) {
|
|
2024
|
+
const hook = program2.command("hook").description("Entry points the installed git hooks call");
|
|
2025
|
+
hook.command("pre-commit").description("Refuse commits on protected branches and writes under write-locked commons").action(async () => {
|
|
2026
|
+
try {
|
|
2027
|
+
process.exit(await runPreCommit(process.cwd()));
|
|
2028
|
+
} catch (err) {
|
|
2029
|
+
fail(err, false);
|
|
2030
|
+
}
|
|
2031
|
+
});
|
|
2032
|
+
hook.command("pre-push").description(
|
|
2033
|
+
"Refuse pushes to protected branches; warn when paths leave the effort scope or touch commons"
|
|
2034
|
+
).argument("[remote]", "passed by git, unused").argument("[url]", "passed by git, unused").action(async () => {
|
|
2035
|
+
try {
|
|
2036
|
+
const stdin = process.stdin.isTTY ? "" : readFileSync4(0, "utf8");
|
|
2037
|
+
process.exit(await runPrePush(process.cwd(), stdin));
|
|
2038
|
+
} catch (err) {
|
|
2039
|
+
fail(err, false);
|
|
2040
|
+
}
|
|
2041
|
+
});
|
|
2042
|
+
const hooks = program2.command("hooks").description("Manage the git hooks in a clone");
|
|
2043
|
+
hooks.command("install").description(
|
|
2044
|
+
"Write .helmsmith/hooks and point this clone's core.hooksPath at them (for a repo whose manifest already exists)"
|
|
2045
|
+
).option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(async (o) => {
|
|
2046
|
+
try {
|
|
2047
|
+
const repoRoot = o.projectRoot ? resolve(o.projectRoot) : new Git(process.cwd()).mainRoot();
|
|
2048
|
+
const manifest = await loadManifest(repoRoot);
|
|
2049
|
+
const out = installHooks(repoRoot, { protectedBranches: manifest.protectedBranches });
|
|
2050
|
+
if (o.json) printJson(out);
|
|
2051
|
+
else for (const f of out.files) console.log(`installed ${f}`);
|
|
2052
|
+
} catch (err) {
|
|
2053
|
+
fail(err, Boolean(o.json));
|
|
2054
|
+
}
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
// src/commands/init.ts
|
|
2059
|
+
import { existsSync as existsSync6, writeFileSync as writeFileSync4 } from "fs";
|
|
2060
|
+
import { join as join7, resolve as resolve2 } from "path";
|
|
2061
|
+
var STORYBOOK_BASE = 6100;
|
|
2062
|
+
function defaultService(name) {
|
|
2063
|
+
const PORT = `\${PORT_${name.toUpperCase()}}`;
|
|
2064
|
+
if (name === "postgres" || name === "db") {
|
|
2065
|
+
return {
|
|
2066
|
+
start: `docker compose up -d ${name}`,
|
|
2067
|
+
stop: `docker compose stop ${name}`,
|
|
2068
|
+
health: `pg_isready -h 127.0.0.1 -p ${PORT} -q`,
|
|
2069
|
+
healthTimeoutMs: 12e4
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
return {
|
|
2073
|
+
start: `make -C ${name} dev`,
|
|
2074
|
+
stop: `make -C ${name} dev-stop`,
|
|
2075
|
+
health: `curl -sf -o /dev/null http://127.0.0.1:${PORT}/health`,
|
|
2076
|
+
healthTimeoutMs: 3e5
|
|
2077
|
+
};
|
|
2078
|
+
}
|
|
2079
|
+
function buildStarterManifest(o) {
|
|
2080
|
+
const ports = {};
|
|
2081
|
+
const services = {};
|
|
2082
|
+
for (const spec of o.services) {
|
|
2083
|
+
const m = /^([a-z][a-z0-9_-]*)=(\d{4,5})$/.exec(spec);
|
|
2084
|
+
if (!m)
|
|
2085
|
+
throw new WorkkitError("MANIFEST_INVALID", `--service expects name=port, got "${spec}"`);
|
|
2086
|
+
ports[m[1]] = { base: Number(m[2]) };
|
|
2087
|
+
services[m[1]] = defaultService(m[1]);
|
|
2088
|
+
}
|
|
2089
|
+
if (o.storybookHost) ports.storybook = { base: STORYBOOK_BASE };
|
|
2090
|
+
const manifest = {
|
|
2091
|
+
project: o.project,
|
|
2092
|
+
baseBranch: o.baseBranch,
|
|
2093
|
+
protectedBranches: [o.baseBranch],
|
|
2094
|
+
worktreeRoot: o.worktreeRoot ?? ".claude/worktrees",
|
|
2095
|
+
branch: { types: ["feature", "chore", "fix"] },
|
|
2096
|
+
slots: { count: 10, reserved: [0] },
|
|
2097
|
+
ports: { services: ports },
|
|
2098
|
+
services,
|
|
2099
|
+
...o.storybookHost ? { storybook: { hostRoot: o.storybookHost } } : {},
|
|
2100
|
+
logs: { dir: ".logs" },
|
|
2101
|
+
refresh: [],
|
|
2102
|
+
scopes: {
|
|
2103
|
+
frontend: [
|
|
2104
|
+
"app/{group}/{section}/**",
|
|
2105
|
+
"components/{group}/{section}/**",
|
|
2106
|
+
"app/api/{section}/**"
|
|
2107
|
+
],
|
|
2108
|
+
backend: ["services/{module}/**"]
|
|
2109
|
+
},
|
|
2110
|
+
areas: {},
|
|
2111
|
+
commons: {},
|
|
2112
|
+
expiry: { slotHours: 12, commonsHours: 4, storybookHours: 2 },
|
|
2113
|
+
archive: { paths: [], keepDays: 30, keepPerArea: 5 }
|
|
2114
|
+
};
|
|
2115
|
+
validateManifest(manifest);
|
|
2116
|
+
return manifest;
|
|
2117
|
+
}
|
|
2118
|
+
var HEADER = `// biome-ignore-all lint/suspicious/noTemplateCurlyInString: \${PORT_*} are the manifest's own placeholders
|
|
2119
|
+
// helmsmith.config.mjs \u2014 read by \`work\` (@helmsmith/workbrokr). Written by \`work init\`.
|
|
2120
|
+
// Edit: the services' start/stop/health commands, the scope templates, the areas (leaves only,
|
|
2121
|
+
// each with its backend module) and the commons. Nothing here is read by the application.
|
|
2122
|
+
`;
|
|
2123
|
+
function runInit(o) {
|
|
2124
|
+
const repoRoot = resolve2(o.repoRoot);
|
|
2125
|
+
for (const name of MANIFEST_FILES) {
|
|
2126
|
+
if (existsSync6(join7(repoRoot, name)) && !o.force) {
|
|
2127
|
+
throw new WorkkitError(
|
|
2128
|
+
"MANIFEST_INVALID",
|
|
2129
|
+
`${name} already exists in ${repoRoot}; pass --force to overwrite`
|
|
2130
|
+
);
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
const manifest = buildStarterManifest(o);
|
|
2134
|
+
const manifestPath = join7(repoRoot, "helmsmith.config.mjs");
|
|
2135
|
+
writeFileSync4(manifestPath, `${HEADER}export default ${JSON.stringify(manifest, null, 2)};
|
|
2136
|
+
`);
|
|
2137
|
+
claimRanges(Registry.open(o.home), validateManifest(manifest), repoRoot);
|
|
2138
|
+
const hooks = o.hooks ? installHooks(repoRoot, { protectedBranches: [o.baseBranch] }).files : [];
|
|
2139
|
+
return { manifestPath, hooks };
|
|
2140
|
+
}
|
|
2141
|
+
function defaultRepoRoot() {
|
|
2142
|
+
try {
|
|
2143
|
+
return new Git(process.cwd()).mainRoot();
|
|
2144
|
+
} catch {
|
|
2145
|
+
return process.cwd();
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
function defaultBaseBranch(repoRoot) {
|
|
2149
|
+
const g = new Git(repoRoot);
|
|
2150
|
+
const head = g.tryRun(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
|
|
2151
|
+
if (head.code === 0) return head.out.trim().replace(/^origin\//, "");
|
|
2152
|
+
return "main";
|
|
2153
|
+
}
|
|
2154
|
+
function registerInit(program2) {
|
|
2155
|
+
program2.command("init").description(
|
|
2156
|
+
"Write helmsmith.config.mjs for this repo, claim its port ranges, install the git hooks"
|
|
2157
|
+
).requiredOption("--project <name>", "project name (lowercase, digits, dashes)").option("--base-branch <branch>", "base branch (default: origin's HEAD, else main)").option(
|
|
2158
|
+
"--service <name=port>",
|
|
2159
|
+
"a service and its slot-0 port; repeat per service",
|
|
2160
|
+
(v, acc) => [...acc, v],
|
|
2161
|
+
[]
|
|
2162
|
+
).option("--storybook-host <dir>", "the package whose .storybook storybrokr boots from").option("--worktree-root <dir>", "where area worktrees live (default .claude/worktrees)").option("--repo-root <dir>", "repository root (default: the main checkout of the cwd)").option("--no-hooks", "do not install the git hooks").option("--force", "overwrite an existing manifest").option("--json", "print the result as JSON").action(
|
|
2163
|
+
(o) => {
|
|
2164
|
+
try {
|
|
2165
|
+
const repoRoot = o.repoRoot ? resolve2(o.repoRoot) : defaultRepoRoot();
|
|
2166
|
+
if (o.service.length === 0)
|
|
2167
|
+
throw new WorkkitError(
|
|
2168
|
+
"MANIFEST_INVALID",
|
|
2169
|
+
"at least one --service name=port is required"
|
|
2170
|
+
);
|
|
2171
|
+
const out = runInit({
|
|
2172
|
+
repoRoot,
|
|
2173
|
+
home: homeDir(),
|
|
2174
|
+
project: o.project,
|
|
2175
|
+
baseBranch: o.baseBranch ?? defaultBaseBranch(repoRoot),
|
|
2176
|
+
services: o.service,
|
|
2177
|
+
storybookHost: o.storybookHost,
|
|
2178
|
+
worktreeRoot: o.worktreeRoot,
|
|
2179
|
+
hooks: o.hooks,
|
|
2180
|
+
force: o.force === true
|
|
2181
|
+
});
|
|
2182
|
+
if (o.json) printJson(out);
|
|
2183
|
+
else {
|
|
2184
|
+
console.log(`wrote ${out.manifestPath}`);
|
|
2185
|
+
for (const h of out.hooks) console.log(`installed ${h}`);
|
|
2186
|
+
console.log(
|
|
2187
|
+
"next: edit the services and areas, commit helmsmith.config.mjs and .helmsmith/hooks, then `work start`"
|
|
2188
|
+
);
|
|
2189
|
+
}
|
|
2190
|
+
} catch (err) {
|
|
2191
|
+
fail(err, Boolean(o.json));
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
);
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
// src/mcp/server.ts
|
|
2198
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2199
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2200
|
+
import { z as z2 } from "zod";
|
|
2201
|
+
|
|
2202
|
+
// src/version.ts
|
|
2203
|
+
import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
|
|
2204
|
+
import { dirname as dirname3, join as join8 } from "path";
|
|
2205
|
+
import { fileURLToPath } from "url";
|
|
2206
|
+
function findPackageJson(startDir) {
|
|
2207
|
+
let dir = startDir;
|
|
2208
|
+
for (; ; ) {
|
|
2209
|
+
const candidate = join8(dir, "package.json");
|
|
2210
|
+
if (existsSync7(candidate)) return candidate;
|
|
2211
|
+
const parent = dirname3(dir);
|
|
2212
|
+
if (parent === dir) throw new Error(`package.json not found above ${startDir}`);
|
|
2213
|
+
dir = parent;
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
var pkgFile = findPackageJson(dirname3(fileURLToPath(import.meta.url)));
|
|
2217
|
+
var VERSION = JSON.parse(readFileSync5(pkgFile, "utf8")).version;
|
|
2218
|
+
|
|
2219
|
+
// src/mcp/server.ts
|
|
2220
|
+
var ok = (value) => ({
|
|
2221
|
+
content: [{ type: "text", text: JSON.stringify(value, null, 2) }]
|
|
2222
|
+
});
|
|
2223
|
+
var failed = (err) => ({
|
|
2224
|
+
content: [{ type: "text", text: JSON.stringify(toErrorBody(err)) }],
|
|
2225
|
+
isError: true
|
|
2226
|
+
});
|
|
2227
|
+
async function guard(fn) {
|
|
2228
|
+
try {
|
|
2229
|
+
return ok(await fn());
|
|
2230
|
+
} catch (err) {
|
|
2231
|
+
return failed(err);
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
function buildMcpServer(ctxFactory) {
|
|
2235
|
+
const server = new McpServer({ name: "work", version: VERSION });
|
|
2236
|
+
server.registerTool(
|
|
2237
|
+
"work_start",
|
|
2238
|
+
{
|
|
2239
|
+
description: "Start an effort on an area: its worktree on <type>/<area>, synced from the base branch, refreshed, leased (a slot for fullstack, a storybrokr instance for frontend with a story) and running. Refuses with EFFORT_OPEN (another effort is open; join=true to share it), SCOPE_CONFLICT, SYNC_CONFLICT (resolve in the worktree, rerun with sync=false).",
|
|
2240
|
+
inputSchema: {
|
|
2241
|
+
area: z2.string().describe("a leaf area from helmsmith.config (e.g. org/applications)"),
|
|
2242
|
+
type: z2.string().describe("feature | chore | fix"),
|
|
2243
|
+
mode: z2.enum(["frontend", "fullstack"]),
|
|
2244
|
+
story: z2.string().optional().describe(
|
|
2245
|
+
"frontend: story file or component folder for storybrokr, relative to its host"
|
|
2246
|
+
),
|
|
2247
|
+
join: z2.boolean().optional().describe("work in the already-open effort instead of refusing"),
|
|
2248
|
+
sync: z2.boolean().optional().describe("fetch and merge the base branch first (default true)")
|
|
2249
|
+
}
|
|
2250
|
+
},
|
|
2251
|
+
async ({ area, type, mode, story, join: join9, sync }) => guard(async () => startEffort(await ctxFactory(), { area, type, mode, story, join: join9, sync }))
|
|
2252
|
+
);
|
|
2253
|
+
server.registerTool(
|
|
2254
|
+
"work_status",
|
|
2255
|
+
{
|
|
2256
|
+
description: "Per area: the open effort, its leases, how far behind the base branch it is, pending notices; plus every lease of the project.",
|
|
2257
|
+
inputSchema: { area: z2.string().optional() }
|
|
2258
|
+
},
|
|
2259
|
+
async ({ area }) => guard(async () => statusFor(await ctxFactory(), area))
|
|
2260
|
+
);
|
|
2261
|
+
server.registerTool(
|
|
2262
|
+
"work_finish",
|
|
2263
|
+
{
|
|
2264
|
+
description: "Stop the effort's runner, release its leases, push its branch, leave commons notices. The worktree and branch stay. Refuses DIRTY_WORKTREE unless park=true (commits as wip).",
|
|
2265
|
+
inputSchema: { area: z2.string(), park: z2.boolean().optional() }
|
|
2266
|
+
},
|
|
2267
|
+
async ({ area, park }) => guard(async () => finishEffort(await ctxFactory(), { area, park }))
|
|
2268
|
+
);
|
|
2269
|
+
server.registerTool(
|
|
2270
|
+
"work_reap",
|
|
2271
|
+
{
|
|
2272
|
+
description: "Expire idle leases: stop only what they started, release them, park their efforts. dryRun lists without touching.",
|
|
2273
|
+
inputSchema: { dryRun: z2.boolean().optional() }
|
|
2274
|
+
},
|
|
2275
|
+
async ({ dryRun }) => guard(async () => reapLeases(await ctxFactory(), { dryRun }))
|
|
2276
|
+
);
|
|
2277
|
+
server.registerTool(
|
|
2278
|
+
"work_audit",
|
|
2279
|
+
{
|
|
2280
|
+
description: "Findings: dirty or unpushed area worktrees, foreign worktrees, aging leases, drift from the base branch, stale notices, archive size.",
|
|
2281
|
+
inputSchema: {}
|
|
2282
|
+
},
|
|
2283
|
+
async () => guard(async () => auditProject(await ctxFactory()))
|
|
2284
|
+
);
|
|
2285
|
+
server.registerTool(
|
|
2286
|
+
"work_env",
|
|
2287
|
+
{
|
|
2288
|
+
description: "The slot-derived variables of an area's open effort (PORT_*, SLOT, LOG_DIR, WORKTREE, BRANCH, MODE).",
|
|
2289
|
+
inputSchema: { area: z2.string() }
|
|
2290
|
+
},
|
|
2291
|
+
async ({ area }) => guard(async () => effortEnv(await ctxFactory(), area))
|
|
2292
|
+
);
|
|
2293
|
+
return server;
|
|
2294
|
+
}
|
|
2295
|
+
async function runStdio() {
|
|
2296
|
+
const server = buildMcpServer(() => buildCtx({ quiet: true }));
|
|
2297
|
+
await server.connect(new StdioServerTransport());
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
// src/commands/mcp.ts
|
|
2301
|
+
function registerMcp(program2) {
|
|
2302
|
+
program2.command("mcp").description("Serve the MCP stdio interface (the same verbs as the CLI)").action(async () => {
|
|
2303
|
+
await runStdio();
|
|
2304
|
+
});
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
// src/commands/reap.ts
|
|
2308
|
+
import chalk6 from "chalk";
|
|
2309
|
+
function registerReap(program2) {
|
|
2310
|
+
program2.command("reap").description(
|
|
2311
|
+
"Expire idle leases: stop only what they started, release them, park their efforts"
|
|
2312
|
+
).option("--dry-run", "list what would be reaped without touching anything").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(async (o) => {
|
|
2313
|
+
try {
|
|
2314
|
+
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
|
|
2315
|
+
const r = await reapLeases(ctx, { dryRun: o.dryRun });
|
|
2316
|
+
if (o.json) printJson(r);
|
|
2317
|
+
else if (r.reaped.length === 0) console.log(chalk6.gray("nothing to reap"));
|
|
2318
|
+
else
|
|
2319
|
+
for (const l of r.reaped)
|
|
2320
|
+
console.log(
|
|
2321
|
+
`${o.dryRun ? "would reap" : "reaped"} ${l.kind} ${l.value} (${l.owner.session}, expired ${l.expires})`
|
|
2322
|
+
);
|
|
2323
|
+
} catch (err) {
|
|
2324
|
+
fail(err, Boolean(o.json));
|
|
2325
|
+
}
|
|
2326
|
+
});
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
// src/commands/retire.ts
|
|
2330
|
+
import chalk7 from "chalk";
|
|
2331
|
+
function registerRetire(program2) {
|
|
2332
|
+
program2.command("retire").description(
|
|
2333
|
+
"Archive an area's evidence, remove its worktree and its local branches that are on origin (the only destructive verb)"
|
|
2334
|
+
).requiredOption("--area <area>", "the area to retire").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(async (o) => {
|
|
2335
|
+
try {
|
|
2336
|
+
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
|
|
2337
|
+
const r = await retireArea(ctx, { area: o.area });
|
|
2338
|
+
if (o.json) printJson(r);
|
|
2339
|
+
else {
|
|
2340
|
+
console.log(`${chalk7.bold(o.area)} retired: removed ${r.removed}`);
|
|
2341
|
+
for (const a of r.archived) console.log(` archived ${a}`);
|
|
2342
|
+
for (const b of r.branchesDeleted) console.log(` deleted local branch ${b}`);
|
|
2343
|
+
for (const b of r.branchesKept)
|
|
2344
|
+
console.log(chalk7.yellow(` kept ${b}: not fully on origin`));
|
|
2345
|
+
}
|
|
2346
|
+
} catch (err) {
|
|
2347
|
+
fail(err, Boolean(o.json));
|
|
2348
|
+
}
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
// src/commands/start.ts
|
|
2353
|
+
import chalk8 from "chalk";
|
|
2354
|
+
function printStart(r) {
|
|
2355
|
+
const e = r.effort;
|
|
2356
|
+
console.log(
|
|
2357
|
+
`${chalk8.bold(e.area)} ${e.type}/${e.mode} ${e.branch}${r.joined ? chalk8.yellow(" (joined)") : ""}`
|
|
2358
|
+
);
|
|
2359
|
+
console.log(` worktree ${e.worktree}`);
|
|
2360
|
+
console.log(
|
|
2361
|
+
` sync ${r.sync}${r.refreshed.length ? ` refreshed: ${r.refreshed.join("; ")}` : ""}`
|
|
2362
|
+
);
|
|
2363
|
+
for (const l of r.leases) {
|
|
2364
|
+
const ports = l.ports ? ` ${Object.entries(l.ports).map(([k, v]) => `${k}=${v}`).join(" ")}` : "";
|
|
2365
|
+
console.log(` lease ${l.kind} ${l.value}${ports} expires ${l.expires}`);
|
|
2366
|
+
}
|
|
2367
|
+
for (const w of r.warnings) warn(w);
|
|
2368
|
+
for (const n of r.notices) console.log(chalk8.yellow(` notice ${n.text} (${n.at})`));
|
|
2369
|
+
}
|
|
2370
|
+
function registerStart(program2) {
|
|
2371
|
+
program2.command("start").description(
|
|
2372
|
+
"Start an effort: the area's worktree on <type>/<area>, synced from the base branch, leased and running"
|
|
2373
|
+
).requiredOption("--area <area>", "a leaf area from helmsmith.config (e.g. org/applications)").requiredOption("--type <type>", "feature | chore | fix (as the manifest allows)").requiredOption("--mode <mode>", "fullstack (a slot) | frontend (storybrokr)").option(
|
|
2374
|
+
"--story <path>",
|
|
2375
|
+
"frontend: the story file or component folder storybrokr boots, relative to the Storybook host"
|
|
2376
|
+
).option("--join", "work in the already-open effort on this area instead of refusing").option("--no-sync", "skip the fetch and merge from the base branch (reproducing something)").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the result as JSON").action(
|
|
2377
|
+
async (o) => {
|
|
2378
|
+
try {
|
|
2379
|
+
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: Boolean(o.json) });
|
|
2380
|
+
const r = await startEffort(ctx, {
|
|
2381
|
+
area: o.area,
|
|
2382
|
+
type: o.type,
|
|
2383
|
+
mode: o.mode,
|
|
2384
|
+
story: o.story,
|
|
2385
|
+
join: o.join,
|
|
2386
|
+
sync: o.sync
|
|
2387
|
+
});
|
|
2388
|
+
if (o.json) printJson(r);
|
|
2389
|
+
else printStart(r);
|
|
2390
|
+
} catch (err) {
|
|
2391
|
+
fail(err, Boolean(o.json));
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
);
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
// src/commands/status.ts
|
|
2398
|
+
import chalk9 from "chalk";
|
|
2399
|
+
var age = (iso) => {
|
|
2400
|
+
const mins = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 6e4));
|
|
2401
|
+
return mins < 60 ? `${mins}m` : mins < 1440 ? `${Math.round(mins / 60)}h` : `${Math.round(mins / 1440)}d`;
|
|
2402
|
+
};
|
|
2403
|
+
var expiresIn = (iso) => {
|
|
2404
|
+
const mins = Math.round((Date.parse(iso) - Date.now()) / 6e4);
|
|
2405
|
+
return mins <= 0 ? chalk9.red("expired") : mins < 60 ? `${mins}m` : `${Math.round(mins / 60)}h`;
|
|
2406
|
+
};
|
|
2407
|
+
function printStatus(r) {
|
|
2408
|
+
console.log(chalk9.bold(`${r.project}`));
|
|
2409
|
+
const cols = ["AREA", "TYPE/MODE", "BRANCH", "OWNER", "STARTED", "LEASES", "BEHIND", "NOTICES"];
|
|
2410
|
+
const rows = r.areas.map((a) => [
|
|
2411
|
+
a.area,
|
|
2412
|
+
a.effort ? `${a.effort.type}/${a.effort.mode}` : chalk9.gray("-"),
|
|
2413
|
+
a.effort?.branch ?? chalk9.gray("-"),
|
|
2414
|
+
a.effort?.owner.session ?? chalk9.gray("-"),
|
|
2415
|
+
a.effort ? age(a.effort.started) : chalk9.gray("-"),
|
|
2416
|
+
a.leases.length ? a.leases.map((l) => `${l.kind}:${l.value}`).join(",") : chalk9.gray("-"),
|
|
2417
|
+
a.behindBase > 0 ? chalk9.yellow(String(a.behindBase)) : "0",
|
|
2418
|
+
a.notices.length ? chalk9.yellow(String(a.notices.length)) : "0"
|
|
2419
|
+
]);
|
|
2420
|
+
const plain = (s) => s.replace(/\u001b\[[0-9;]*m/g, "");
|
|
2421
|
+
const widths = cols.map((c, i) => Math.max(c.length, ...rows.map((row) => plain(row[i]).length)));
|
|
2422
|
+
const line = (cells) => cells.map((c, i) => c + " ".repeat(widths[i] - plain(c).length)).join(" ");
|
|
2423
|
+
console.log(chalk9.bold(line(cols)));
|
|
2424
|
+
for (const row of rows) console.log(line(row));
|
|
2425
|
+
if (r.leases.length) {
|
|
2426
|
+
console.log("");
|
|
2427
|
+
console.log(chalk9.bold("KIND VALUE OWNER AGE EXPIRES-IN"));
|
|
2428
|
+
for (const l of r.leases) {
|
|
2429
|
+
console.log(
|
|
2430
|
+
`${l.kind.padEnd(10)} ${String(l.value).padEnd(12)} ${l.owner.session.padEnd(21)} ${age(l.created).padEnd(5)} ${expiresIn(l.expires)}`
|
|
2431
|
+
);
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
function registerStatus(program2) {
|
|
2436
|
+
program2.command("status").description("Open efforts, leases, drift from the base branch and notices, per area").option("--area <area>", "one area only").option("--project-root <dir>", "repository root (default: the main checkout of the cwd)").option("--json", "print the report as JSON").action(async (o) => {
|
|
2437
|
+
try {
|
|
2438
|
+
const ctx = await buildCtx({ projectRoot: o.projectRoot, quiet: true });
|
|
2439
|
+
const r = statusFor(ctx, o.area);
|
|
2440
|
+
if (o.json) printJson(r);
|
|
2441
|
+
else printStatus(r);
|
|
2442
|
+
} catch (err) {
|
|
2443
|
+
fail(err, Boolean(o.json));
|
|
2444
|
+
}
|
|
2445
|
+
});
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
// src/cli.ts
|
|
2449
|
+
var { program } = createCli({
|
|
2450
|
+
name: "work",
|
|
2451
|
+
version: VERSION,
|
|
2452
|
+
description: "Area-based work: start, status, finish, reap, retire, audit."
|
|
2453
|
+
});
|
|
2454
|
+
registerInit(program);
|
|
2455
|
+
registerStart(program);
|
|
2456
|
+
registerStatus(program);
|
|
2457
|
+
registerEnv(program);
|
|
2458
|
+
registerFinish(program);
|
|
2459
|
+
registerReap(program);
|
|
2460
|
+
registerRetire(program);
|
|
2461
|
+
registerAudit(program);
|
|
2462
|
+
registerHook(program);
|
|
2463
|
+
registerMcp(program);
|
|
2464
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
2465
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
2466
|
+
process.exit(1);
|
|
2467
|
+
});
|
|
2468
|
+
//# sourceMappingURL=cli.js.map
|