co-maintainer 0.4.0-beta.2 → 0.4.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 CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Murat Kirazkaya
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Murat Kirazkaya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/package.json CHANGED
@@ -1,10 +1,25 @@
1
1
  {
2
2
  "name": "co-maintainer",
3
- "version": "0.4.0-beta.2",
3
+ "version": "0.4.0",
4
4
  "description": "Analyzes a GitHub repository and writes repository-specific contribution guidance.",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/GroophyLifefor/co-maintainer"
9
+ },
10
+ "homepage": "https://github.com/GroophyLifefor/co-maintainer#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/GroophyLifefor/co-maintainer/issues"
13
+ },
14
+ "keywords": [
15
+ "github",
16
+ "pull-request",
17
+ "code-review",
18
+ "maintainer",
19
+ "ai"
20
+ ],
6
21
  "bin": {
7
- "co-maintainer": "./dist/main.js"
22
+ "co-maintainer": "dist/main.js"
8
23
  },
9
24
  "files": [
10
25
  "dist/",
@@ -67,15 +67,15 @@ export function resolveWebhookUrl(args, port, configured) {
67
67
  // `new URL` is lenient: "http://http://host/:5000/x" parses with host
68
68
  // "http" and the real URL buried in the path, and "http:///x" treats "x"
69
69
  // as the host. GitHub needs a routable host and a real webhook path, so
70
- // reject a scheme leaking into the path, a bare host with no dot or port,
71
- // and a path that is just "/".
72
- const hasQualifiedHost = url.hostname === "localhost" ||
73
- url.hostname.includes(".") ||
74
- url.port !== "";
75
- if (url.pathname.startsWith("//") ||
70
+ // reject a leaked scheme, an empty authority, and a path that is just "/".
71
+ // A single-label host (an internal Docker/k8s name) or an IPv6 literal is
72
+ // legitimate, and both are indistinguishable from the "http:///x" spelling
73
+ // once parsed, so the empty authority is caught on the raw string instead.
74
+ const hasAuthority = /^https?:\/\/[^/?#]+/.test(raw.trim());
75
+ if (!hasAuthority ||
76
+ url.pathname.startsWith("//") ||
76
77
  url.pathname.includes("://") ||
77
- url.pathname === "/" ||
78
- !hasQualifiedHost) {
78
+ url.pathname === "/") {
79
79
  die("--webhook-url must be an absolute http(s) URL with a webhook path, " +
80
80
  "e.g. --webhook-url=https://example.com/github/webhook");
81
81
  }
@@ -9,6 +9,12 @@ async function ask(question) {
9
9
  try {
10
10
  return (await rl.question(question)).trim();
11
11
  }
12
+ catch {
13
+ // `question` rejects when stdin closes mid-prompt (Ctrl-D, a detached
14
+ // pipeline, a killed parent). Treat that as "no answer" so the caller's
15
+ // own fallback/required validation runs instead of an uncaught rejection.
16
+ return "";
17
+ }
12
18
  finally {
13
19
  rl.close();
14
20
  }
@@ -1,4 +1,4 @@
1
- import { parseArgs } from "./args.js";
1
+ import { parseArgs, setCliInteractive } from "./args.js";
2
2
  function die(message) {
3
3
  throw new Error(message);
4
4
  }
@@ -50,6 +50,10 @@ export function filterReviewConfigArgs(raw) {
50
50
  export async function parseReviewArgs(args) {
51
51
  const positional = args.filter((a) => !a.startsWith("--"));
52
52
  const flags = reviewFlags(args);
53
+ // `--json` must never prompt (plan §8.7). `parseArgs` only receives the
54
+ // filtered args, and `--json` is stripped before it sees them, so the flag
55
+ // has to be applied here rather than after the parse returns.
56
+ setCliInteractive(!flags.json);
53
57
  const isPr = positional.length >= 2 &&
54
58
  /^[^/]+\/[^/]+$/.test(positional[0]) &&
55
59
  /^\d+$/.test(positional[1]);
@@ -2,6 +2,11 @@ type Args = Record<string, unknown>;
2
2
  export declare function toolError(message: string): string;
3
3
  export declare function rejectUnknownKeys(args: Args, allowed: ReadonlySet<string>): string | null;
4
4
  export declare function rejectFlagLike(value: string, label: string): string | null;
5
+ /** `codegraph_exec.ts` routes through `cmd /c` on Windows (the CLI is a `.cmd`
6
+ * shim), and `cmd.exe` re-parses every argument, so a value carrying `&`, `|`,
7
+ * `>` or `%` can append a second command. None of them have a legitimate place
8
+ * in a search term, symbol name or repo-relative path, so refuse them. */
9
+ export declare function rejectShellMetacharacters(value: string, label: string): string | null;
5
10
  /** Repo-relative path only — no `..`, no absolute paths (plan E103). */
6
11
  export declare function rejectEscapingPath(file: string): string | null;
7
12
  export declare function guardToolArgs(args: Args, allowed: ReadonlySet<string>): string | null;
@@ -14,11 +14,24 @@ export function rejectFlagLike(value, label) {
14
14
  }
15
15
  return null;
16
16
  }
17
+ /** `codegraph_exec.ts` routes through `cmd /c` on Windows (the CLI is a `.cmd`
18
+ * shim), and `cmd.exe` re-parses every argument, so a value carrying `&`, `|`,
19
+ * `>` or `%` can append a second command. None of them have a legitimate place
20
+ * in a search term, symbol name or repo-relative path, so refuse them. */
21
+ export function rejectShellMetacharacters(value, label) {
22
+ if (/[&|<>^%\r\n]/.test(value)) {
23
+ return toolError(`${label} must not contain shell metacharacters`);
24
+ }
25
+ return null;
26
+ }
17
27
  /** Repo-relative path only — no `..`, no absolute paths (plan E103). */
18
28
  export function rejectEscapingPath(file) {
19
29
  const flag = rejectFlagLike(file, "file");
20
30
  if (flag)
21
31
  return flag;
32
+ const shell = rejectShellMetacharacters(file, "file");
33
+ if (shell)
34
+ return shell;
22
35
  const norm = file.replace(/\\/g, "/");
23
36
  if (norm.startsWith("/") ||
24
37
  norm.startsWith("//") ||
@@ -30,5 +43,15 @@ export function rejectEscapingPath(file) {
30
43
  return null;
31
44
  }
32
45
  export function guardToolArgs(args, allowed) {
33
- return rejectUnknownKeys(args, allowed);
46
+ const unknown = rejectUnknownKeys(args, allowed);
47
+ if (unknown)
48
+ return unknown;
49
+ for (const [key, value] of Object.entries(args)) {
50
+ if (typeof value !== "string")
51
+ continue;
52
+ const shell = rejectShellMetacharacters(value, key);
53
+ if (shell)
54
+ return shell;
55
+ }
56
+ return null;
34
57
  }
@@ -1,4 +1,10 @@
1
- /** Fixture directory hashing for plan §15.3. */
1
+ /** Fixture directory hashing for plan §15.3.
2
+ *
3
+ * The hash exists to catch schema/content drift in the wire fixtures, not
4
+ * line-ending drift, so `\r\n` is normalized to `\n` before hashing. Without
5
+ * that the digest depends on the checkout: `.gitattributes` keeps the repo at
6
+ * LF, but a Windows working tree with `autocrlf` hands the test CRLF and a
7
+ * different digest. */
2
8
  import { readDir, readFile } from "../util/runtime.js";
3
9
  export async function hashRemoteFixtures(version) {
4
10
  const dir = new URL(`./fixtures/v${version}/`, import.meta.url);
@@ -11,9 +17,11 @@ export async function hashRemoteFixtures(version) {
11
17
  names.sort();
12
18
  const chunks = [];
13
19
  const enc = new TextEncoder();
20
+ const dec = new TextDecoder();
14
21
  for (const name of names) {
15
22
  chunks.push(enc.encode(`${name}\n`));
16
- chunks.push(await readFile(new URL(name, dir)));
23
+ const raw = dec.decode(await readFile(new URL(name, dir)));
24
+ chunks.push(enc.encode(raw.replace(/\r\n/g, "\n")));
17
25
  chunks.push(enc.encode("\n"));
18
26
  }
19
27
  const total = chunks.reduce((n, c) => n + c.length, 0);
@@ -6,7 +6,8 @@ export declare const MIN_SERVER_SCHEMA = 1;
6
6
  export declare const REMOTE_CLI_UPGRADE_COMMAND = "npm install -g co-maintainer@latest";
7
7
  export declare const REMOTE_MAX_BODY_BYTES = 52428800;
8
8
  export declare const REMOTE_SYNC_INTERVAL_SECONDS = 3;
9
- /** SHA-256 of sorted `fixtures/v<N>/*.json` (name + contents); bump with schema version. */
9
+ /** SHA-256 of sorted `fixtures/v<N>/*.json` (name + contents, `\r\n` normalized
10
+ * to `\n`); bump with schema version. */
10
11
  export declare const REMOTE_FIXTURE_HASH: Record<number, string>;
11
12
  export declare const REMOTE_SYNC_STATUSES: readonly ["queued", "running", "done", "failed", "canceled"];
12
13
  export type RemoteSyncStatus = (typeof REMOTE_SYNC_STATUSES)[number];
@@ -6,9 +6,10 @@ export const MIN_SERVER_SCHEMA = 1;
6
6
  export const REMOTE_CLI_UPGRADE_COMMAND = "npm install -g co-maintainer@latest";
7
7
  export const REMOTE_MAX_BODY_BYTES = 52_428_800;
8
8
  export const REMOTE_SYNC_INTERVAL_SECONDS = 3;
9
- /** SHA-256 of sorted `fixtures/v<N>/*.json` (name + contents); bump with schema version. */
9
+ /** SHA-256 of sorted `fixtures/v<N>/*.json` (name + contents, `\r\n` normalized
10
+ * to `\n`); bump with schema version. */
10
11
  export const REMOTE_FIXTURE_HASH = {
11
- 1: "1cbb11016227fcc7fc58b692ed5a2b72dc9b0e23b4cf68858e2d7facc98efd79",
12
+ 1: "71e4e5f8637eab4b53d47817a688812a517310d967abe26aa0d7f4e7d7d65092",
12
13
  };
13
14
  export const REMOTE_SYNC_STATUSES = [
14
15
  "queued",
@@ -20,7 +20,16 @@ import { getRepo } from "../../store/repos.js";
20
20
  import { getJob } from "../../store/jobs.js";
21
21
  import { getLogsSince } from "../../services/jobs.js";
22
22
  import { listInstallationsWithRepos } from "../../github/app.js";
23
- const REPO = /^\/repos\/([^/]+)\/([^/]+)(?:\/(pulls|knowledge|settings)(?:\/(\d+))?)?$/;
23
+ const REPO = /^\/repos\/([^/]+)\/([^/]+)(?:\/(pulls|knowledge|settings|remote)(?:\/(\d+))?)?$/;
24
+ /** Matches a repository page URL. Only `pulls` is keyed by a number, so a stray
25
+ * suffix such as `/repos/o/r/remote/7` must fall through to a 404 rather than
26
+ * render the listing and silently drop the number. */
27
+ function matchRepo(pathname) {
28
+ const match = REPO.exec(pathname);
29
+ if (match && match[4] && match[3] !== "pulls")
30
+ return null;
31
+ return match;
32
+ }
24
33
  export async function handlePageRequest(request, deps, ip) {
25
34
  const url = new URL(request.url);
26
35
  if (url.pathname === "/styles.css" && request.method === "GET") {
@@ -126,7 +135,7 @@ export async function handlePageRequest(request, deps, ip) {
126
135
  const config = readConfig();
127
136
  return renderSettings(username, config, config.webhookUrl || deps.webhookUrl || "");
128
137
  }
129
- const match = REPO.exec(url.pathname);
138
+ const match = matchRepo(url.pathname);
130
139
  if (match && request.method === "GET") {
131
140
  const fullName = `${decodeURIComponent(match[1])}/${decodeURIComponent(match[2])}`;
132
141
  const row = requireRepo(fullName);
@@ -2,9 +2,10 @@
2
2
  * surface the store layer was written against: a synchronous `Database`,
3
3
  * `prepare<T>()` generics, `.get()/.all()/.run()`, and `db.changes`.
4
4
  *
5
- * Two behaviours of the JSR driver must be reproduced on top of
5
+ * Three behaviours of the JSR driver must be reproduced on top of
6
6
  * `node:sqlite`:
7
7
  * - `undefined` parameters bind as SQL NULL (node:sqlite rejects undefined)
8
+ * - `boolean` parameters bind as 0/1 (node:sqlite rejects booleans)
8
9
  * - `db.changes` reports the rows modified by the most recent `run()`, which
9
10
  * `node:sqlite` only returns from the statement call
10
11
  */
@@ -2,9 +2,10 @@
2
2
  * surface the store layer was written against: a synchronous `Database`,
3
3
  * `prepare<T>()` generics, `.get()/.all()/.run()`, and `db.changes`.
4
4
  *
5
- * Two behaviours of the JSR driver must be reproduced on top of
5
+ * Three behaviours of the JSR driver must be reproduced on top of
6
6
  * `node:sqlite`:
7
7
  * - `undefined` parameters bind as SQL NULL (node:sqlite rejects undefined)
8
+ * - `boolean` parameters bind as 0/1 (node:sqlite rejects booleans)
8
9
  * - `db.changes` reports the rows modified by the most recent `run()`, which
9
10
  * `node:sqlite` only returns from the statement call
10
11
  */
@@ -32,11 +33,25 @@ export class Statement {
32
33
  }
33
34
  }
34
35
  /** Normalizes call arguments: a single object binds by name, everything else
35
- * is a positional list with `undefined` mapped to NULL. */
36
+ * is a positional list. Values `node:sqlite` refuses are mapped to what it
37
+ * accepts — `undefined` to NULL, booleans to 0/1 — on both paths, so a field
38
+ * typed `boolean | undefined` survives a named bind too. */
36
39
  function args(params) {
37
- if (params.length === 1 && isBindObject(params[0]))
38
- return [params[0]];
39
- return params.map((value) => value === undefined ? null : value);
40
+ if (params.length === 1 && isBindObject(params[0])) {
41
+ const bound = {};
42
+ for (const [key, value] of Object.entries(params[0])) {
43
+ bound[key] = normalize(value);
44
+ }
45
+ return [bound];
46
+ }
47
+ return params.map((value) => normalize(value));
48
+ }
49
+ function normalize(value) {
50
+ if (value === undefined)
51
+ return null;
52
+ if (typeof value === "boolean")
53
+ return value ? 1 : 0;
54
+ return value;
40
55
  }
41
56
  function isBindObject(value) {
42
57
  return (typeof value === "object" &&
@@ -15,10 +15,13 @@ export type EnsureOptions = {
15
15
  allowInstall?: boolean;
16
16
  interactive?: boolean;
17
17
  run?: Runner;
18
- confirm?: (question: string) => boolean;
18
+ /** `node:readline/promises` made this async; both shapes are accepted. */
19
+ confirm?: (question: string) => boolean | Promise<boolean>;
19
20
  log?: (message: string) => void;
20
21
  /** Overrides `toolsDir()`, for tests. */
21
22
  root?: string;
23
+ /** Overrides `process.exit`, for tests. */
24
+ exit?: (code: number) => never;
22
25
  };
23
26
  export declare function runCommand(command: string, args: string[]): Promise<CommandResult>;
24
27
  /** One version per directory, so several can sit side by side and an upgrade
@@ -85,6 +85,9 @@ export async function ensureCodegraph(options = {}) {
85
85
  const run = options.run ?? runCommand;
86
86
  const log = options.log ?? ((message) => console.log(message));
87
87
  const interactive = options.interactive ?? process.stdin.isTTY === true;
88
+ // Annotated so TypeScript can see the calls below never return and keeps the
89
+ // `Presence` narrowing intact.
90
+ const exit = options.exit ?? process.exit;
88
91
  const present = await detect(CODEGRAPH_VERSION, root, run);
89
92
  if (present.state === "ok")
90
93
  return present.path;
@@ -96,14 +99,16 @@ export async function ensureCodegraph(options = {}) {
96
99
  if (!interactive) {
97
100
  log(`[codegraph] co-maintainer needs codegraph ${CODEGRAPH_VERSION} to index the repository.\n` +
98
101
  `Run it with --allow-tool-install, or install it yourself:\n ${installHint(CODEGRAPH_VERSION, root)}`);
99
- process.exit(1);
102
+ exit(1);
100
103
  }
101
104
  const confirm = options.confirm ?? defaultConfirm;
102
- const approved = confirm(`co-maintainer needs codegraph ${CODEGRAPH_VERSION} to index the repository.\n` +
105
+ // `defaultConfirm` is async, so an unawaited call is always truthy and the
106
+ // user's "no" was silently ignored. Await it before deciding.
107
+ const approved = await confirm(`co-maintainer needs codegraph ${CODEGRAPH_VERSION} to index the repository.\n` +
103
108
  `Install it into ${versionDir(CODEGRAPH_VERSION, root)} (your global PATH is not touched)?`);
104
109
  if (!approved) {
105
110
  log("[codegraph] declined; nothing was installed");
106
- process.exit(1);
111
+ exit(1);
107
112
  }
108
113
  }
109
114
  const { command, args } = installCommand(CODEGRAPH_VERSION, root);
@@ -112,12 +117,12 @@ export async function ensureCodegraph(options = {}) {
112
117
  const result = await run(command, args);
113
118
  if (result.code !== 0) {
114
119
  log(`[codegraph] install failed (exit ${result.code}): ${result.stderr.trim() || result.stdout.trim()}`);
115
- process.exit(1);
120
+ exit(1);
116
121
  }
117
122
  const after = await detect(CODEGRAPH_VERSION, root, run);
118
123
  if (after.state !== "ok") {
119
124
  log(`[codegraph] install finished but ${binaryPath(CODEGRAPH_VERSION, root)} is still not usable`);
120
- process.exit(1);
125
+ exit(1);
121
126
  }
122
127
  log(`[codegraph] ready at ${after.path}`);
123
128
  return after.path;
@@ -141,7 +146,8 @@ export async function ensureCodegraphForReview(options = {}) {
141
146
  };
142
147
  }
143
148
  const confirm = options.confirm ?? defaultConfirm;
144
- const approved = confirm(`co-maintainer needs codegraph ${CODEGRAPH_VERSION} to index this repository.\n` +
149
+ // Same as `ensureCodegraph`: an unawaited async confirm is always truthy.
150
+ const approved = await confirm(`co-maintainer needs codegraph ${CODEGRAPH_VERSION} to index this repository.\n` +
145
151
  `Install into ${versionDir(CODEGRAPH_VERSION, root)}?`);
146
152
  if (!approved)
147
153
  return { reason: "codegraph install declined" };
@@ -7,7 +7,13 @@ export type ExecOptions = {
7
7
  env?: Record<string, string>;
8
8
  };
9
9
  export type CodegraphRunner = (binary: string, args: string[], worktree: string) => Promise<CommandResult>;
10
- /** Run the codegraph CLI in a repo root (plan §13.4). No `cmd /c` wrapper. */
10
+ /** Run the codegraph CLI in a repo root (plan §13.4).
11
+ *
12
+ * On Windows the CLI is a `.cmd`/`.ps1` shim, and `node:child_process.spawn`
13
+ * cannot execute those without a shell — it fails with `EINVAL`. `Deno.Command`
14
+ * resolved them natively, so this wrapper is needed only after the Node move.
15
+ * Route through `cmd /c` exactly like `pr/checkout.ts` does for `git`.
16
+ */
11
17
  export declare function execCodegraph(binary: string, args: string[], cwd: string, options?: ExecOptions): Promise<CommandResult>;
12
18
  export declare function createCodegraphRunner(indexDir: string): CodegraphRunner;
13
19
  export declare function runCodegraphTool(binary: string, args: string[], worktree: string, runner: CodegraphRunner): Promise<string>;
@@ -1,9 +1,15 @@
1
- import { commandOutput, commandSpawn, envToObject, } from "../util/runtime.js";
1
+ import { commandOutput, commandSpawn, envToObject, isWindows, } from "../util/runtime.js";
2
2
  export const LOCAL_CODEGRAPH_DIR = ".co-maintainer-codegraph";
3
3
  export const SERVER_CODEGRAPH_DIR = ".codegraph";
4
4
  const TOOL_TIMEOUT_MS = 60_000;
5
5
  const INDEX_COMMANDS = new Set(["init", "sync", "index", "unlock"]);
6
- /** Run the codegraph CLI in a repo root (plan §13.4). No `cmd /c` wrapper. */
6
+ /** Run the codegraph CLI in a repo root (plan §13.4).
7
+ *
8
+ * On Windows the CLI is a `.cmd`/`.ps1` shim, and `node:child_process.spawn`
9
+ * cannot execute those without a shell — it fails with `EINVAL`. `Deno.Command`
10
+ * resolved them natively, so this wrapper is needed only after the Node move.
11
+ * Route through `cmd /c` exactly like `pr/checkout.ts` does for `git`.
12
+ */
7
13
  export async function execCodegraph(binary, args, cwd, options = {}) {
8
14
  const indexDir = options.codegraphDir ?? LOCAL_CODEGRAPH_DIR;
9
15
  const env = {
@@ -12,18 +18,20 @@ export async function execCodegraph(binary, args, cwd, options = {}) {
12
18
  ...options.env,
13
19
  };
14
20
  const timeoutMs = options.timeoutMs === undefined ? TOOL_TIMEOUT_MS : options.timeoutMs;
21
+ const windows = isWindows();
15
22
  const commandOptions = {
16
- args,
23
+ args: windows ? ["/c", binary, ...args] : args,
17
24
  cwd,
18
25
  env,
19
26
  stdout: "piped",
20
27
  stderr: "piped",
21
28
  };
29
+ const target = windows ? "cmd" : binary;
22
30
  if (timeoutMs === null) {
23
- const output = await commandOutput(binary, commandOptions);
31
+ const output = await commandOutput(target, commandOptions);
24
32
  return decode(output);
25
33
  }
26
- const proc = commandSpawn(binary, commandOptions);
34
+ const proc = commandSpawn(target, commandOptions);
27
35
  let timedOut = false;
28
36
  const timer = setTimeout(() => {
29
37
  timedOut = true;
@@ -40,7 +40,11 @@ export declare function makeTempDir(options?: {
40
40
  export declare function makeTempDirSync(options?: {
41
41
  prefix?: string;
42
42
  }): string;
43
- /** A uniquely named temp file (created empty), mirroring `Deno.makeTempFile`. */
43
+ /** A uniquely named temp file (created empty), mirroring `Deno.makeTempFile`.
44
+ *
45
+ * `wx` fails on collision instead of truncating an existing file, so the retry
46
+ * loop preserves `mkdtemp`'s uniqueness without leaving a directory behind —
47
+ * callers only ever delete the returned file. */
44
48
  export declare function makeTempFile(options?: {
45
49
  prefix?: string;
46
50
  suffix?: string;
@@ -87,4 +91,13 @@ export declare function commandSpawn(command: string, options?: CommandOptions):
87
91
  * POSIX: signal 0 checks existence without delivering; `ESRCH` means dead,
88
92
  * while `EPERM` (or anything else) means it exists but is not ours — assume
89
93
  * alive rather than risk two writers on the same database. */
94
+ /** Decides Windows PID liveness from a `tasklist` invocation. A matching row is
95
+ * CSV and starts with the quoted image name; the "no tasks" notice is prose, so
96
+ * the distinction is locale-independent. A spawn failure or an empty body is
97
+ * indeterminate and reports alive: refusing to start beats letting a second
98
+ * writer take over a live lock. */
99
+ export declare function livenessFromTasklist(result: {
100
+ error?: unknown;
101
+ stdout?: string | null;
102
+ }): boolean;
90
103
  export declare function isProcessAlive(pid: number): boolean;
@@ -102,13 +102,27 @@ export function makeTempDir(options) {
102
102
  export function makeTempDirSync(options) {
103
103
  return fsMkdtempSync(join(tmpdir(), options?.prefix ?? "cm-"));
104
104
  }
105
- /** A uniquely named temp file (created empty), mirroring `Deno.makeTempFile`. */
105
+ /** A uniquely named temp file (created empty), mirroring `Deno.makeTempFile`.
106
+ *
107
+ * `wx` fails on collision instead of truncating an existing file, so the retry
108
+ * loop preserves `mkdtemp`'s uniqueness without leaving a directory behind —
109
+ * callers only ever delete the returned file. */
106
110
  export async function makeTempFile(options) {
107
- const dir = await fsMkdtemp(join(tmpdir(), options?.prefix ?? "cm-"));
108
- const name = `${crypto.randomUUID()}${options?.suffix ?? ""}`;
109
- const path = join(dir, name);
110
- await fsWriteFile(path, "");
111
- return path;
111
+ const prefix = options?.prefix ?? "cm-";
112
+ const suffix = options?.suffix ?? "";
113
+ for (let attempt = 0; attempt < 10; attempt++) {
114
+ const path = join(tmpdir(), `${prefix}${crypto.randomUUID()}${suffix}`);
115
+ try {
116
+ await fsWriteFile(path, "", { flag: "wx" });
117
+ return path;
118
+ }
119
+ catch (error) {
120
+ if (error.code === "EEXIST")
121
+ continue;
122
+ throw error;
123
+ }
124
+ }
125
+ throw new Error(`could not create a temp file with prefix ${prefix}`);
112
126
  }
113
127
  export async function* readDir(dir) {
114
128
  for (const entry of await readdir(dir, { withFileTypes: true })) {
@@ -189,12 +203,25 @@ export function commandSpawn(command, options = {}) {
189
203
  * POSIX: signal 0 checks existence without delivering; `ESRCH` means dead,
190
204
  * while `EPERM` (or anything else) means it exists but is not ours — assume
191
205
  * alive rather than risk two writers on the same database. */
206
+ /** Decides Windows PID liveness from a `tasklist` invocation. A matching row is
207
+ * CSV and starts with the quoted image name; the "no tasks" notice is prose, so
208
+ * the distinction is locale-independent. A spawn failure or an empty body is
209
+ * indeterminate and reports alive: refusing to start beats letting a second
210
+ * writer take over a live lock. */
211
+ export function livenessFromTasklist(result) {
212
+ if (result.error)
213
+ return true;
214
+ const out = (result.stdout ?? "").trim();
215
+ if (out === "")
216
+ return true;
217
+ return out.startsWith('"');
218
+ }
192
219
  export function isProcessAlive(pid) {
193
220
  if (isWindows()) {
194
221
  const result = spawnSync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], {
195
222
  encoding: "utf8",
196
223
  });
197
- return (result.stdout ?? "").trim().startsWith('"');
224
+ return livenessFromTasklist(result);
198
225
  }
199
226
  try {
200
227
  process.kill(pid, 0);
package/package.json CHANGED
@@ -1,10 +1,25 @@
1
1
  {
2
2
  "name": "co-maintainer",
3
- "version": "0.4.0-beta.2",
3
+ "version": "0.4.0",
4
4
  "description": "Analyzes a GitHub repository and writes repository-specific contribution guidance.",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/GroophyLifefor/co-maintainer"
9
+ },
10
+ "homepage": "https://github.com/GroophyLifefor/co-maintainer#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/GroophyLifefor/co-maintainer/issues"
13
+ },
14
+ "keywords": [
15
+ "github",
16
+ "pull-request",
17
+ "code-review",
18
+ "maintainer",
19
+ "ai"
20
+ ],
6
21
  "bin": {
7
- "co-maintainer": "./dist/main.js"
22
+ "co-maintainer": "dist/main.js"
8
23
  },
9
24
  "files": [
10
25
  "dist/",