@krapht/ork 1.0.0-alpha.2 → 1.0.0-alpha.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -7
- package/dist/__build_info__-0wqQ-rtf.mjs +180 -0
- package/dist/apply-Mb0fNpCX.mjs +17 -0
- package/dist/argument-error-CihH46Le.mjs +27 -0
- package/dist/attach-C0-YyNoY.mjs +92 -0
- package/dist/attach-CAKNqnUK.mjs +1259 -0
- package/dist/bin.mjs +1885 -1609
- package/dist/brief-C2hew8cS.mjs +116 -0
- package/dist/{decide-CGyx9rv7.mjs → builtin-files-YYAnbZGD.mjs} +3 -51
- package/dist/chips-CP_pgKlj.mjs +48 -0
- package/dist/command-tree-CEkzWNy6.mjs +63 -0
- package/dist/{completion-CSU5vkEt.mjs → completion-K4rAVsrf.mjs} +200 -30
- package/dist/context-B-fjPJky.mjs +106 -0
- package/dist/context-DMK0U1w0.mjs +160 -0
- package/dist/detached-q0AYG0Bs.mjs +39 -0
- package/dist/extra-arguments-BxQMXARO.mjs +13 -0
- package/dist/{find-D6Gj1sxg.mjs → find-Ct5CNl1J.mjs} +2 -2
- package/dist/flags-C90N4J1i.mjs +145 -0
- package/dist/follow-ups-D3HPyNYM.mjs +568 -0
- package/dist/format-CiPhQqbX.mjs +260 -0
- package/dist/{help-groups-BwYoAWsh.mjs → help-groups-BQvcKQ9H.mjs} +49 -78
- package/dist/in-a-run-D_wg4iJi.mjs +27 -0
- package/dist/install-Hji6LIei.mjs +259 -0
- package/dist/{limits-B1-DYc3d.mjs → limits-BD6RZRCS.mjs} +2 -2
- package/dist/{mcp-DE_EmyVm.mjs → mcp-CpbDmBLG.mjs} +6 -6
- package/dist/once-J8Qn5fTG.mjs +112 -0
- package/dist/orphan-guard-BFKg8VSd.mjs +58 -0
- package/dist/output-DXZbnFt_.mjs +458 -0
- package/dist/outputs-text-CNTJ7Qq_.mjs +19 -0
- package/dist/{pause-CL6N_Xrj.mjs → pause-Cl4ZYA35.mjs} +7 -4
- package/dist/print-DzugGZQB.mjs +19 -0
- package/dist/project-records-CtkmlsHN.mjs +233 -0
- package/dist/refusals-DQQX3AIb.mjs +116 -0
- package/dist/{relay-0SD0erwf.mjs → relay-CiP77vga.mjs} +2 -2
- package/dist/run-CtuNY3mE.mjs +234 -0
- package/dist/send-DRYGks8K.mjs +104 -0
- package/dist/short-flags-DkJLWVqh.mjs +1 -0
- package/dist/short-flags-Qf21as0E.mjs +83 -0
- package/dist/src-BMFQKHz4.mjs +3122 -0
- package/dist/{src-BbyEoMn4.mjs → src-BrFfDKux.mjs} +800 -235
- package/dist/{src-Dujmvv4s.mjs → src-BuF9Lbow.mjs} +12580 -5505
- package/dist/{src-DOcK8Jfp.mjs → src-k0A_QpST.mjs} +1024 -590
- package/dist/store-rTgRu8xy.mjs +25 -0
- package/dist/ui/THIRD_PARTY_LICENSES.txt +612 -0
- package/dist/ui/assets/app-BYxpKJRh.css +1 -0
- package/dist/ui/assets/app-SEFEJLkF.js +112 -0
- package/dist/ui/assets/{index-9dFha9Qn.js → index-Cb6Voq_W.js} +3 -3
- package/dist/ui/assets/theme-CdXvqTQ6.js +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/{ui-CTmO2Xzu.mjs → ui-C2f37TSb.mjs} +57 -25
- package/dist/usage-BuvGcQSk.mjs +256 -0
- package/dist/validate-ObP5lt29.mjs +1 -0
- package/dist/validate-_HjACtk9.mjs +632 -0
- package/dist/verdict-B592j1W3.mjs +84 -0
- package/dist/{watch-CtbgDAcz.mjs → watch-DV3ixCYj.mjs} +45 -17
- package/dist/{workflows-D1_WVukk.mjs → workflows-QLjBhGNS.mjs} +30 -5
- package/dist/worktrees-D3ubG_Qw.mjs +302 -0
- package/dist/yolo-BSH1E_kE.mjs +33 -0
- package/package.json +10 -8
- package/dist/__build_info__-fy6L1Mrd.mjs +0 -615
- package/dist/clean-DaHjs__2.mjs +0 -88
- package/dist/context-DT8MfEIR.mjs +0 -274
- package/dist/format-Dnfv8zMT.mjs +0 -427
- package/dist/in-a-run-pLAZeNys.mjs +0 -22
- package/dist/list-BrH8KRSJ.mjs +0 -110
- package/dist/output-BLQWD6MJ.mjs +0 -950
- package/dist/remove-BeGdqroy.mjs +0 -194
- package/dist/report-DK4LGqwF.mjs +0 -129
- package/dist/send-C8tunWhv.mjs +0 -163
- package/dist/src-CCUKRX51.mjs +0 -1204
- package/dist/ui/assets/app-BNT0SuIz.css +0 -1
- package/dist/ui/assets/app-T7wVAhgx.js +0 -67
- package/dist/ui/assets/theme-3xH5jxKs.js +0 -1
- package/dist/usage-DYUmP0i4.mjs +0 -419
- package/dist/worktrees-DdMYoNOC.mjs +0 -212
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { Kr as placeFiles, Qr as inside, br as feedbackActions, ei as LESSON_CAP_TOKENS, ii as writePlainFile, ni as guardedBase, qr as placeFor, ri as plainPathInside, ti as estimateTokens } from "./src-BuF9Lbow.mjs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
// ../core/src/feedback/lessons.ts
|
|
6
|
+
/**
|
|
7
|
+
* @file Accepting a feedback item as a lesson (specs/feedback.md, ADR-059): one
|
|
8
|
+
* line in the workflow's `.ork/lessons/<workflow>.md`, marked with the item
|
|
9
|
+
* it came from so it isn't added twice, and refused when it would take the
|
|
10
|
+
* file past the cap a session loads, or when the file, or a folder on the way
|
|
11
|
+
* to it, is a link a cloned repository could aim at the person's own files.
|
|
12
|
+
* Only a person's acceptance writes one: feedback is model output that
|
|
13
|
+
* repository content can steer.
|
|
14
|
+
*/
|
|
15
|
+
/** A workflow's name, as the format allows it: never a path. */
|
|
16
|
+
const SLUG = /^[a-z0-9][a-z0-9._-]*$/u;
|
|
17
|
+
/** A workflow's lessons file in a project. */
|
|
18
|
+
const lessonsFileOf = (place, workflow) => path.join(placeFiles(place).lessons, `${workflow}.md`);
|
|
19
|
+
/** What marks a lesson with its item, in the file. */
|
|
20
|
+
const lessonMark = (item) => `<!-- feedback ${item.slice(0, 12)} -->`;
|
|
21
|
+
/** The lesson as one list entry, its later lines indented under the first. */
|
|
22
|
+
const entryOf = (text, item) => {
|
|
23
|
+
const [first = "", ...rest] = text.trim().split("\n").map((line) => line.trimEnd());
|
|
24
|
+
return [`- ${first} ${lessonMark(item)}`, ...rest.map((l) => ` ${l}`)].join("\n").concat("\n");
|
|
25
|
+
};
|
|
26
|
+
/** Adds a lesson to its workflow's file, or says why it can't. */
|
|
27
|
+
const addLesson = (ask) => {
|
|
28
|
+
if (ask.text.trim() === "") return {
|
|
29
|
+
ok: false,
|
|
30
|
+
error: "The lesson is empty."
|
|
31
|
+
};
|
|
32
|
+
if (!SLUG.test(ask.workflow) || !plainPathInside(ask.base, ask.file)) return {
|
|
33
|
+
ok: false,
|
|
34
|
+
error: `${ask.shownAs} is a link, or behind one, or outside the project: Ork writes lessons only to a plain file there, so it wrote nothing.`
|
|
35
|
+
};
|
|
36
|
+
const current = existsSync(ask.file) ? readFileSync(ask.file, "utf8") : `# Lessons for ${ask.workflow}\n\n`;
|
|
37
|
+
if (current.includes(lessonMark(ask.item))) return {
|
|
38
|
+
ok: false,
|
|
39
|
+
error: `${ask.item.slice(0, 12)} is already a lesson in ${ask.shownAs}.`
|
|
40
|
+
};
|
|
41
|
+
const next = `${current}${current === "" || current.endsWith("\n") ? "" : "\n"}${entryOf(ask.text, ask.item)}`;
|
|
42
|
+
const tokens = estimateTokens(next);
|
|
43
|
+
if (tokens > 1500) return {
|
|
44
|
+
ok: false,
|
|
45
|
+
error: `${ask.shownAs} would be about ${tokens.toLocaleString("en")} tokens, over the ${LESSON_CAP_TOKENS.toLocaleString("en")} a session loads. Edit it to make room, then accept again.`
|
|
46
|
+
};
|
|
47
|
+
const written = writePlainFile(ask.base, ask.file, next, ask.shownAs);
|
|
48
|
+
return written.ok ? {
|
|
49
|
+
ok: true,
|
|
50
|
+
tokens
|
|
51
|
+
} : written;
|
|
52
|
+
};
|
|
53
|
+
// ../core/src/feedback/accept.ts
|
|
54
|
+
/**
|
|
55
|
+
* @file Accepting a feedback item (`ork feedback accept`, the Feedback panel):
|
|
56
|
+
* marks an item accepted and, with `--lesson`, writes it as a lesson into the
|
|
57
|
+
* lessons file of the workflow it was seen in, in the project of its newest
|
|
58
|
+
* record there (ADR-059). An `ork` item is about Ork itself, so it's never a
|
|
59
|
+
* lesson: it can be published instead.
|
|
60
|
+
*/
|
|
61
|
+
const listed = (names) => names.map((n) => `\`${n}\``).join(", ");
|
|
62
|
+
/** The workflow a lesson is for: the one named, or the only one it was seen in. */
|
|
63
|
+
const workflowFor = (item, named) => {
|
|
64
|
+
if (named !== void 0) return item.workflows.includes(named) ? {
|
|
65
|
+
ok: true,
|
|
66
|
+
workflow: named
|
|
67
|
+
} : {
|
|
68
|
+
ok: false,
|
|
69
|
+
error: `${item.id.slice(0, 12)} wasn't seen in \`${named}\`, only in ${listed(item.workflows)}.`
|
|
70
|
+
};
|
|
71
|
+
const [only, other] = item.workflows;
|
|
72
|
+
if (only === void 0) return {
|
|
73
|
+
ok: false,
|
|
74
|
+
error: `No workflow is recorded for ${item.id.slice(0, 12)}, so it has no lessons file.`
|
|
75
|
+
};
|
|
76
|
+
return other === void 0 ? {
|
|
77
|
+
ok: true,
|
|
78
|
+
workflow: only
|
|
79
|
+
} : {
|
|
80
|
+
ok: false,
|
|
81
|
+
error: `${item.id.slice(0, 12)} was seen in ${listed(item.workflows)}: name one with --workflow.`
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
const real = (dir) => {
|
|
85
|
+
try {
|
|
86
|
+
return realpathSync(dir);
|
|
87
|
+
} catch {
|
|
88
|
+
return path.resolve(dir);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* The project of the item's newest record (in `workflow`, when given): the
|
|
93
|
+
* folder its run started in, when that's still the same project. A clone that
|
|
94
|
+
* was deleted is refused, never taken for a repository above it.
|
|
95
|
+
*/
|
|
96
|
+
const placeOf = (store, item, workflow, registered) => {
|
|
97
|
+
const record = store.feedbackRecords(item.id).findLast((r) => workflow === void 0 || r.feedback.origin.workflow === workflow);
|
|
98
|
+
const [run] = record === void 0 ? [] : store.lookup(record.runId);
|
|
99
|
+
if (run === void 0) return {
|
|
100
|
+
ok: false,
|
|
101
|
+
error: `The runs ${item.id.slice(0, 12)} was seen in are gone, so its project isn't known.`
|
|
102
|
+
};
|
|
103
|
+
if (!existsSync(run.project)) return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: `Its project at ${run.project} is gone.`
|
|
106
|
+
};
|
|
107
|
+
const folder = path.dirname(run.file);
|
|
108
|
+
const place = placeFor(existsSync(folder) ? folder : run.project, registered);
|
|
109
|
+
return real(place.root) === real(run.project) ? {
|
|
110
|
+
ok: true,
|
|
111
|
+
place,
|
|
112
|
+
project: run.project
|
|
113
|
+
} : {
|
|
114
|
+
ok: false,
|
|
115
|
+
error: `Its project at ${run.project} isn't a project any more: Ork would take it for ${place.root}.`
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
/** Accepts `item`, as a lesson with `--lesson`. */
|
|
119
|
+
const acceptItem = (store, item, args, registered) => {
|
|
120
|
+
const name = `${item.id.slice(0, 12)}: ${item.title}`;
|
|
121
|
+
const actions = feedbackActions(item);
|
|
122
|
+
if (!args.lesson) {
|
|
123
|
+
if (!actions.accept.ok) return {
|
|
124
|
+
ok: false,
|
|
125
|
+
error: actions.accept.error
|
|
126
|
+
};
|
|
127
|
+
store.setFeedbackStatus(item.id, "accepted");
|
|
128
|
+
return {
|
|
129
|
+
ok: true,
|
|
130
|
+
lines: [`accepted ${name}`]
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (!actions.lesson.ok) return {
|
|
134
|
+
ok: false,
|
|
135
|
+
error: actions.lesson.error
|
|
136
|
+
};
|
|
137
|
+
const chosen = workflowFor(item, args.workflow);
|
|
138
|
+
if (!chosen.ok) return chosen;
|
|
139
|
+
const found = placeOf(store, item, chosen.workflow, registered);
|
|
140
|
+
if (!found.ok) return found;
|
|
141
|
+
const { place } = found;
|
|
142
|
+
const file = lessonsFileOf(place, chosen.workflow);
|
|
143
|
+
const shownAs = inside(place.root, file) ? path.relative(place.root, file) : file;
|
|
144
|
+
const added = addLesson({
|
|
145
|
+
file,
|
|
146
|
+
base: guardedBase(place),
|
|
147
|
+
shownAs,
|
|
148
|
+
workflow: chosen.workflow,
|
|
149
|
+
item: item.id,
|
|
150
|
+
text: args.text ?? item.title
|
|
151
|
+
});
|
|
152
|
+
if (!added.ok) return added;
|
|
153
|
+
if (actions.accept.ok) store.setFeedbackStatus(item.id, "accepted");
|
|
154
|
+
return {
|
|
155
|
+
ok: true,
|
|
156
|
+
lesson: {
|
|
157
|
+
file: shownAs,
|
|
158
|
+
root: place.root,
|
|
159
|
+
workflow: chosen.workflow,
|
|
160
|
+
tokens: added.tokens
|
|
161
|
+
},
|
|
162
|
+
lines: [`accepted ${name}`, `lesson added to ${shownAs} in ${place.root} (about ${added.tokens.toLocaleString("en")} of ${LESSON_CAP_TOKENS.toLocaleString("en")} tokens); every session of \`${chosen.workflow}\` reads it from now on`]
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
const lessonPlaceOf = (store, item, workflow, registered) => {
|
|
166
|
+
const found = placeOf(store, item, workflow, registered);
|
|
167
|
+
if (!found.ok) return {
|
|
168
|
+
ok: false,
|
|
169
|
+
workflow,
|
|
170
|
+
error: found.error
|
|
171
|
+
};
|
|
172
|
+
const { place } = found;
|
|
173
|
+
const file = lessonsFileOf(place, workflow);
|
|
174
|
+
const exists = existsSync(file);
|
|
175
|
+
return {
|
|
176
|
+
ok: true,
|
|
177
|
+
workflow,
|
|
178
|
+
file: inside(place.root, file) ? path.relative(place.root, file) : file,
|
|
179
|
+
root: place.root,
|
|
180
|
+
tokens: exists ? estimateTokens(readFileSync(file, "utf8")) : 0,
|
|
181
|
+
exists
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
// ../core/src/remote/session.ts
|
|
185
|
+
const chattable = (type) => type === "agent.leader" || type === "agent.worker";
|
|
186
|
+
/** Reads `node`'s session from a run's events, and every node's last output. */
|
|
187
|
+
const nodeSession = (events, node) => {
|
|
188
|
+
const session = {
|
|
189
|
+
known: false,
|
|
190
|
+
chattable: false,
|
|
191
|
+
running: false
|
|
192
|
+
};
|
|
193
|
+
const outputs = {};
|
|
194
|
+
for (const { event } of events) {
|
|
195
|
+
if (event.type === "run.started") {
|
|
196
|
+
const declared = event.nodes.find((n) => n.id === node);
|
|
197
|
+
if (declared !== void 0) {
|
|
198
|
+
session.known = true;
|
|
199
|
+
session.chattable = chattable(declared.type);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (event.type === "node.finished") outputs[event.id] = event.output;
|
|
203
|
+
if (!("id" in event) || event.id !== node) continue;
|
|
204
|
+
if (event.type === "node.started") {
|
|
205
|
+
session.known = true;
|
|
206
|
+
session.chattable = chattable(event.nodeType);
|
|
207
|
+
session.running = true;
|
|
208
|
+
}
|
|
209
|
+
if (event.type === "node.finished") session.running = false;
|
|
210
|
+
if (event.type === "worktree" && event.action === "created") session.checkout = {
|
|
211
|
+
path: event.path,
|
|
212
|
+
...event.branch === void 0 ? {} : { branch: event.branch },
|
|
213
|
+
own: true,
|
|
214
|
+
owner: node
|
|
215
|
+
};
|
|
216
|
+
if (event.type !== "node.event") continue;
|
|
217
|
+
const inner = event.event;
|
|
218
|
+
if (inner.fork === true) continue;
|
|
219
|
+
if (inner.type === "session.started") {
|
|
220
|
+
session.sessionId = inner.sessionId;
|
|
221
|
+
if (inner.cwd !== void 0) session.cwd = inner.cwd;
|
|
222
|
+
if (inner.driver !== void 0) session.driver = inner.driver;
|
|
223
|
+
if (inner.model !== "") session.model = inner.model;
|
|
224
|
+
}
|
|
225
|
+
if (inner.type === "session.finished" && inner.outcome.sessionId !== "") session.sessionId = inner.outcome.sessionId;
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
session,
|
|
229
|
+
outputs
|
|
230
|
+
};
|
|
231
|
+
};
|
|
232
|
+
/**
|
|
233
|
+
* Runs the command; resolves its exit code. Without `onOutput` it writes on the
|
|
234
|
+
* terminal.
|
|
235
|
+
*/
|
|
236
|
+
const runInstall = (argv, onOutput) => new Promise((resolve) => {
|
|
237
|
+
const [program = "", ...args] = argv;
|
|
238
|
+
const child = spawn(program, args, { stdio: onOutput === void 0 ? "inherit" : [
|
|
239
|
+
"ignore",
|
|
240
|
+
"pipe",
|
|
241
|
+
"pipe"
|
|
242
|
+
] });
|
|
243
|
+
for (const stream of ["stdout", "stderr"]) {
|
|
244
|
+
child[stream]?.setEncoding("utf8");
|
|
245
|
+
child[stream]?.on("data", (text) => {
|
|
246
|
+
onOutput?.(stream, text);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
child.on("error", (error) => {
|
|
250
|
+
const line = `Could not run ${program}: ${error.message}\n`;
|
|
251
|
+
if (onOutput === void 0) process.stderr.write(line);
|
|
252
|
+
else onOutput("stderr", line);
|
|
253
|
+
resolve(127);
|
|
254
|
+
});
|
|
255
|
+
child.on("close", (code) => {
|
|
256
|
+
resolve(code ?? 1);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
export { placeOf as a, lessonPlaceOf as i, nodeSession as n, acceptItem as r, runInstall as t };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { _t as limitsReport, gt as limitsNow, ht as NOT_STARTED, v as capsHere } from "./src-BuF9Lbow.mjs";
|
|
2
2
|
// src/run/limits.ts
|
|
3
3
|
/**
|
|
4
4
|
* @file The limits across runs that apply here (RFC-007): the user's settings
|
|
@@ -26,7 +26,7 @@ const runLimits = (store, place, home) => {
|
|
|
26
26
|
* Whether what is left of a workflow can spend: a tool-only one, or a resume
|
|
27
27
|
* with only tools to go, is not held back by a quota.
|
|
28
28
|
*/
|
|
29
|
-
const spendsAnything = (workflow, history) => Object.entries(workflow.nodes).some(([id, node]) => (node.type === "
|
|
29
|
+
const spendsAnything = (workflow, history) => Object.entries(workflow.nodes).some(([id, node]) => (node.type === "agent.leader" || node.type === "agent.worker" || node.type === "workflow.call") && history?.get(id)?.at(-1)?.ok !== true);
|
|
30
30
|
/** Why a run won't start: a settings file it can't read, or a spent limit. */
|
|
31
31
|
const limitsRefusal = (limits, workflow, history) => {
|
|
32
32
|
if (!spendsAnything(workflow, history)) return [];
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { n as explainNode } from "./context-
|
|
5
|
-
import {
|
|
1
|
+
import { T as isNewer } from "./src-BuF9Lbow.mjs";
|
|
2
|
+
import { h as runMarkerIn } from "./src-k0A_QpST.mjs";
|
|
3
|
+
import { n as ORK_VERSION } from "./__build_info__-0wqQ-rtf.mjs";
|
|
4
|
+
import { n as explainNode } from "./context-B-fjPJky.mjs";
|
|
5
|
+
import { a as openMcp, c as refused, o as tool, s as answer } from "./src-BrFfDKux.mjs";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { defineCommand } from "citty";
|
|
8
8
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -43,7 +43,7 @@ const orkMcpServer = (options = {}) => {
|
|
|
43
43
|
...options.script === void 0 ? {} : { script: options.script }
|
|
44
44
|
});
|
|
45
45
|
const all = [...opened.tools, explainTool(opened, options.home)];
|
|
46
|
-
const tools = options.expects !== void 0 && isNewer(options.expects, "1.0.0-alpha.
|
|
46
|
+
const tools = options.expects !== void 0 && isNewer(options.expects, "1.0.0-alpha.21") ? tooOld(all, options.expects) : all;
|
|
47
47
|
const server = new McpServer({
|
|
48
48
|
name: "ork",
|
|
49
49
|
version: ORK_VERSION
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import "./src-BuF9Lbow.mjs";
|
|
2
|
+
import { n as OncePathspecError, t as runOnce } from "./run-CtuNY3mE.mjs";
|
|
3
|
+
import { c as write, l as writeErr, s as style } from "./output-DXZbnFt_.mjs";
|
|
4
|
+
import { t as ago } from "./format-CiPhQqbX.mjs";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { defineCommand, parseArgs } from "citty";
|
|
7
|
+
// src/commands/once.ts
|
|
8
|
+
/**
|
|
9
|
+
* @file `ork once -- <command>`: runs a command unless the same check already
|
|
10
|
+
* passed on identical files, and records a pass (specs/check-once.md). The
|
|
11
|
+
* command's own output and exit code are `ork once`'s; a hit prints one line
|
|
12
|
+
* and exits 0. Everything after `--` is the command, so its flags are never
|
|
13
|
+
* Ork's.
|
|
14
|
+
*/
|
|
15
|
+
const args = {
|
|
16
|
+
as: {
|
|
17
|
+
type: "string",
|
|
18
|
+
description: "The check's name (default: the command's text)"
|
|
19
|
+
},
|
|
20
|
+
covers: {
|
|
21
|
+
type: "string",
|
|
22
|
+
description: "Also record the pass for this name (repeat for more)"
|
|
23
|
+
},
|
|
24
|
+
on: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "A git pathspec the check depends on; only matching files key it (repeat for more)"
|
|
27
|
+
},
|
|
28
|
+
"with-head": {
|
|
29
|
+
type: "boolean",
|
|
30
|
+
description: "Key the check by HEAD too (for checks relative to it)"
|
|
31
|
+
},
|
|
32
|
+
force: {
|
|
33
|
+
type: "boolean",
|
|
34
|
+
description: "Run even after a pass"
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
/** The command's exit code: a signal or a program that can't start is 1. */
|
|
38
|
+
const exitCodeOf = (command) => new Promise((resolve) => {
|
|
39
|
+
const [program = "", ...rest] = command;
|
|
40
|
+
const child = spawn(program, rest, { stdio: "inherit" });
|
|
41
|
+
child.on("error", (error) => {
|
|
42
|
+
writeErr(`Cannot run ${program}: ${error.message}`);
|
|
43
|
+
resolve(1);
|
|
44
|
+
});
|
|
45
|
+
child.on("close", (code) => {
|
|
46
|
+
resolve(code ?? 1);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
/**
|
|
50
|
+
* Every use of a repeatable flag, `--flag v` or `--flag=v`: citty keeps only
|
|
51
|
+
* the last. Not `run.ts`'s `flagValues`, which would load the run command's
|
|
52
|
+
* imports on every `ork once`.
|
|
53
|
+
*/
|
|
54
|
+
const valuesOf = (before, flag) => before.flatMap((arg, index) => {
|
|
55
|
+
if (arg.startsWith(`${flag}=`)) return [arg.slice(flag.length + 1)];
|
|
56
|
+
if (arg !== flag) return [];
|
|
57
|
+
const next = before[index + 1];
|
|
58
|
+
return [next === void 0 || next.startsWith("--") ? void 0 : next];
|
|
59
|
+
});
|
|
60
|
+
/** The values of `flag`, or `undefined` when one of its uses has none. */
|
|
61
|
+
const listOf = (before, flag) => {
|
|
62
|
+
const values = valuesOf(before, flag);
|
|
63
|
+
return values.every((value) => value !== void 0 && value !== "") ? values.filter((value) => value !== void 0) : void 0;
|
|
64
|
+
};
|
|
65
|
+
const once = defineCommand({
|
|
66
|
+
meta: {
|
|
67
|
+
name: "once",
|
|
68
|
+
description: "Run a command unless the same check already passed on these files: ork once [flags] -- <command…>"
|
|
69
|
+
},
|
|
70
|
+
args,
|
|
71
|
+
run: async ({ rawArgs }) => {
|
|
72
|
+
const split = rawArgs.indexOf("--");
|
|
73
|
+
const command = split === -1 ? [] : rawArgs.slice(split + 1);
|
|
74
|
+
if (command.length === 0) {
|
|
75
|
+
writeErr("Say what to run after --: ork once [--as <name>] [--covers <name>]… [--on <pathspec>]… [--with-head] [--force] -- <command…>");
|
|
76
|
+
process.exitCode = 1;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const before = rawArgs.slice(0, split);
|
|
80
|
+
const given = parseArgs(before, args);
|
|
81
|
+
const covers = listOf(before, "--covers");
|
|
82
|
+
const on = listOf(before, "--on");
|
|
83
|
+
if (covers === void 0 || on === void 0) {
|
|
84
|
+
writeErr(`${covers === void 0 ? "--covers needs a name" : "--on needs a pathspec"}: ork once [--covers <name>]… [--on <pathspec>]… -- <command…>`);
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const run = process.env["ORK_RUN"];
|
|
89
|
+
const node = process.env["ORK_NODE"];
|
|
90
|
+
const result = await runOnce({
|
|
91
|
+
cwd: process.cwd(),
|
|
92
|
+
name: given["as"] ?? command.join(" "),
|
|
93
|
+
covers,
|
|
94
|
+
on,
|
|
95
|
+
withHead: given["with-head"] === true,
|
|
96
|
+
force: given["force"] === true,
|
|
97
|
+
...run === void 0 || node === void 0 ? {} : { who: `${run}/${node}` },
|
|
98
|
+
run: () => exitCodeOf(command)
|
|
99
|
+
}).catch((error) => {
|
|
100
|
+
if (!(error instanceof OncePathspecError)) throw error;
|
|
101
|
+
writeErr(error.message);
|
|
102
|
+
return null;
|
|
103
|
+
});
|
|
104
|
+
if (result === null) {
|
|
105
|
+
process.exitCode = 1;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (result.skipped) write(`${style("green", "✓")} check already passed on these files · ${ago(result.pass.at)} · ${result.pass.who}`);
|
|
109
|
+
process.exitCode = result.code;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
export { once };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Readable, Writable } from "node:stream";
|
|
2
|
+
// ../core/src/machine/orphan-guard.ts
|
|
3
|
+
/**
|
|
4
|
+
* @file The orphan guard: a node's processes end with `ork`, whatever ends
|
|
5
|
+
* `ork` (SIGKILL, the OOM killer, a crash exit). A child started in its own
|
|
6
|
+
* process group (`detached: true`) under `sh` also starts a small watcher in
|
|
7
|
+
* that group, which reads a pipe that only `ork` holds open. `ork` never
|
|
8
|
+
* writes to it, so when `ork` dies the read sees EOF, and the watcher sends
|
|
9
|
+
* its own group (`kill 0`, never another's) SIGTERM, then SIGKILL after
|
|
10
|
+
* {@link KILL_AFTER_MS}. A line on the pipe instead lets the watcher exit
|
|
11
|
+
* quietly, and so does killing it, which is how a node that ends normally
|
|
12
|
+
* leaves its leftover background processes alone. POSIX only. Two uses:
|
|
13
|
+
*
|
|
14
|
+
* - A shell that outlives the program (the tool runner's timing wrapper):
|
|
15
|
+
* `${orphanGuard(4)} w=$!; "$0" "$@" 4<&-; s=$?; kill -s KILL $w`, then
|
|
16
|
+
* spawn with a `"pipe"` at `stdio[4]` and call
|
|
17
|
+
* `holdGuard(child.stdio[4])`.
|
|
18
|
+
* - A shell that `exec`s the program (a session, so its pid stays the
|
|
19
|
+
* driver's): `${orphanGuard(3)} exec 3<&-; exec "$0" "$@"` (a bare `exec`
|
|
20
|
+
* closes the pipe: macOS's `sh` keeps a copy of one an `exec` command
|
|
21
|
+
* closes, which the driver would inherit), with a `"pipe"` at `stdio[3]`;
|
|
22
|
+
* nothing is left to kill the watcher, so call the function `holdGuard`
|
|
23
|
+
* returns once the driver has exited (on `exit`: the pipe holds `close`).
|
|
24
|
+
*/
|
|
25
|
+
/** How long the group gets to end after SIGTERM before SIGKILL. */
|
|
26
|
+
const KILL_AFTER_MS = 2e3;
|
|
27
|
+
/** The file descriptors a redirect can name in every POSIX shell. */
|
|
28
|
+
const SHELL_FDS = [
|
|
29
|
+
3,
|
|
30
|
+
4,
|
|
31
|
+
5,
|
|
32
|
+
6,
|
|
33
|
+
7,
|
|
34
|
+
8,
|
|
35
|
+
9
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* Shell text that starts the watcher in the background on pipe `fd` (3 to 9);
|
|
39
|
+
* it ends with `&`, so `$!` after it is the watcher's pid. The watcher holds no
|
|
40
|
+
* other descriptor, so it never keeps the child's output pipes open.
|
|
41
|
+
*/
|
|
42
|
+
const orphanGuard = (fd) => {
|
|
43
|
+
const others = SHELL_FDS.filter((other) => other !== fd).map((other) => `${String(other)}>&-`).join(" ");
|
|
44
|
+
return `{ read -r _ork <&${String(fd)} || { trap '' TERM; kill -s TERM 0; sleep ${String(KILL_AFTER_MS / 1e3)}; kill -s KILL 0; }; } </dev/null >/dev/null 2>&1 ${others} & `;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Keeps `ork`'s end of the watch pipe (`child.stdio[fd]`) open and drained, so
|
|
48
|
+
* it never holds back the child's `close`; returns the release, which writes
|
|
49
|
+
* the line that lets the watcher exit without touching its group.
|
|
50
|
+
*/
|
|
51
|
+
const holdGuard = (pipe) => {
|
|
52
|
+
pipe?.on("error", () => {});
|
|
53
|
+
if (pipe instanceof Readable) pipe.resume();
|
|
54
|
+
return () => {
|
|
55
|
+
if (pipe instanceof Writable && pipe.writable) pipe.write("\n");
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
export { holdGuard as n, orphanGuard as r, KILL_AFTER_MS as t };
|