mainflow 0.43.0 → 0.44.0-alpha.2

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.
@@ -0,0 +1,572 @@
1
+ import { D as SESSION_ID, I as configSchema, ft as describeRequest, gt as hasControl, tt as repoPaths } from "./dist-CXrUfRQp.js";
2
+ import { $t as realPath, Gt as resolveHub, Jt as nodeStorage, M as matchGrants, Ot as formatMismatch, Yt as currentBranch, Zt as gitCheckout, j as grantScope, k as readSession, kt as hubFormatVersion, mt as readYaml } from "./host-C74j2Qox.js";
3
+ import { n as hubWriter, r as writtenLines } from "./operation-CUeOFEaf.js";
4
+ import { OWNER_REPO, githubOrigin, quote, readManifest, resolveFixed, scanCommand, scopedCwd } from "./grant-commands.js";
5
+ import { claudeCodeSessionId, parsePayload } from "./hooks.js";
6
+ import { readFileSync } from "node:fs";
7
+ import { dirname, isAbsolute, join, resolve } from "node:path";
8
+ import { spawnSync } from "node:child_process";
9
+ //#region src/grant-check.ts
10
+ const none = (reason) => ({
11
+ decision: "none",
12
+ reason
13
+ });
14
+ /** A call this cannot judge; its message is the reason. */
15
+ var NotJudged = class extends Error {};
16
+ /** Text that makes a call more than one command, or runs a command inside it; checked before any quote is read. */
17
+ const COMPOUND = [
18
+ ["&&", "&&"],
19
+ [";", ";"],
20
+ ["|", "|"],
21
+ ["$(", "$("],
22
+ ["`", "反引號"],
23
+ ["\n", "換行"],
24
+ ["\r", "換行"]
25
+ ];
26
+ /** Characters the shell gives a meaning to outside quotes. */
27
+ const SHELL_CHARS = /* @__PURE__ */ new Set("\\$`|&;<>(){}[]*?!#~");
28
+ /**
29
+ * Split one simple command into the words the shell passes on: words separated by spaces, each made of plain
30
+ * characters, single-quoted text, and double-quoted text in which the shell expands nothing. Anything else throws.
31
+ */
32
+ function shellWords(command) {
33
+ if (hasControl(command)) throw new NotJudged("指令含換行、tab 或其他控制字元");
34
+ const words = [];
35
+ let i = 0;
36
+ while (i < command.length) {
37
+ if (command[i] === " ") {
38
+ i++;
39
+ continue;
40
+ }
41
+ let word = "";
42
+ while (i < command.length && command[i] !== " ") {
43
+ const c = command[i];
44
+ if (c === "'" || c === "\"") {
45
+ const end = command.indexOf(c, i + 1);
46
+ if (end < 0) throw new NotJudged(`${c === "'" ? "單" : "雙"}引號沒有收尾`);
47
+ const inner = command.slice(i + 1, end);
48
+ if (c === "\"" && /[\\$`!]/.test(inner)) throw new NotJudged("雙引號裡有 \\、$、反引號或 !,shell 會展開或轉義它們");
49
+ word += inner;
50
+ i = end + 1;
51
+ } else if (SHELL_CHARS.has(c)) throw new NotJudged(`「${c}」是 shell 語法`);
52
+ else {
53
+ word += c;
54
+ i++;
55
+ }
56
+ }
57
+ words.push(word);
58
+ }
59
+ return words;
60
+ }
61
+ /** The push words that send more than one branch's new commits, or rewrite or delete a remote branch. */
62
+ const PUSH_DANGER = /^(?:--force.*|--delete.*|--mirror.*|--all.*|--tags.*|--prune.*|-[A-Za-z]*[fd][A-Za-z]*)$/;
63
+ const MERGE_DANGER = /^(?:--admin.*|--delete-branch.*|-[A-Za-z]*d[A-Za-z]*)$/;
64
+ function readGit(words) {
65
+ let i = 1;
66
+ let dir;
67
+ while (words[i]?.startsWith("-")) {
68
+ if (words[i] === "-C" && dir === void 0 && words[i + 1] !== void 0) {
69
+ dir = words[i + 1];
70
+ i += 2;
71
+ continue;
72
+ }
73
+ throw new NotJudged(`git 的「${quote(words[i])}」不在固定寫法裡,git 之後只接受一個 -C <目錄>`);
74
+ }
75
+ const sub = words[i];
76
+ const rest = words.slice(i + 1);
77
+ if (sub === "commit") return {
78
+ action: "commit",
79
+ ...dir !== void 0 ? { dir } : {}
80
+ };
81
+ if (sub !== "push") throw new NotJudged(`git ${quote(sub ?? "")} 不是可判斷的動作,只判斷 commit 與 push`);
82
+ const danger = rest.find((w) => PUSH_DANGER.test(w));
83
+ if (danger) throw new NotJudged(`push 帶 ${danger},會強制推送、刪除或推送不只一個分支`);
84
+ const positional = [];
85
+ for (const word of rest) {
86
+ if (word === "-u" || word === "--set-upstream") continue;
87
+ if (word.startsWith("-")) throw new NotJudged(`push 的「${quote(word)}」不在固定寫法裡`);
88
+ if (word.startsWith("+")) throw new NotJudged(`refspec ${quote(word)} 以 + 開頭,會強制推送`);
89
+ if (word.includes(":")) throw new NotJudged(`refspec ${quote(word)} 含 :,會推到別的名稱或刪除遠端分支`);
90
+ positional.push(word);
91
+ }
92
+ if (positional.length > 2) throw new NotJudged("push 最多一個遠端與一個分支");
93
+ const [remote, branch] = positional;
94
+ return {
95
+ action: "push",
96
+ ...dir !== void 0 ? { dir } : {},
97
+ ...remote !== void 0 ? { remote } : {},
98
+ ...branch !== void 0 ? { branch } : {}
99
+ };
100
+ }
101
+ function readMerge(rest) {
102
+ const danger = rest.find((w) => MERGE_DANGER.test(w));
103
+ if (danger) throw new NotJudged(`merge 帶 ${danger},會略過審查規則或刪除分支`);
104
+ let number;
105
+ let method;
106
+ let repo;
107
+ for (let i = 0; i < rest.length; i++) {
108
+ const word = rest[i];
109
+ if (word === "--squash" || word === "--merge" || word === "--rebase") {
110
+ if (method) throw new NotJudged("只能選一種合併方式");
111
+ method = word;
112
+ } else if (word === "-R") {
113
+ if (repo !== void 0 || rest[i + 1] === void 0) throw new NotJudged("-R 要寫一次 <owner/repo>");
114
+ repo = rest[++i];
115
+ if (!OWNER_REPO.test(repo)) throw new NotJudged(`-R ${quote(repo)} 不是 owner/repo`);
116
+ } else if (/^\d+$/.test(word) && number === void 0) number = word;
117
+ else throw new NotJudged(`merge 的「${quote(word)}」不在固定寫法裡`);
118
+ }
119
+ if (repo !== void 0 && number === void 0) throw new NotJudged("帶 -R 時要寫 PR 編號");
120
+ return {
121
+ action: "merge-pr",
122
+ ...number !== void 0 ? { number } : {},
123
+ ...repo !== void 0 ? { repo } : {}
124
+ };
125
+ }
126
+ /** The words after `promptex build`. */
127
+ function readPromptex(rest, via) {
128
+ let install = false;
129
+ let dryRun = false;
130
+ let dir;
131
+ let unit;
132
+ for (let i = 0; i < rest.length; i++) {
133
+ const word = rest[i];
134
+ if (word === "--install" && !install) install = true;
135
+ else if (word === "--dry-run") dryRun = true;
136
+ else if (word === "--unit" && unit === void 0 && rest[i + 1] !== void 0) unit = rest[++i];
137
+ else if (word.startsWith("-")) throw new NotJudged(`promptex build 的「${quote(word)}」不在固定寫法裡`);
138
+ else if (dir === void 0) dir = word;
139
+ else throw new NotJudged("promptex build 只接受一個目錄");
140
+ }
141
+ if (dryRun) throw new NotJudged("帶 --dry-run 只列出變更、不寫入,不需授權");
142
+ if (!install) throw new NotJudged("沒有 --install,不會安裝");
143
+ return {
144
+ action: "install-prompts",
145
+ via,
146
+ ...dir !== void 0 ? { dir } : {},
147
+ ...unit !== void 0 ? { unit } : {}
148
+ };
149
+ }
150
+ /** Read a command as one of the forms; throws NotJudged for anything else. */
151
+ function readCall(command) {
152
+ const words = shellWords(command);
153
+ const [first, second, third] = words;
154
+ if (first === "git") return readGit(words);
155
+ if (first === "gh" && second === "pr" && third === "merge") return readMerge(words.slice(3));
156
+ if (first === "gh" && second === "release" || (first === "npm" || first === "pnpm") && second === "publish") return { action: first === "gh" ? "release" : "publish" };
157
+ if (first === "pnpm" && second === "install:prompts") {
158
+ if (words.length > 2) throw new NotJudged("pnpm install:prompts 不能再帶參數,因為 pnpm 會把它們接到腳本後面");
159
+ return {
160
+ action: "install-prompts",
161
+ via: "script"
162
+ };
163
+ }
164
+ if (first === "pnpm" && second === "exec" && third === "promptex" && words[3] === "build") return readPromptex(words.slice(4), "exec");
165
+ if (first === "promptex" && second === "build") return readPromptex(words.slice(2), "bare");
166
+ throw new NotJudged(`「${quote(command.trim())}」不是可判斷的動作,只判斷 ${Object.keys(FORM_ACTIONS).join("、")} 的固定寫法`);
167
+ }
168
+ /** The actions a single command can be judged for. */
169
+ const FORM_ACTIONS = {
170
+ commit: true,
171
+ push: true,
172
+ "merge-pr": true,
173
+ publish: true,
174
+ release: true,
175
+ "install-prompts": true
176
+ };
177
+ /** The config.yaml names whose checkout holds `dir`: its repository or a linked worktree of it. */
178
+ function repoNamesOf(dir, place) {
179
+ const checkout = gitCheckout(dir);
180
+ if (!checkout) return [];
181
+ const roots = [checkout.current, checkout.main].flatMap((p) => p ? [realPath(p) ?? p] : []);
182
+ return Object.entries(place.repos).filter(([, path]) => {
183
+ const real = realPath(resolve(place.root, path));
184
+ return real !== null && roots.includes(real);
185
+ }).map(([name]) => name);
186
+ }
187
+ /** A directory the call names, resolved against its cwd, symbolic links resolved; throws when it does not exist. */
188
+ function existing(place, dir) {
189
+ const real = realPath(resolve(place.cwd, dir ?? "."));
190
+ if (!real) throw new NotJudged(`目錄 ${quote(dir ?? place.cwd)} 不存在`);
191
+ return real;
192
+ }
193
+ function repoAt(dir, place) {
194
+ const repos = repoNamesOf(dir, place);
195
+ if (repos.length === 0) throw new NotJudged(`${quote(dir)} 不是 config.yaml 登記的倉庫或它的 worktree`);
196
+ return repos;
197
+ }
198
+ /** A git setting at `dir`, every value of it; empty when it is not set. */
199
+ function gitConfig(dir, key) {
200
+ const result = spawnSync("git", [
201
+ "-C",
202
+ dir,
203
+ "config",
204
+ "--get-all",
205
+ key
206
+ ], { encoding: "utf8" });
207
+ return result.status === 0 ? result.stdout.split("\n").filter(Boolean) : [];
208
+ }
209
+ const gitHas = (dir, ref) => spawnSync("git", [
210
+ "-C",
211
+ dir,
212
+ "show-ref",
213
+ "--verify",
214
+ "--quiet",
215
+ ref
216
+ ]).status === 0;
217
+ function resolvePush(call, place) {
218
+ const dir = existing(place, call.dir);
219
+ const repos = repoAt(dir, place);
220
+ const current = currentBranch(dir);
221
+ const remote = call.remote ?? (current && gitConfig(dir, `branch.${current}.pushRemote`)[0]) ?? gitConfig(dir, "remote.pushDefault")[0] ?? (current && gitConfig(dir, `branch.${current}.remote`)[0]) ?? "origin";
222
+ if (remote !== "origin") throw new NotJudged(`遠端是 ${quote(remote)},只放行推到 origin`);
223
+ if (gitConfig(dir, "remote.origin.mirror").some((v) => v === "true")) throw new NotJudged("remote.origin.mirror 是 true,會鏡像推送全部 ref");
224
+ if (gitConfig(dir, "remote.origin.push").length > 0) throw new NotJudged("設了 remote.origin.push,推送的對象由設定決定");
225
+ let branch;
226
+ if (call.branch === void 0) {
227
+ if (!current) throw new NotJudged("沒有寫分支,目前又不在任何分支上");
228
+ const mode = gitConfig(dir, "push.default")[0];
229
+ if (mode !== void 0 && mode !== "simple" && mode !== "current") throw new NotJudged(`push.default 是 ${quote(mode)},沒寫分支時推送的不一定是目前分支`);
230
+ branch = current;
231
+ } else {
232
+ const named = call.branch === "HEAD" ? current : call.branch.replace(/^refs\/heads\//, "");
233
+ if (!named) throw new NotJudged("HEAD 不在任何分支上");
234
+ if (!gitHas(dir, `refs/heads/${named}`)) throw new NotJudged(`${quote(named)} 不是本機的分支`);
235
+ if (gitHas(dir, `refs/tags/${named}`)) throw new NotJudged(`${quote(named)} 有同名的 tag,推送的可能是 tag`);
236
+ branch = named;
237
+ }
238
+ return {
239
+ action: "push",
240
+ repos,
241
+ branch,
242
+ dir
243
+ };
244
+ }
245
+ function resolveMerge(call, place) {
246
+ let repos;
247
+ if (call.repo !== void 0) {
248
+ const slug = `github.com/${call.repo}`.toLowerCase();
249
+ repos = Object.entries(place.repos).filter(([, path]) => githubOrigin(resolve(place.root, path)) === slug).map(([name]) => name);
250
+ if (repos.length === 0) throw new NotJudged(`${quote(call.repo)} 不是 config.yaml 裡任何倉庫的 origin`);
251
+ } else {
252
+ if (place.env.GH_REPO) throw new NotJudged("設了 GH_REPO,gh 合併的倉庫由它決定");
253
+ repos = repoAt(place.cwd, place);
254
+ const remotes = spawnSync("git", [
255
+ "-C",
256
+ place.cwd,
257
+ "remote"
258
+ ], { encoding: "utf8" }).stdout?.split("\n").filter(Boolean) ?? [];
259
+ if (remotes.length !== 1 || remotes[0] !== "origin") throw new NotJudged("沒帶 -R 時,倉庫只能有 origin 一個遠端,gh 才不會選到別的倉庫");
260
+ if (!githubOrigin(place.cwd)) throw new NotJudged("origin 不在 github.com 上");
261
+ }
262
+ const base = place.prBase({
263
+ cwd: place.cwd,
264
+ ...call.number !== void 0 ? { number: call.number } : {},
265
+ ...call.repo !== void 0 ? { repo: call.repo } : {}
266
+ });
267
+ if (!base) throw new NotJudged("gh pr view 讀不到 PR 的 base 分支");
268
+ return {
269
+ action: "merge-pr",
270
+ repos,
271
+ branch: base,
272
+ dir: place.cwd
273
+ };
274
+ }
275
+ /** The promptex binary `pnpm exec` or a package script runs from `cwd`: the nearest node_modules/.bin/promptex above it. */
276
+ function localPromptex(cwd) {
277
+ for (let at = cwd;; at = dirname(at)) {
278
+ const bin = join(at, "node_modules/.bin/promptex");
279
+ if (realPath(bin)) return bin;
280
+ if (dirname(at) === at) return null;
281
+ }
282
+ }
283
+ function resolveInstall(call, place) {
284
+ let read = call;
285
+ if (call.via === "script") {
286
+ const text = readManifest(join(place.cwd, "package.json"));
287
+ if (text === null) throw new NotJudged(`${quote(place.cwd)} 沒有 1 MB 以內的 package.json`);
288
+ let scripts;
289
+ try {
290
+ scripts = JSON.parse(text).scripts ?? {};
291
+ } catch {
292
+ throw new NotJudged("package.json 不是 JSON");
293
+ }
294
+ const script = scripts["install:prompts"];
295
+ if (typeof script !== "string") throw new NotJudged("package.json 沒有 install:prompts 腳本");
296
+ for (const hook of ["preinstall:prompts", "postinstall:prompts"]) if (scripts[hook] !== void 0) throw new NotJudged(`package.json 有 ${hook},會另外執行`);
297
+ const mark = compoundMark(script);
298
+ if (mark) throw new NotJudged(`install:prompts 腳本含 ${mark},只判斷單一指令`);
299
+ const words = shellWords(script);
300
+ if (words[0] !== "promptex" || words[1] !== "build") throw new NotJudged(`install:prompts 腳本「${quote(script)}」不是 promptex build --install`);
301
+ read = readPromptex(words.slice(2), "script");
302
+ }
303
+ const project = existing(place, read.dir);
304
+ if (!scopedCwd(project, place.root, place.repos).ok && repoNamesOf(project, place).length === 0) throw new NotJudged(`${quote(project)} 不在 hub 或登記的倉庫內`);
305
+ const command = call.via === "bare" ? "promptex" : localPromptex(place.cwd);
306
+ if (!command) throw new NotJudged(`${quote(place.cwd)} 往上找不到 node_modules/.bin/promptex`);
307
+ const args = [
308
+ "build",
309
+ read.dir ?? ".",
310
+ "--install",
311
+ ...read.unit !== void 0 ? ["--unit", read.unit] : [],
312
+ "--dry-run",
313
+ "--json"
314
+ ];
315
+ const units = place.listUnits({
316
+ command,
317
+ args,
318
+ cwd: place.cwd
319
+ });
320
+ if (!units || units.length === 0) throw new NotJudged("列不出這次安裝的單元(promptex build --install --dry-run --json 失敗或沒有單元)");
321
+ const repos = repoNamesOf(project, place);
322
+ return units.map((unit) => ({
323
+ action: "install-prompts",
324
+ unit,
325
+ repos,
326
+ dir: project
327
+ }));
328
+ }
329
+ function resolveCall(call, command, place) {
330
+ try {
331
+ switch (call.action) {
332
+ case "publish":
333
+ case "release": {
334
+ const scan = scanCommand(command);
335
+ if (scan.kind === "none") return {
336
+ ok: false,
337
+ reason: "不是可判斷的動作"
338
+ };
339
+ if (scan.kind === "refused") return {
340
+ ok: false,
341
+ reason: scan.reason
342
+ };
343
+ const where = scopedCwd(place.cwd, place.root, place.repos);
344
+ if (!where.ok) return {
345
+ ok: false,
346
+ reason: where.reason
347
+ };
348
+ const resolved = resolveFixed(scan.call, {
349
+ cwd: where.dir,
350
+ root: place.root,
351
+ repos: place.repos
352
+ });
353
+ return resolved.ok ? {
354
+ ok: true,
355
+ requests: [resolved.request],
356
+ text: command
357
+ } : resolved;
358
+ }
359
+ case "commit": {
360
+ const dir = existing(place, call.dir);
361
+ return {
362
+ ok: true,
363
+ requests: [{
364
+ action: "commit",
365
+ repos: repoAt(dir, place),
366
+ branch: currentBranch(dir),
367
+ dir
368
+ }],
369
+ text: command
370
+ };
371
+ }
372
+ case "push": return {
373
+ ok: true,
374
+ requests: [resolvePush(call, place)],
375
+ text: command
376
+ };
377
+ case "merge-pr": return {
378
+ ok: true,
379
+ requests: [resolveMerge(call, place)],
380
+ text: command
381
+ };
382
+ case "install-prompts": return {
383
+ ok: true,
384
+ requests: resolveInstall(call, place),
385
+ text: command
386
+ };
387
+ }
388
+ } catch (error) {
389
+ if (error instanceof NotJudged) return {
390
+ ok: false,
391
+ reason: error.message
392
+ };
393
+ throw error;
394
+ }
395
+ }
396
+ const defaultPrBase = ({ cwd, number, repo }) => {
397
+ const args = [
398
+ "pr",
399
+ "view",
400
+ ...number !== void 0 ? [number] : [],
401
+ ...repo !== void 0 ? ["-R", repo] : [],
402
+ "--json",
403
+ "baseRefName"
404
+ ];
405
+ const result = spawnSync("gh", args, {
406
+ cwd,
407
+ encoding: "utf8",
408
+ timeout: 2e4
409
+ });
410
+ if (result.status !== 0) return null;
411
+ try {
412
+ const base = JSON.parse(result.stdout).baseRefName;
413
+ return typeof base === "string" && base ? base : null;
414
+ } catch {
415
+ return null;
416
+ }
417
+ };
418
+ /**
419
+ * The units of promptex's dry run: it prints one JSON object per unit and platform, each naming its `unit`, and writes
420
+ * nothing. Running it evaluates the project's promptex config, as the install itself would.
421
+ */
422
+ const defaultListUnits = ({ command, args, cwd }) => {
423
+ const result = spawnSync(command, args, {
424
+ cwd,
425
+ encoding: "utf8",
426
+ timeout: 6e4,
427
+ maxBuffer: 67108864
428
+ });
429
+ if (result.status !== 0 || typeof result.stdout !== "string") return null;
430
+ const units = /* @__PURE__ */ new Set();
431
+ for (const line of result.stdout.split("\n")) {
432
+ if (!line.startsWith("{")) continue;
433
+ try {
434
+ const entry = JSON.parse(line);
435
+ if (entry.verb === "build" && typeof entry.unit === "string" && entry.unit) units.add(entry.unit);
436
+ } catch {
437
+ return null;
438
+ }
439
+ }
440
+ return units.size > 0 ? [...units] : null;
441
+ };
442
+ function compoundMark(command) {
443
+ return COMPOUND.find(([text]) => command.includes(text))?.[1] ?? null;
444
+ }
445
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
446
+ /** The mainflow session a host's session id stands for: the id itself when such a session exists, else Claude Code's. */
447
+ function sessionIdOf(dirs, raw) {
448
+ if (SESSION_ID.test(raw) && readSession(dirs, raw)) return raw;
449
+ return claudeCodeSessionId(raw);
450
+ }
451
+ /** The ops log's fields cannot hold its separator, so text that contains one is written with a broken bar. */
452
+ const opsText = (text) => text.replace(/\s\|\s/g, " ¦ ").replace(/\s+/g, " ").slice(0, 200);
453
+ function opsTarget(request) {
454
+ switch (request.action) {
455
+ case "publish": return `npm ${request.name}`;
456
+ case "release": return `github release ${request.repos.join("/")}`;
457
+ case "merge-pr": return `github pr ${request.repos.join("/")}`;
458
+ case "install-prompts": return `prompts ${request.repos.join("/") || "(unlisted project)"}`;
459
+ default: return `git ${request.repos.join("/")}`;
460
+ }
461
+ }
462
+ async function logGranted(ctx, dirs, scope, text, matches, summary) {
463
+ const first = matches[0];
464
+ try {
465
+ const written = await (ctx.write ?? hubWriter(dirs, ctx.env ?? process.env, { hook: true }))("ops.add", {
466
+ target: opsText(opsTarget(first.request)),
467
+ operation: opsText(text),
468
+ result: opsText(`授權放行 ${summary}`),
469
+ ref: scope.served[first.grant.owner]
470
+ });
471
+ for (const line of writtenLines(written)) if (line.startsWith("!")) ctx.warn?.(`mainflow grant check: ${line.slice(2)}`);
472
+ } catch (error) {
473
+ ctx.warn?.(`mainflow grant check: allowed by ${first.grant.ref} but could not write the ops log: ${error.message}`);
474
+ }
475
+ }
476
+ async function judge(input, ctx) {
477
+ if (!isObject(input)) return none("輸入不是 JSON 物件");
478
+ if (typeof input.tool !== "string" || input.tool.toLowerCase() !== "bash") return none("只判斷 Bash 呼叫");
479
+ const command = isObject(input.input) ? input.input.command : void 0;
480
+ if (typeof command !== "string") return none("input.command 不是字串");
481
+ const mark = compoundMark(command);
482
+ if (mark) return none(`指令含 ${mark},只判斷單一指令`);
483
+ const { dirs } = ctx;
484
+ if (!dirs) return none("不在 mainflow hub 裡,沒有授權可用");
485
+ const format = hubFormatVersion(nodeStorage(dirs));
486
+ const mismatch = format === null ? null : formatMismatch(format);
487
+ if (mismatch) return none(`hub 的資料格式不符:${mismatch}`);
488
+ if (typeof input.sessionId !== "string" || !input.sessionId) return none("沒有字串的 sessionId");
489
+ if (typeof input.cwd !== "string" || !isAbsolute(input.cwd)) return none("cwd 不是絕對路徑");
490
+ const cwd = realPath(input.cwd);
491
+ if (!cwd) return none(`目錄 ${quote(input.cwd)} 不存在`);
492
+ let call;
493
+ try {
494
+ call = readCall(command);
495
+ } catch (error) {
496
+ if (error instanceof NotJudged) return none(error.message);
497
+ throw error;
498
+ }
499
+ const scoped = grantScope(dirs, readSession(dirs, sessionIdOf(dirs, input.sessionId)));
500
+ if (!scoped.ok) return none(scoped.reason);
501
+ if (![...scoped.scope.grants, ...scoped.scope.outOfScope.map((o) => o.grant)].some((g) => g.grant.action === call.action)) return none(`${Object.keys(scoped.scope.served).join("、")} 沒有 ${call.action} 的授權`);
502
+ const config = readYaml(join(dirs.data, "config.yaml"), configSchema).data;
503
+ const place = {
504
+ root: realPath(dirs.root) ?? dirs.root,
505
+ repos: repoPaths(config.repos),
506
+ cwd,
507
+ env: ctx.env ?? process.env,
508
+ prBase: ctx.prBase ?? defaultPrBase,
509
+ listUnits: ctx.listUnits ?? defaultListUnits
510
+ };
511
+ const resolved = resolveCall(call, command, place);
512
+ if (!resolved.ok) return none(resolved.reason);
513
+ const match = matchGrants(scoped.scope, resolved.requests);
514
+ if (!match.ok) return none(match.reason);
515
+ const refs = [...new Set(match.matches.map((m) => m.grant.ref))];
516
+ const summary = `${refs.join("、")}(${match.matches.map((m) => describeRequest(m.request)).join("、")})`;
517
+ if (!ctx.dryRun) await logGranted(ctx, dirs, scoped.scope, resolved.text, match.matches, summary);
518
+ return {
519
+ decision: "allow",
520
+ reason: `授權 ${refs.join("、")} 涵蓋 ${match.matches.map((m) => describeRequest(m.request)).join("、")}`,
521
+ grant: refs[0],
522
+ ...refs.length > 1 ? { grants: refs } : {}
523
+ };
524
+ }
525
+ /** Judge one call; any failure is `none`, so a caller that cannot be told otherwise keeps its own decision. */
526
+ async function grantCheck(input, ctx) {
527
+ try {
528
+ return await judge(input, ctx);
529
+ } catch (error) {
530
+ return none(`判斷授權時出錯:${error.message}`);
531
+ }
532
+ }
533
+ /**
534
+ * The hub grant check judges against: the one of $CLAUDE_PROJECT_DIR, then of the call's cwd, then of the current
535
+ * directory, as the PreToolUse hook finds it, since a publish runs in a repository another hub may own.
536
+ */
537
+ function hubOf(dirs, env) {
538
+ for (const dir of dirs) {
539
+ if (!dir || !isAbsolute(dir)) continue;
540
+ try {
541
+ const hub = resolveHub(dir, {
542
+ env,
543
+ waitForLock: false
544
+ });
545
+ if (hub) return hub;
546
+ } catch {}
547
+ }
548
+ return null;
549
+ }
550
+ /** `mainflow grant check`: read the call from stdin, print the verdict as one JSON line, exit 0. */
551
+ async function runGrantCheck(cwd, dryRun) {
552
+ let text = "";
553
+ try {
554
+ text = readFileSync(0, "utf8");
555
+ } catch {}
556
+ const input = parsePayload(text);
557
+ const warnings = [];
558
+ const verdict = input ? await grantCheck(input, {
559
+ dirs: hubOf([
560
+ process.env.CLAUDE_PROJECT_DIR,
561
+ typeof input.cwd === "string" ? input.cwd : void 0,
562
+ cwd
563
+ ], process.env),
564
+ dryRun,
565
+ warn: (m) => warnings.push(m)
566
+ }) : none("標準輸入不是單一 JSON 物件");
567
+ for (const line of warnings) console.error(line);
568
+ console.log(JSON.stringify(verdict));
569
+ return 0;
570
+ }
571
+ //#endregion
572
+ export { grantCheck, runGrantCheck, shellWords };
@@ -1,7 +1,7 @@
1
- import "./index-BqPQp6TF.js";
1
+ import "./index-l8QUG80k.js";
2
2
  //#region ../schema/dist/grants.d.ts
3
3
  /** An operation about to run, resolved to what a grant is written against. */
4
- type GrantRequest = {
4
+ type GrantRequest = ({
5
5
  action: 'publish';
6
6
  name: string;
7
7
  version: string;
@@ -11,6 +11,24 @@ type GrantRequest = {
11
11
  action: 'release';
12
12
  repos: string[];
13
13
  tag: string;
14
+ } |
15
+ /** `branch` is the branch committed on, pushed to, or merged into; null on a detached HEAD */
16
+ {
17
+ action: 'commit' | 'push' | 'merge-pr';
18
+ repos: string[];
19
+ branch: string | null;
20
+ } |
21
+ /**
22
+ * One request per unit the install writes; `repos` holds the config.yaml names of the repository the promptex project
23
+ * is in, for the ops log, and may be empty
24
+ */
25
+ {
26
+ action: 'install-prompts';
27
+ unit: string;
28
+ repos: string[];
29
+ }) & {
30
+ /** The directory the call works in, symbolic links resolved; a grant limited to paths covers the request only when it lies in them */
31
+ dir?: string;
14
32
  };
15
33
  //#endregion
16
34
  //#region src/grant-commands.d.ts
@@ -38,6 +56,7 @@ export type Scan = {
38
56
  };
39
57
  /** A git tag a release may create: no path separators, no `..`. */
40
58
  export declare const TAG: RegExp;
59
+ export declare const OWNER_REPO: RegExp;
41
60
  /** Whether a Bash call is one this hook decides and, if so, whether it is in a fixed form. */
42
61
  export declare function scanCommand(command: string): Scan;
43
62
  export interface ResolveContext {
@@ -66,8 +85,17 @@ export declare function scopedCwd(cwd: unknown, root: string, repos: Record<stri
66
85
  ok: false;
67
86
  reason: string;
68
87
  };
88
+ /**
89
+ * Read a file only when it is a regular file of at most MAX_MANIFEST bytes, following symlinks. It is opened
90
+ * without blocking and checked on the open descriptor, so a FIFO or a device such as /dev/zero is refused rather
91
+ * than waited on, and the file checked is the file read.
92
+ */
93
+ export declare function readManifest(file: string): string | null;
69
94
  /** The origin remote of a checkout as github.com/owner/repo in lowercase, or null when it is not on github.com. */
70
95
  export declare function githubOrigin(dir: string): string | null;
71
- /** What a fixed-form call would publish or release; refused when the package or repository does not qualify. */
96
+ /**
97
+ * What a fixed-form call would publish or release, with the directory it runs in for grants limited to paths; refused
98
+ * when the package or repository does not qualify.
99
+ */
72
100
  export declare function resolveFixed(call: Fixed, ctx: ResolveContext): Resolved;
73
101
  //#endregion
@@ -1,4 +1,4 @@
1
- import { ut as GRANT_FORMS } from "./dist-D5rZ0MQH.js";
1
+ import { ut as GRANT_FORMS } from "./dist-CXrUfRQp.js";
2
2
  import { closeSync, constants, fstatSync, openSync, readFileSync, realpathSync } from "node:fs";
3
3
  import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { spawnSync } from "node:child_process";
@@ -237,9 +237,19 @@ function resolveRelease(call, ctx) {
237
237
  }
238
238
  };
239
239
  }
240
- /** What a fixed-form call would publish or release; refused when the package or repository does not qualify. */
240
+ /**
241
+ * What a fixed-form call would publish or release, with the directory it runs in for grants limited to paths; refused
242
+ * when the package or repository does not qualify.
243
+ */
241
244
  function resolveFixed(call, ctx) {
242
- return call.action === "publish" ? resolvePublish(ctx.cwd) : resolveRelease(call, ctx);
245
+ const resolved = call.action === "publish" ? resolvePublish(ctx.cwd) : resolveRelease(call, ctx);
246
+ return resolved.ok ? {
247
+ ok: true,
248
+ request: {
249
+ ...resolved.request,
250
+ dir: ctx.cwd
251
+ }
252
+ } : resolved;
243
253
  }
244
254
  //#endregion
245
- export { TAG, githubOrigin, quote, resolveFixed, scanCommand, scopedCwd };
255
+ export { OWNER_REPO, TAG, githubOrigin, quote, readManifest, resolveFixed, scanCommand, scopedCwd };
package/dist/hooks.d.ts CHANGED
@@ -1,21 +1,5 @@
1
- import { a as Renumbering, i as Notice, n as OperationValue, o as HubDirs, r as OperationName, t as OperationInput } from "./index-DRpamPge.js";
2
- //#region src/remote/operation.d.ts
3
- /** What a write reports: the operation's value, and what its caller tells the user about it. */
4
- interface Written<N extends OperationName = OperationName> {
5
- value: OperationValue<N>;
6
- /** Waiting in the outbox, not yet answered by the sync server; new entries in `value` have provisional ids */
7
- queued: boolean;
8
- /** Why nothing sends the outbox for now: no sync client runs and none could be started */
9
- stopped?: string;
10
- /** Why a sync client was started although one was expected to run */
11
- restarted?: string;
12
- /** Provisional ids the server gave to other entries, so this write's entries got the ids after the arrow */
13
- renumbered: Renumbering[];
14
- /** What the operation said about the write although it went through, such as a file it kept */
15
- notices: Notice[];
16
- }
17
- type HubWrite = <N extends OperationName>(op: N, input: OperationInput<N>) => Promise<Written<N>>;
18
- //#endregion
1
+ import { o as HubDirs } from "./index-jq7lN8V8.js";
2
+ import { t as HubWrite } from "./operation-BroV98VB.js";
19
3
  //#region src/hooks.d.ts
20
4
  /** Fields Claude Code sends on stdin to every command hook, plus the ones specific to the events handled here. */
21
5
  export interface ClaudeCodePayload {