24 Commits

Author SHA1 Message Date
Danny Kim 0ee3ac7687 Update the root-only test count in the note description
Check / check (pull_request) Successful in 47s
2026-10-18 03:05:37 +09:00
Danny Kim 4a88458a8f Stop installing Deno in the action 2026-10-18 03:05:36 +09:00
Danny Kim 7d25b759fb Spawn child processes directly and drop the Deno.Command shim 2026-10-18 03:05:36 +09:00
Danny Kim 6ad859be62 Set the agreed tsconfig strictness flags and drop exactOptionalPropertyTypes
Check / check (pull_request) Successful in 44s
2026-10-18 03:05:15 +09:00
Danny Kim 8b5c249248 Note the planned modules plugin and fix a sentence case in the alias notes
Check / check (pull_request) Successful in 38s
2026-10-17 23:14:27 +09:00
Danny Kim 5af53b0882 Wrap the long doc comment lines in the project lint plugin 2026-10-17 23:14:27 +09:00
Danny Kim eba9b9c57c Keep main's one-line guards where no rule needs braces
Check / check (pull_request) Successful in 37s
2026-10-17 18:52:49 +09:00
Danny Kim 3cbe4171ea Describe the Deno size limits in the notes 2026-10-17 18:50:24 +09:00
Danny Kim cd1864e1a9 Restore helper layout in auth, isolation, runtime, and profiles from main 2026-10-17 18:49:28 +09:00
Danny Kim 0c5059e3b4 Restore post.ts from main 2026-10-17 18:48:15 +09:00
Danny Kim c6fc183ad6 Restore run.ts from main 2026-10-17 18:46:52 +09:00
Danny Kim 1f54518320 Match Deno size limits 2026-10-17 18:46:52 +09:00
Danny Kim 999a089374 Prune notes that don't apply to bot-agents and keep aliases on direct src subfolders
Check / check (pull_request) Successful in 38s
2026-10-17 18:12:47 +09:00
Danny Kim 166476dee9 State alias rule v2 in AGENTS.md
Check / check (pull_request) Successful in 37s
2026-10-17 16:02:20 +09:00
Danny Kim c778c00068 Allow .mts and .mjs imports in no-asset-imports
Check / check (pull_request) Successful in 42s
2026-10-17 15:59:45 +09:00
Danny Kim 6b87ff66b9 Fix the quoted old rule name in the alias notes
Check / check (pull_request) Successful in 2m30s
2026-10-17 15:46:05 +09:00
Danny Kim d2d3cbffdb Switch to the renamed @cunarist/eslint-plugin-modules
Check / check (pull_request) Successful in 2m10s
2026-10-17 15:43:47 +09:00
Danny Kim bfa8269801 Route tests through a #testing alias, ban ../ imports, and document the alias ownership rule
Check / check (pull_request) Successful in 46s
2026-10-17 14:45:52 +09:00
Danny Kim 15fe481181 Prune migration notes and align the notes with the current lint setup
Check / check (pull_request) Successful in 39s
2026-10-16 23:41:04 +09:00
Danny Kim 662e946fad Drop the scripts lint override and accept Oxfmt's standard package.json order 2026-10-16 23:41:04 +09:00
Danny Kim ffb7cb9a75 Rename no-non-ts-imports to no-asset-imports, allow .js subpaths, and compare node types by enum member 2026-10-16 23:41:04 +09:00
Danny Kim 2e09945a6a Document that scripts is flat with no subfolders or index files
Check / check (pull_request) Successful in 39s
2026-10-16 23:19:03 +09:00
Danny Kim 934a3cfd03 Remove Deno lint references from the project lint plugin comments
Check / check (pull_request) Successful in 38s
2026-10-16 23:15:37 +09:00
Danny Kim b170f25c7d Document that package.json aliases may target only direct subfolders of src
Agents / agent (pull_request) Has been skipped
Check / check (pull_request) Successful in 1m18s
2026-10-16 22:08:23 +09:00
57 changed files with 1151 additions and 1759 deletions
-36
View File
@@ -1,36 +0,0 @@
---
description: Containerfiles build the frontend in a node:24-alpine stage with npm ci and vp build; CI uses setup-vp and runs vp check, vp build, vp test (plus lit-check)
---
## Containerfiles
- The frontend build stage is `docker.io/library/node:24-alpine`, replacing
`denoland/deno:alpine`. Vite+ publishes `linux-x64-musl` binaries, so Alpine
works. (`ghcr.io/voidzero-dev/vite-plus` is a glibc alternative that would
need a pinned digest; not used.)
- The stage copies `package.json` and `package-lock.json` first, runs
`npm ci`, then copies the sources and configs and runs `npx vp build`.
- The runtime stage copies the built `js/` and `css/` directories (not single
files) from the node stage.
- No Deno binary, `deno install`, or `deno task` remains in any stage.
```dockerfile
FROM docker.io/library/node:24-alpine AS frontend
WORKDIR /src/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npx vp build
```
## CI
- GitHub repos (temeddix): `.github/workflows`. Capsulizers repos (Gitea):
`.gitea/workflows`. Don't move a repo's CI between hosts.
- Install with `voidzero-dev/setup-vp`, then run `vp check`, `vp build`, and
`vp test`; Lit repos also run `npm run lit-check`
([[lit-analyzer-ts6]]).
- Repos that ran CI on a self-hosted runner keep it.
- Until `@cunarist/eslint-plugin-*` are on npm, `npm ci` fails in CI. That is
expected and stated in the PR; run `npm install` once they are published
and commit the refreshed lockfile.
-20
View File
@@ -1,20 +0,0 @@
---
description: Code that must ship as standalone JS (memona framed_find.js, brain stream-recovery.js) is written as .ts, compiled at build time, and its output gitignored
---
Some apps ship plain JavaScript that runs outside the main bundle: memona's
`framed_find.js` and brain's `stream-recovery.js`. Under the TS-only rule
([[ts-only-files-and-imports]]) they are handled like this (2026-10-16):
- The source is a `.ts` file, linted and type-checked like everything else.
- The `.js` is produced at build time by the existing build (a Vite input or
a build step in `vite.config.ts` or `scripts/`), never committed.
- The output path is gitignored and, if it lands in a linted folder, listed
in `lint.ignorePatterns`; never in `overrides`, never with disable comments.
- Whatever loads the file (Rust `include_str!`, a container `COPY`, an HTML
`<script>`) keeps the same path and name as before, so behavior is
unchanged. Builds that need the file must run the compile first; check
that the Containerfile and CI order the steps that way.
Don't commit a hand-maintained `.js` copy "for convenience", and don't turn
it into `.mjs` or `.cjs`.
@@ -1,41 +0,0 @@
---
description: Apps use the npm lint plugins' configs, port their old deno-lint-plugin.ts rules into scripts/project-lint.ts, never override source files, and refactor instead of raising limits
---
## Plugins
Apps consume the published plugins as npm devDependencies, through `jsPlugins`
and the configs equivalent to what their Deno setup enabled:
- `@cunarist/eslint-plugin-explicit-types`: `concrete`, `naming`, `absence`
- `@cunarist/eslint-plugin-import-check`: `import-check`
- `@cunarist/eslint-plugin-lit`: `core`, `strict`, `dom-ref`,
`reactive-controller`, `naming`
## Repo-local rules
The local `scripts/deno-lint-plugin.ts` that home, eximium, stockvibe, brain,
memona, and memona-home carried is ported into `scripts/project-lint.ts`
([[project-lint-plugin]]) under the `project` namespace, same rule names and
behavior. Depending on the repo: `no-self-sync`, `no-sync-in-render`,
`app-module-conventions`, `always-wildcard-export`, `no-class-extends`,
`no-lit-nothing`, `no-title-attribute`, `prefer-wa-components`,
`no-satisfies`, `no-unknown`, `restricted-types`, `no-line-lint-ignore`,
`no-app-disposables-in-component`, `no-crepe-react-ui`. `@hugoalh` rules map to
Oxlint built-ins in `configs.project`.
memona's `deno-lint-plugin.ts` and its `coding-standards` memory are the house
style the explicit-types rules came from; they win any conflict.
## No overrides, refactor instead
Apps use **no `overrides` for source files at all** (the rule-registry
exception exists only in plugin repos). Oxlint's default limits stay, and
violations are refactored without behavior change in the same PR: barrels for
`max-dependencies`, splits for `max-lines`/`max-lines-per-function`/
`max-classes-per-file`, an options object for `max-params`. Long Lit
`render()` templates are split into smaller template functions or
components. See [[lint-overrides-policy]].
If a hand-written exception still looks truly necessary, ask the user first;
when they're away, leave it failing or refactor, and log the question.
-43
View File
@@ -1,43 +0,0 @@
---
description: Frontends build with vp build in Vite app mode, keeping the exact output paths the Rust backend and HTML expect; emptyOutDir false when outDir holds committed files
---
`deno bundle` was replaced by `vp build` in **Vite app mode** (not
`build.lib`). Behavior must not change, so the output keeps the file names and
locations the backend serves and the HTML references.
Typical shape for the Rust-backed frontends (home, eximium, stockvibe,
memona-home), where `frontend/` builds into `backend/static`:
```ts
build: {
outDir: "../backend/static",
emptyOutDir: false, // the folder holds committed files (index.html, ...)
rollupOptions: {
input: { bundle: "src/index.ts" },
output: { entryFileNames: "js/[name].js" }, // -> js/bundle.js
},
},
```
- **Fixed names, no hashes:** set `entryFileNames` (and `assetFileNames`
where assets are emitted) explicitly. Vite's hashed defaults would 404,
since Rust and the templates request `js/bundle.js` and `css/theme.css`.
- **`emptyOutDir: false` whenever `outDir` is outside the Vite root or holds
committed files.** Never let it default to true there: it would wipe them.
- Output stays gitignored, so `.gitignore`, `.containerignore`, and the Rust
side need no edits; home's startup check for `js/bundle.js` and
`css/theme.css` keeps passing.
- **CSS is never imported from TS** (no `import "./theme.css"`); the HTML
links the stylesheet. How the CSS file is produced is per repo; read the
repo's `vite.config.ts` before changing it.
- Copy whole `js/` and `css/` directories in Containerfiles, not single files,
so code-split chunks ship too ([[app-containers-and-ci]]).
- Where a repo serves an `index.html` entry (brain's dashboard), use plain app
mode with `index.html` as input, `base` set to the mount path, and `outDir:
"dist"` with the default `emptyOutDir`.
- `vp dev` must work too, and the decorators plugin must cover build, dev, and
test ([[decorators-standard-accessor]]).
Check the repo's own migration PR for its final values before editing; these
are the conventions it followed, not a config to paste.
@@ -0,0 +1,39 @@
---
description: Child processes use plain node:child_process spawn (explicit stdio, uid/gid, whole env); src/child.ts has exitCode (128+signo) and echo; no execFile maxBuffer
---
Every call site calls `spawn` from `node:child_process` itself, with
everything explicit:
- **`uid`/`gid`** of the profile for anything an agent profile runs (each
profile is its own UID, 20000+). Never drop them.
- **`env`** is the whole environment the child gets (a sanitized one for
agents); nothing from the coordinator is merged in.
- **`stdio`**: stdin is `"ignore"` so an agent CLI never waits on input
(only `codex login --with-api-key` pipes the token in). Output that is not
read is `"ignore"`; output that is read is `"pipe"` and streamed.
- No `execFile`/`exec`: their `maxBuffer` (1 MiB) kills a chatty child. Read
piped streams incrementally instead.
`src/child.ts` holds what the call sites share:
- **`exitCode(child)`**: call it right after `spawn`. It settles on `exit`
(not `close`, which a daemonized grandchild can hold open), maps a signal to
`128 + signo` as a shell does, and rejects on a failed start. The rejection
is pre-caught so it is not reported as unhandled before the caller awaits it.
- **`echo(stream, chunk)`**: writes and waits for `drain` when the buffer is
full, so echoing never outruns a slow reader.
Tests use `spawnSync` for short commands.
Other port details:
- **`src/errors.ts`** `isNotFound` checks `ENOENT` (file) or `ESRCH` (process).
- Temporary files are an exclusive `wx` create with mode `0600`.
- `src/json.ts` holds type guards used instead of `as` casts on parsed JSON.
- `process` and `Buffer` must come from `node:` imports:
`no-restricted-globals` in `scripts/project-lint.ts` enforces it
([[lint-parity-never-shrinks]]).
Related: how each agent CLI reports bad credentials is in
[[agent-cli-auth-failures]].
+13 -12
View File
@@ -1,20 +1,21 @@
--- ---
description: bot-agents lint has prefer-readonly-parameter-types off (Node/web types can't satisfy it) and one scripts/** override for no-unsafe-enum-comparison; nothing else description: bot-agents lint has prefer-readonly-parameter-types and the size/complexity rules off (Deno had no limits) and no overrides; nothing else
--- ---
The repo follows [[lint-config-shape]] with two recorded exceptions, both The repo follows [[lint-config-shape]] with one recorded exception, present
present when the migration PR was approved: when the migration PR was approved:
- `typescript/prefer-readonly-parameter-types` is `"off"` in - `typescript/prefer-readonly-parameter-types` is `"off"` in
`scripts/project-lint.ts`, as in the explicit-types template, because `scripts/project-lint.ts`, as in the explicit-types template, because
Node/web types (`Response`, `Stats`, `RequestInit`) can never satisfy it. Node/web types (`Response`, `Stats`, `RequestInit`) can never satisfy it.
- `vite.config.ts` has one `overrides` entry, for `scripts/**` only: - `vite.config.ts` has no `overrides`. The `scripts/**` override for
`typescript/no-unsafe-enum-comparison` off, because the lint plugin compares `typescript/no-unsafe-enum-comparison` was removed (2026-10-16):
AST node `type`s, which the AST types declare as an enum. This is the `scripts/project-lint.ts` compares node types with `AST_NODE_TYPES` members
tooling exception [[lint-overrides-policy]] allows. from `@typescript-eslint/types` instead of string literals
([[lint-overrides-policy]]).
Oxlint's default size limits apply to `src/`; the migration split `run.ts` Size and complexity limits match Deno, which had none here, so every Oxlint
into `run-context.ts`, `agents.ts`, and `agent-{claude,codex,opencode}.ts`, limit rule is `"off"` ([[lint-config-shape]]). `run.ts` and `post.ts` keep
and `post.ts` into `post.ts` + `post-artifacts.ts`, rather than raising them. main's single-file layout (2026-10-17 revert of the size splits). Sequential
Sequential awaits in loops became recursion or a `reduce` chain to stay awaits in loops became recursion or a `reduce` chain (`no-await-in-loop`) to
sequential. Add no other opt-out without the user. stay sequential. Add no other opt-out without the user.
@@ -1,5 +1,5 @@
--- ---
description: bot-agents migration decisions: live @main action for six repos, JSON profiles, Node 24.2+, Deno kept only for agents, no @cunarist plugins needed description: bot-agents migration decisions: live @main action for six repos, JSON profiles, Node 24.2+, no @cunarist plugins needed
--- ---
Decisions for `commons/bot-agents` (Capsulizers Gitea), from the Deno to Decisions for `commons/bot-agents` (Capsulizers Gitea), from the Deno to
@@ -12,16 +12,16 @@ Node + Vite+ migration (PR #20, `node-migration`, 2026-10-15/16):
consumer (`uses: .../commons/bot-agents@<branch>`) on a throwaway PR. consumer (`uses: .../commons/bot-agents@<branch>`) on a throwaway PR.
- **Profiles are JSON.** `profiles.yml` became `profiles.json` with the same - **Profiles are JSON.** `profiles.yml` became `profiles.json` with the same
content (encrypted `v1:` values unchanged), read with `JSON.parse`. That content (encrypted `v1:` values unchanged), read with `JSON.parse`. That
removed the only runtime dependency (`@std/yaml`); keep it dependency-free. removed the only runtime dependency (`@std/yaml`); keep it free of runtime
dependencies (`@eslint/core` and the other devDependencies serve only the
tooling).
- **Node 24.2+**, not 22: `src/post.ts` uses `import.meta.main`. `engines.node` - **Node 24.2+**, not 22: `src/post.ts` uses `import.meta.main`. `engines.node`
is `>=24.2`, which the user accepted for this repo is `>=24.2`, which the user accepted for this repo
([[bot-agents-runtime-no-build]]). ([[bot-agents-runtime-no-build]]).
- **Deno stays in the action's global npm install** (`action.yml`), only
because the agents still check Deno-based consumer repos (memona,
memona-home). Remove it once those repos have moved; it is not a
dependency of this repo's TypeScript.
- **No `@cunarist/eslint-plugin-*`.** The Deno setup enabled no plugin, so - **No `@cunarist/eslint-plugin-*`.** The Deno setup enabled no plugin, so
this repo depends on none and CI passes without them being published. this repo depends on none and CI passes without them being published.
Planned (2026-10-17): once the plugins are on npm, add
`@cunarist/eslint-plugin-modules` here for its import checks.
- **Stricter input checks were accepted** in the migration: Gitea `/user` and - **Stricter input checks were accepted** in the migration: Gitea `/user` and
comment responses, the runtime manifest, and the outcome's profile type are comment responses, the runtime manifest, and the outcome's profile type are
validated; some type errors became `TypeError` with the same messages. validated; some type errors became `TypeError` with the same messages.
@@ -1,25 +0,0 @@
---
description: src/command.ts reproduces Deno.Command on child_process.spawn (stdio defaults, clearEnv, uid/gid, 128+signo); src/errors.ts maps NotFound to ENOENT/ESRCH
---
The Deno code was ported one for one; two small modules keep the old shapes
so callers barely changed:
- **`src/command.ts`** wraps `child_process.spawn` with `Deno.Command`'s API:
`output()` and `spawn()`, `clearEnv`, `uid`/`gid` (the agent isolation runs
each profile as its own UID, 20000+), and Deno's stdio defaults: stdin
inherited; stdout/stderr piped for `output()`, inherited for `spawn()`. A
signal exit maps to status `128 + signo`. Status settles on process exit,
not on stream close. Use it instead of calling `node:child_process`
directly so isolation and status handling stay uniform.
- **`src/errors.ts`** replaces `Deno.errors.NotFound` checks with an
`ENOENT`/`ESRCH` code check.
- `makeTempFile` became an exclusive `wx` create with mode `0600`.
- `src/json.ts` holds type guards used instead of `as` casts on parsed JSON.
- `process` and `Buffer` must come from `node:` imports:
`no-restricted-globals` in `scripts/project-lint.ts` maps Deno's
`no-window`/`no-process-global`/`no-node-globals` there
([[lint-parity-never-shrinks]]).
Related: how each agent CLI reports bad credentials is in
[[agent-cli-auth-failures]].
@@ -17,15 +17,14 @@ properties, no decorators) and relative `.ts` imports.
phase. It copies exactly `src/*.ts`, `package.json` (`"type": "module"` phase. It copies exactly `src/*.ts`, `package.json` (`"type": "module"`
makes Node load the sources as ES modules), `profiles.json`, and makes Node load the sources as ES modules), `profiles.json`, and
`prompt.md`. **A new runtime file or subdirectory must be added there**, or `prompt.md`. **A new runtime file or subdirectory must be added there**, or
it is missing at run time. `src/` is flat; a subfolder would not be copied. it is missing at run time. Runtime code in `src/` is flat; a subfolder would
not be copied. The only subfolder, `src/testing/`, is test-only.
- `tests/entrypoints.test.ts` runs each phase under real Node with a cleared - `tests/entrypoints.test.ts` runs each phase under real Node with a cleared
environment and expects its first "X is not set" error, which proves the environment and expects its first "X is not set" error, which proves the
whole import graph loads without a build. Keep it in sync with the phases. whole import graph loads without a build. Keep it in sync with the phases.
- **None of the app build conventions apply here:** no `vp build`, no - **None of the app build conventions apply here:** no `vp build`, no
frontend or Rust/Tauri output paths, no Containerfile, no decorators frontend or Rust/Tauri output paths, no Containerfile, no decorators
plugin, no lit-check, no generated JS ([[app-vite-build-output]], plugin, no lit-check, no generated JS. Don't add any of them.
[[app-containers-and-ci]], [[app-generated-js-as-ts]] describe the other
apps). Don't add any of them.
- **CI** (`.gitea/workflows/check.yml`) runs on the self-hosted runner's - **CI** (`.gitea/workflows/check.yml`) runs on the self-hosted runner's
default image as root: `npm ci`, `npx vp check`, `npx vp test`. It does not default image as root: `npm ci`, `npx vp check`, `npx vp test`. It does not
use `setup-vp`. YAML stays declarative ([[yaml-stays-declarative]]). use `setup-vp`. YAML stays declarative ([[yaml-stays-declarative]]).
+9 -6
View File
@@ -1,22 +1,25 @@
--- ---
description: Isolation and credential tests need root and use test.skipIf(!IS_ROOT); locally 5 skip, CI runs as root so all 61 run; verify isolation changes in node:24-bookworm as root description: Isolation and credential tests need root and use test.skipIf(!IS_ROOT); locally 5 skip, CI runs as root so all 66 run; verify isolation changes in node:24-bookworm as root
--- ---
Some tests exercise real UID isolation, process cleanup, and `prepare.ts` Some tests exercise real UID isolation, process cleanup, and `prepare.ts`
credential cleanup. They need root and are written as credential cleanup. They need root and are written as
`test.skipIf(!IS_ROOT)(...)` (`IS_ROOT` from `src/testing.ts`), so they show `test.skipIf(!IS_ROOT)(...)` (`IS_ROOT` from `#testing`), so they show
as skipped instead of silently passing. as skipped instead of silently passing.
- As a normal user `vp test` passes 56 and skips 5. In CI (self-hosted - As a normal user `vp test` passes 61 and skips 5. In CI (self-hosted
runner, root) all 61 run. runner, root) all 66 run.
- When changing `isolation.ts`, `runtime.ts`, `credentials.ts`, `prepare.ts`, - When changing `isolation.ts`, `runtime.ts`, `credentials.ts`, `prepare.ts`,
or `command.ts`, run the suite as root in `node:24-bookworm` (e.g. a or `child.ts`, run the suite as root in `node:24-bookworm` (e.g. a
container with the repo mounted) before pushing; a green non-root run container with the repo mounted) before pushing; a green non-root run
proves nothing about those paths. proves nothing about those paths.
- The migration also ran an end-to-end smoke as root with fake - The migration also ran an end-to-end smoke as root with fake
`codex`/`claude`/`npx` binaries and a fake Gitea through `codex`/`claude`/`npx` binaries and a fake Gitea through
`protect-source.sh` -> `prepare.ts` -> `run.ts` -> `post.ts`; repeat that `protect-source.sh` -> `prepare.ts` -> `run.ts` -> `post.ts`; repeat that
for changes to the phase flow ([[bot-agents-runtime-no-build]]). for changes to the phase flow ([[bot-agents-runtime-no-build]]).
- `src/testing.ts` holds the shared test helpers; tests follow - `src/testing/index.ts` holds the shared test helpers (user IDs, temp dirs,
`runCommand`) behind the `#testing` alias. Every test imports it as
`#testing`. It imports no source module, so `tests/` (which may not use
`../`) never reaches internals. Tests follow
[[tests-layout-vitest]] (unit tests in `src/*.test.ts`, repo-level ones in [[tests-layout-vitest]] (unit tests in `src/*.test.ts`, repo-level ones in
`tests/`). `tests/`).
@@ -1,44 +0,0 @@
---
description: Keep TC39 standard decorators with accessor; Oxc can't lower them, so scripts/decorators.ts (esbuild Vite plugin) does; never legacy decorators
---
Applies to every repo that uses decorators (the Lit frontends).
**Decision (2026-10-16):** keep standard (TC39) decorators with `accessor`,
exactly as the Deno code had them. No legacy decorators.
```ts
@customElement("app-card")
export class AppCard extends LitElement {
@property() accessor name = ""; // keep this form
}
```
**Gotcha, verified 2026-10-16:** Vite 8's Oxc transformer parses standard
decorators but can **not** lower them, so `vp build`, `vp dev`, and `vp test`
emit decorator syntax browsers and Node don't run. Check again on each Vite+
upgrade; when Oxc can lower them, delete the plugin and the `esbuild`
devDependency.
**Fix in use:** `scripts/decorators.ts`, a small Vite plugin backed by
`esbuild` (devDependency), registered in `vite.config.ts` `plugins` so it
covers build, dev server, and Vitest alike:
- `enforce: "pre"`, so it runs before Oxc;
- transforms only `.ts` modules whose source contains `@`;
- `esbuild.transform(code, { loader: "ts", target: "es2022", sourcemap,
sourcefile: id })`, returning `{ code, map }`.
It is tooling under `scripts/`: no tests, and it must pass every lint rule.
**The options as presented to the user:**
| Option | What | Why not |
| ------ | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| A | Legacy decorators: two tsconfig flags (`experimentalDecorators`, ...) and a codemod removing `accessor` | the user ruled out legacy decorators |
| B | Wait for Oxc to lower `accessor` decorators | not available yet; blocks the migration |
| C | No decorators: `static properties = {...}` + `declare` fields | rewrites every component |
| D | A transform step in the Vite build | chosen, with esbuild as the transformer (above) |
Don't add `experimentalDecorators` to `tsconfig.json`, and don't strip
`accessor` from fields.
-45
View File
@@ -1,45 +0,0 @@
---
description: Every repo moved from Deno to Node + Vite+ in Oct 2026 because Cloudflare bought Deno; nothing goes back to Deno
---
On 2026-10-12 the user moved every project from Deno to Node.js + Vite+
(`vp`). Cloudflare bought Deno on 2026-10-09; the runtime gets roughly 12
months of maintenance and Deno Deploy shuts down around April 2027. None of the
repos used Deploy or KV, so the move is about the toolchain, not hosting.
Do not reintroduce `deno.json`, `deno.lock`, `Deno.*` APIs, `jsr:` or `npm:`
specifiers, `mod.ts` entries, or `deno task` anywhere, docs and scripts
included.
## Order the migration followed
1. The custom Deno lint plugins were ported to npm first, as
`@cunarist/eslint-plugin-explicit-types`, `-import-check`, and `-lit`.
`cunarist/eslint-plugin-explicit-types` is the template every other repo
copies (config shape, `scripts/project-lint.ts`, AGENTS.md, notes).
2. Then `commons/bot-agents` (six repos use it as an action), then the apps:
home, eximium, stockvibe, brain, memona, memona-home.
3. deno-practice, interview-practice, web-landing, and vemium were left out
(archivable). Old `cunarist/deno-*` repos are archived one at a time, and
only after their `eslint-plugin-*` replacement is published to npm.
## What "migrated" means
- Same behavior. A migration PR changes tooling, not features.
- Lint coverage is at least what Deno enforced: [[lint-parity-never-shrinks]].
- Only three config files and only `.ts`: [[toolchain-node-vite-plus]],
[[ts-only-files-and-imports]].
- Deno.test suites became Vitest: [[tests-layout-vitest]].
The custom plugins' Deno names map like this:
| Deno | npm |
| ----------------------------------------------- | ---------------------------------------- |
| `jsr:@cunarist/deno-lint-plugin-explicit-types` | `@cunarist/eslint-plugin-explicit-types` |
| `jsr:@cunarist/deno-import-check` (`./lint`) | `@cunarist/eslint-plugin-import-check` |
| `jsr:@cunarist/deno-lint-plugin-lit` | `@cunarist/eslint-plugin-lit` |
| `scripts/deno-lint-plugin.ts` (per app) | `scripts/project-lint.ts` (per app) |
Until those packages are on npm, a repo that depends on them fails `npm ci` in
CI; that is expected and stated in the PR, never worked around by committing
tarballs or `file:` dependencies.
-1
View File
@@ -9,7 +9,6 @@ description: Work on a branch, plain imperative commits without AI trailers, one
- Commit messages are plain imperative sentences ("Port the import-check - Commit messages are plain imperative sentences ("Port the import-check
rules to an ESLint plugin"). No conventional-commit prefixes, no AI rules to an ESLint plugin"). No conventional-commit prefixes, no AI
trailers (`Co-Authored-By: Claude`, "Generated with ..."), no emoji. trailers (`Co-Authored-By: Claude`, "Generated with ..."), no emoji.
- A version bump is its own commit titled `Version X.Y.Z` ([[publishing-ci-only]]).
- When several agents share a branch, each works in its own `git worktree`, - When several agents share a branch, each works in its own `git worktree`,
runs `git pull --rebase` before pushing, and retries a rejected push after runs `git pull --rebase` before pushing, and retries a rejected push after
rebasing. Remove the worktree and temp branch afterwards. rebasing. Remove the worktree and temp branch afterwards.
-43
View File
@@ -1,43 +0,0 @@
---
description: Icons are .ts modules exporting a typed Lit svg template (memona's style), one per file behind an index.ts; never .svg imports as text
---
memona's way of declaring icons is the standard (2026-10-14). An icon is a
`.ts` module that exports a Lit `TemplateResult` holding the inline SVG:
```ts
// src/icons/lock.ts
import { html, type TemplateResult } from "lit";
/** Font Awesome Free's solid `lock`, as its SVG source. */
export const lockIcon: TemplateResult = html`<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 384 512"
>
<path fill="currentColor" d="M128 96l0 64 ..." />
</svg>`;
```
```ts
// usage
import { lockIcon } from "#icons";
render() { return html`<span class="icon">${lockIcon}</span>`; }
```
- One icon per file, camelCase name with an `Icon` suffix, a doc comment
saying what it depicts, exported through `src/icons/index.ts`.
- Use `fill="currentColor"` so CSS controls the colour.
- Keep a third-party licence comment that the SVG source carries (Font
Awesome Free's `<!--! ... -->` notice) inside the template.
**Replaced pattern:** eximium, stockvibe, and memona-home imported Font
Awesome `.svg` files as text in `icons.ts`:
```ts
import lock from "@fortawesome/fontawesome-free/svgs/solid/lock.svg" with { type: "text" }; // no
```
That breaks [[ts-only-files-and-imports]] (non-`.ts` import plus import
attributes) and was converted to memona's style during the frontend
migration. Before writing new icon code in any repo, read how that repo (or
memona) already declares icons and match it.
+78 -24
View File
@@ -1,48 +1,102 @@
--- ---
description: Shared code comes through # aliases in package.json imports, each directory's entry is index.ts, barrels keep imports few; never ../ out of a directory description: An alias owns its target (folder index.ts = folder and below, any other file = that file); no overlap; outside an area only the exact alias reaches it; ../ is banned in every file; locally, aliases target only direct src/ subfolders
--- ---
## `#` aliases ## `#` aliases own their targets
Shared code is imported through Node subpath imports declared in Shared code is imported through Node subpath imports declared in
`package.json` `imports`, never by a relative path that climbs out of a `package.json` `imports`. Node, TypeScript, Vite, Vite+'s config bundler, and
directory: Oxlint's plugin loader all resolve them natively, so `.ts` source runs
unbuilt. `vp pack` inlines them, so they never reach package consumers.
One rule for every file, never tied to folder or file names (user rule
2026-10-17; it replaces the earlier "aliases only point at direct `src/`
subfolders" rule as the general rule, and the "modules plugin checks only
aliased folders" scope; this repo keeps the stricter rule as a local
convention, see the last section):
- **An alias may point anywhere** and owns its target. An alias to a
folder's `index.ts` owns that folder and everything below it; an alias to
any other file owns only that file.
- **No overlap.** No file may be owned by 2 aliases: no alias inside another
alias's area, and no 2 aliases with the same target. The modules plugin's
`enforce-layer-order` reports it once at the top of every file 2 aliases
claim. Fix by merging the aliases or moving files apart.
- **Outside an area, only the exact alias reaches it.** A deeper alias
specifier (`#a/one.ts`, `no-barrel-bypass`) and a relative path into the
area (`./a/one.ts`, `no-relative-bypass`; `./b/f.ts` to a file alias,
`prefer-alias-import`, fixed to the alias) are bypasses.
- **Inside an area, relative imports of siblings and subfolders are free.**
- **`../` is banned in every file**, owned or not (`no-parent-import`).
Anything above a file needs an alias.
- **Files no alias owns** need no `index.ts` and no alias, and import `./`
siblings and subfolders freely.
- Unchanged: no `/*` subpath patterns, the key order of `imports` is the
layer order `enforce-layer-order` checks (add a new alias at the right
layer), and entry files are `index.ts` only.
```jsonc ```jsonc
"imports": { "imports": {
"#helpers": "./src/helpers/index.ts", "#a": "./a/index.ts", // owns a/**
"#components/*": "./src/components/*" "#f": "./b/f.ts", // owns only b/f.ts
"#a-sub": "./a/sub/index.ts", // no: inside #a
"#a2": "./a/index.ts" // no: same target as #a
} }
``` ```
```ts ```ts
import { typeNameTail } from "#helpers"; // yes // in main.ts, which no alias owns
import { typeNameTail } from "../helpers/ast.ts"; // no: import/no-relative-parent-imports import { a } from "#a"; // yes
import { one } from "#a/one.ts"; // no: deeper alias specifier
import { one } from "./a/one.ts"; // no: relative path into #a
import { f } from "#f"; // yes
import { f } from "./b/f.ts"; // no: relative path to a file alias
import { g } from "./b/g.ts"; // yes: unowned
// in a/one.ts, owned by #a
import { two } from "./two.ts"; // yes: sibling
import { deep } from "./sub/deep.ts"; // yes: subfolder of the same area
import { x } from "../x.ts"; // no: no ../ anywhere
``` ```
Node, TypeScript, Vite, Vite+'s config bundler, and Oxlint's plugin loader
all resolve them natively, so `.ts` source runs unbuilt. `vp pack` inlines them,
so they never reach package consumers.
- Deno's trailing-slash mappings (`"#components/"`) don't exist in Node; they
become patterns, `"#components/*": "./src/components/*"`.
- Aliases point at `.ts` files only. There is no `#package` alias - Aliases point at `.ts` files only. There is no `#package` alias
([[ts-only-files-and-imports]]). ([[ts-only-files-and-imports]]).
- In repos linted by import-check, the key order of `imports` is the layer - Lint `jsPlugins` entries in `vite.config.ts` stay path strings, even when
order `enforce-layer-order` checks; add a new alias at the right layer. the file has an alias.
- Keep test-only helpers in their own alias (e.g. `#testing`) so they never - Keep test-only helpers in their own alias (e.g. `#testing`) so they never
land in a bundle. land in a bundle.
## `index.ts` entries and barrels ## Barrels
- Every directory's entry file is `index.ts`, never `mod.ts` (decided - An area used from outside exposes its API through its alias target, and
2026-10-12; import-check's `enforce-mod-file` now requires `index.ts`). callers import the alias (`#ui`), not files inside it.
- A directory used from outside exposes its API through its `index.ts`
barrel, and callers import the barrel (`#ui`), not files inside it
(`no-barrel-bypass`).
- Barrels are also the standard fix for `import/max-dependencies`: one barrel - Barrels are also the standard fix for `import/max-dependencies`: one barrel
import replaces many direct ones ([[lint-overrides-policy]]). import replaces many direct ones ([[lint-overrides-policy]]).
- `import/no-cycle` is on; a barrel must not create a cycle. - `import/no-cycle` is on; a barrel must not create a cycle.
Relative imports inside one directory (`./sibling.ts`) are fine, with the Relative imports are written with the `.ts` extension.
`.ts` extension written out.
## `scripts/` is flat (user rule, 2026-10-16)
- `scripts/` has no subfolders and no `index.ts`.
- Each tool is one file, `scripts/<name>.ts`, imported directly by file:
`import projectLint from "./scripts/project-lint.ts";` in
`vite.config.ts`. No alias owns `scripts/`, so these `./` imports are free.
- Code in a former `scripts/<tool>/` folder is merged or renamed into flat
`scripts/<name>.ts` files. Non-TS assets (HTML, images, fixtures) move to
where they belong (next to the tool's output, `.agents/`, or a gitignored
scratch dir). A subfolder that is really an app moves under an alias.
## Local convention: aliases target direct `src/` subfolders
This is a convention of this repo only, stricter than the general rule above
(which the published modules plugin checks and which allows more). It is not
a plugin rule.
- Every `package.json` `imports` alias points only at the `index.ts` of a
direct subfolder of `src/`, e.g. `"#helpers": "./src/helpers/index.ts"`.
No alias to a single file, a deeper folder, or anything outside `src/`.
Here the one alias is `"#testing": "./src/testing/index.ts"`.
- Code outside `src/` (`scripts/`, `tests/`, `vite.config.ts`) gets no alias
of its own and imports its own files by `./` relative paths. It reaches
`src/` only through the aliases, since `../` is banned.
- No lint rule checks this; check `imports` by hand when adding an alias.
+11 -10
View File
@@ -1,5 +1,5 @@
--- ---
description: vite.config.ts lint uses Oxlint categories at error with default limits, names no rule itself, and spreads configs from the plugins and scripts/project-lint.ts description: vite.config.ts lint uses Oxlint categories at error with Deno size limits, names no rule itself, and spreads configs from the plugins and scripts/project-lint.ts
--- ---
The lint config is short and category-driven (user decisions 2026-10-14/15): The lint config is short and category-driven (user decisions 2026-10-14/15):
@@ -19,14 +19,15 @@ lint: {
- **Categories:** `correctness`, `suspicious`, `pedantic`, `perf` at `error`. - **Categories:** `correctness`, `suspicious`, `pedantic`, `perf` at `error`.
`style` and `restriction` are never turned on wholesale; pick single rules `style` and `restriction` are never turned on wholesale; pick single rules
from them in `configs.project`. Extra rules that categories bring are fine. from them in `configs.project`. Extra rules that categories bring are fine.
- **Default limits stay** (2026-10-16): pedantic's `max-lines` 300, - **Size limits match Deno** (2026-10-17, replaces the 2026-10-16 "default
`max-lines-per-function` 50, `max-classes-per-file` 1, limits stay"): repos behave as they did on Deno. bot-agents ran `deno lint`
`max-dependencies` 10, `max-params` 3, `complexity` 20. Don't raise them to with no config, so it had no size or complexity limit, and every Oxlint
make code pass; refactor (see [[lint-overrides-policy]]). Apps follow this limit rule (`complexity`, `max-lines`, `max-params`, `id-length`,
strictly. The plugin template already turns off `max-lines`, `no-nested-ternary`, `max-lines-per-function`, `max-classes-per-file`,
`max-lines-per-function`, `typescript/prefer-readonly-parameter-types`, and `max-depth`, `max-nested-callbacks`, `max-statements`,
`typescript/no-unsafe-enum-comparison` because AST visitors fight them; do `import/max-dependencies`, `unicorn/max-nested-calls`) is `"off"` in
not add new opt-outs anywhere without asking the user. `configs.project`. No file is split for size. Do not add new opt-outs
anywhere without asking the user.
- **`vite.config.ts` names no rule.** Every rule id, built-in ones included, - **`vite.config.ts` names no rule.** Every rule id, built-in ones included,
lives in `configs.project` of `scripts/project-lint.ts` lives in `configs.project` of `scripts/project-lint.ts`
([[project-lint-plugin]]). `vite.config.ts` only spreads configs. ([[project-lint-plugin]]). `vite.config.ts` only spreads configs.
@@ -39,7 +40,7 @@ lint: {
Hand-written code is never ignored. Hand-written code is never ignored.
- **No lint disable comments**: [[lint-no-disable-comments]]. - **No lint disable comments**: [[lint-no-disable-comments]].
- **Parity:** [[lint-parity-never-shrinks]]. **Naming:** [[naming-id-match]]. - **Parity:** [[lint-parity-never-shrinks]]. **Naming:** [[naming-id-match]].
- Don't add a second import sorter; import-check's `enforce-import-order` is - Don't add a second import sorter; the modules plugin's `enforce-import-order` is
the only one, and Oxfmt's `sortImports` stays off. the only one, and Oxfmt's `sortImports` stays off.
`perf` came last: in the template it flagged nine `.includes` on constant `perf` came last: in the template it flagged nine `.includes` on constant
+12 -24
View File
@@ -1,5 +1,5 @@
--- ---
description: No lint overrides for source files; narrow ones only for scripts/**; generated files go in ignorePatterns; refactor (barrels, splits) instead of raising limits description: No lint overrides for source files; narrow ones only for scripts/**; generated files go in ignorePatterns; size limits match Deno, so nothing is split for size
--- ---
Final policy (2026-10-16). The user considers per-file `overrides` bad Final policy (2026-10-16). The user considers per-file `overrides` bad
@@ -18,28 +18,16 @@ practice.
## Fix the code instead ## Fix the code instead
Oxlint's defaults stay; violations are refactored without behavior change, Category rule violations are fixed in the code without behavior change,
covered by the tests, inside the same migration PR. covered by the tests, inside the same migration PR; e.g. `prefer-set-has`
becomes `const K: ReadonlySet<string> = new Set([...])` with `.has`.
| Rule | Usual fix | Size and complexity limits are not refactored for: they match what Deno
| ------------------------- | ------------------------------------------------------- | enforced (2026-10-17, replacing the earlier "refactor to Oxlint's defaults"),
| `import/max-dependencies` | import a barrel (`#ui`) instead of many direct modules | which for bot-agents is none, so those rules are `"off"`
| `max-lines` | split the file by responsibility | ([[lint-config-shape]]).
| `max-lines-per-function` | extract helpers; split a long Lit template into parts |
| `max-classes-per-file` | one class per file |
| `max-params` | pass one options object |
| `prefer-set-has` | `const K: ReadonlySet<string> = new Set([...])`, `.has` |
```ts Alias rule: an alias may point anywhere and owns its target (a folder's
// before: 14 imports, over the limit of 10 `index.ts` owns the folder and below, any other file owns only itself); no
import { a } from "#ui/button"; two aliases may own the same file, and `../` is banned everywhere
import { b } from "#ui/dialog"; ([[imports-aliases-barrels]]).
// after: one barrel
import { a, b } from "#ui";
```
Measured on memona before the move (Oxlint 1.87, default limits): 921
pedantic hits in 274 files, of which `max-lines-per-function` 206,
`max-lines` 145, `max-classes-per-file` 59, `max-dependencies` 45 (median 14
imports, worst 29). The user chose refactoring over raising limits or
matching memona's old hugoalh limits (2048 lines per file, no function limit).
+1 -1
View File
@@ -29,7 +29,7 @@ Before removing any line from `configs.project`, find what else covers it.
in packages. in packages.
- **Vacuous:** JSX rules (no `.tsx` exists) and Deno-only rules. - **Vacuous:** JSX rules (no `.tsx` exists) and Deno-only rules.
- **`@hugoalh` rules** map to Oxlint built-ins in `configs.project`. - **`@hugoalh` rules** map to Oxlint built-ins in `configs.project`.
- **import-check's CLI cycle check** → `import/no-cycle`. - **deno-import-check's CLI cycle check** → `import/no-cycle`.
The template's list in `configs.project` is the baseline to copy: `curly`, The template's list in `configs.project` is the baseline to copy: `curly`,
`guard-for-in`, `no-console`, `no-empty`, `no-regex-spaces`, `guard-for-in`, `no-console`, `no-empty`, `no-regex-spaces`,
-40
View File
@@ -1,40 +0,0 @@
---
description: lit-analyzer runs from scripts/lit-check.ts, which maps "typescript" to the aliased typescript-6 devDependency; npm run lit-check in CI
---
Applies to Lit repos that ran `cunarist/deno-lit-analyzer` under Deno.
lit-analyzer is built on TypeScript's JavaScript compiler API and is run
against TypeScript 6 (the open question was whether it works with TS 7 /
tsgo). The repo installs TS 6 under another name and redirects lit-analyzer
to it, so the rest of the toolchain is unaffected (decided 2026-10-16):
```jsonc
// package.json
"scripts": { "lit-check": "node scripts/lit-check.ts" },
"devDependencies": { "typescript-6": "npm:typescript@^6", "lit-analyzer": "..." }
```
```ts
// scripts/lit-check.ts (shape)
import { registerHooks } from "node:module";
registerHooks({
resolve(specifier, context, next) {
// when lit-analyzer asks for "typescript", hand it TS 6
return next(specifier === "typescript" ? "typescript-6" : specifier, context);
},
});
// then run lit-analyzer
```
- Node runs the `.ts` script directly (type stripping), so no build step.
- **Gotcha:** the obvious `await import("lit-analyzer/cli.js")` fails
`project/no-non-ts-imports` (a `.js` subpath). Import the package entry, or
spawn the package's bin with `node:child_process`, instead.
- CI runs `npm run lit-check` as its own step.
- It is tooling under `scripts/`: no tests; a narrow `overrides` entry for it
(e.g. `no-console`) is allowed if truly needed.
Don't replace this with a global TypeScript downgrade, a second
`tsconfig`, or a `.js` wrapper script.
+13 -26
View File
@@ -1,34 +1,15 @@
--- ---
description: package.json key order is metadata, scripts, exports, imports, devDependencies, dependencies; standard fields only; no peerDependencies or packageManager description: package.json keys follow Oxfmt's standard order (vp check --fix); standard fields only; no peerDependencies or packageManager
--- ---
## Key order (2026-10-14) ## Key order (2026-10-16)
Metadata first, then `scripts`, `exports`, `imports`, `devDependencies`, Oxfmt's standard `package.json` sorting is accepted: there is no
`dependencies`. Oxfmt would re-sort the file, so `vite.config.ts` sets `fmt: { sortPackageJson: false }`, and `vp check --fix` sets the order. This
`fmt: { sortPackageJson: false }`; keep it, and keep the order by hand. replaces the earlier hand-written order (metadata, `scripts`, `exports`,
`imports`, `devDependencies`, `dependencies`).
```jsonc The `imports` key order can carry meaning: the modules plugin's
{
"name": "@cunarist/eslint-plugin-x",
"version": "0.1.0",
"description": "...",
"keywords": ["eslint", "eslint-plugin", "eslintplugin", "oxlint"],
"license": "MIT",
"repository": { "type": "git", "url": "git+https://github.com/..." },
"type": "module",
"files": ["dist"],
"engines": { "node": ">=22" },
"eslint-doc-generator": {/* tool options that can only live here */},
"scripts": { "docs": "eslint-doc-generator" },
"exports": { ".": "./dist/index.js" },
"imports": { "#helpers": "./src/helpers/index.ts" },
"devDependencies": { "vite-plus": "^1.1.0" },
"dependencies": {},
}
```
The `imports` key order can carry meaning: import-check's
`enforce-layer-order` reads it as the layer order. Never reorder it casually. `enforce-layer-order` reads it as the layer order. Never reorder it casually.
## Fields ## Fields
@@ -49,3 +30,9 @@ The `imports` key order can carry meaning: import-check's
Refresh the lockfile with `npm install` (or `npm install Refresh the lockfile with `npm install` (or `npm install
--package-lock-only` for a version bump) and commit it with the change. --package-lock-only` for a version bump) and commit it with the change.
Alias rule: an alias may point anywhere and owns its target (a folder's
`index.ts` owns the folder and everything below it; any other file owns only
itself). No two aliases may own the same file, outside an area only the exact
alias reaches it, and `../` is banned in every file
([[imports-aliases-barrels]]).
+5 -4
View File
@@ -11,11 +11,12 @@ Oxlint plugin with `meta.name` `"project"`, loaded through `jsPlugins` in
plugin configs: Deno parity rules, extras, per-rule options, and opt-outs, plugin configs: Deno parity rules, extras, per-rule options, and opt-outs,
built-in Oxlint ids included (`curly`, `id-match`, `import/no-cycle`, ...). built-in Oxlint ids included (`curly`, `id-match`, `import/no-cycle`, ...).
- **Custom rules**, enabled as `project/<rule>`. Every repo has - **Custom rules**, enabled as `project/<rule>`. Every repo has
`no-disable-comments` and `no-non-ts-imports`. Apps also port the rules of `no-disable-comments` and `no-asset-imports`; bot-agents has no others.
their old `scripts/deno-lint-plugin.ts` here under the same names and
behavior (`no-self-sync`, `app-module-conventions`, `restricted-types`, ...).
Rules and plugin stay in this one file; don't add `scripts/rules/`. `scripts/` stays flat; don't add `scripts/rules/`. If the plugin ever needs
more files, they sit flat beside it and share the prefix
(`project-lint-modules.ts`, ...); `scripts/project-lint.ts` keeps the plugin
object and the rule list.
```ts ```ts
import type { DummyRuleMap } from "vite-plus/lint"; import type { DummyRuleMap } from "vite-plus/lint";
-37
View File
@@ -1,37 +0,0 @@
---
description: Packages publish only from CI when a GitHub release vX.Y.Z is published, via npm trusted publishing; never npm publish by hand; notes live only on the release page
---
Standing convention (2026-10-12), applied to every package:
- **Only CI publishes.** Publishing a GitHub release `vX.Y.Z` triggers
`.github/workflows/publish.yml`. Never run `npm publish` locally and never
push a bare `v*` tag. Use the `release` skill, and only when the user asked
for a release.
- **The workflow** checks that the tag equals `v` + `package.json` `version`,
runs the full CI as a reusable workflow (every Node version in the matrix),
then `vp pack` and `npm publish --access public --provenance`. A release
marked pre-release publishes under the `next` dist-tag, others `latest`.
It runs on Node 24 (npm 11, which trusted publishing needs) with no cache.
- **Auth is npm trusted publishing** (OIDC, `id-token: write`), which also
attaches provenance. npm only allows a trusted publisher on a package that
already exists, so the very first release bootstraps with a short-lived
token in the `NPM_TOKEN` repo secret (the workflow uses it only when set).
Afterwards the user configures the trusted publisher for `publish.yml`,
deletes the secret, and sets the package to disallow tokens.
- **Release notes live only on the GitHub release page**
(`--generate-notes`, edited there). There is no `CHANGELOG.md`; never add
one.
- **The version lives in two places in plugin packages:** `package.json` and
`meta.version` in `src/index.ts`; `tests/package-meta.test.ts` fails if they
differ.
- **Before 1.0**, a change that breaks users (renamed rule id, a rule that
fires on more code, removed behavior) is a minor bump.
- **Before a release**, pack the package and lint a known-bad and a known-good
file from separate consumer projects, once through `vp lint` with
`jsPlugins` and once through ESLint 9 with the configs. On the lit plugin
this caught two bugs that 313 unit tests missed.
The cunarist lint plugins (explicit-types, import-check, lit) are published
together, with a token the user provides; the old `deno-*` repos are archived
only after that, one at a time.
+8 -5
View File
@@ -1,5 +1,5 @@
--- ---
description: Vitest via vp test; unit tests sit next to code as *.test.ts, regression/integration tests in tests/ via the public entry; scripts/ has no tests description: Vitest via vp test; unit tests sit next to code as *.test.ts, regression/integration tests in tests/ via the public entry (through its # alias, never ../); scripts/ has no tests
--- ---
Decided 2026-10-14 for all repos. Decided 2026-10-14 for all repos.
@@ -11,12 +11,15 @@ Decided 2026-10-14 for all repos.
config needed. Packages publish only `dist/`, so colocated tests cost nothing config needed. Packages publish only `dist/`, so colocated tests cost nothing
in the tarball. in the tarball.
- **Regression and integration tests** live in the top-level `tests/` folder - **Regression and integration tests** live in the top-level `tests/` folder
and use only the public entry point (an alias like `#plugin`), never and use only the public entry point, imported through its `#` alias (e.g.
internals. `vp test tests` runs just these. `#plugin` for `./src/plugin/index.ts`), never internals and never `../`,
which is banned in every file. `vp test tests` runs just these
([[imports-aliases-barrels]]). In bot-agents, `tests/` imports only
`#testing` (`./src/testing/index.ts`), plain helpers that import no source
module.
- **No in-source tests** (`import.meta.vitest`). - **No in-source tests** (`import.meta.vitest`).
- **`scripts/` gets no tests** (2026-10-15): it is repo tooling, not - **`scripts/` gets no tests** (2026-10-15): it is repo tooling, not
production code (`project-lint.ts`, `decorators.ts`, `lit-check.ts`, production code (`project-lint.ts`, `protect-source.sh`).
release helpers).
- Test files follow every lint rule; no relaxed overrides for tests. - Test files follow every lint rule; no relaxed overrides for tests.
## Migrating Deno tests ## Migrating Deno tests
+2 -2
View File
@@ -44,8 +44,8 @@ before any push. CI installs the toolchain with `voidzero-dev/setup-vp`.
- `vp check` does **not** report `isolatedDeclarations` errors (TS9010); only - `vp check` does **not** report `isolatedDeclarations` errors (TS9010); only
`vp pack` does, so packages run `vp pack` in CI on every PR. `vp pack` does, so packages run `vp pack` in CI on every PR.
- Oxfmt sorts `package.json` keys by default, which destroys the required key - Oxfmt sorts `package.json` keys by default; that order is accepted, so
order. Set `fmt: { sortPackageJson: false }` ([[package-json-shape]]). there is no `fmt` option for it ([[package-json-shape]]).
- Oxlint and Oxfmt skip what `.gitignore` lists; that is the only reason - Oxlint and Oxfmt skip what `.gitignore` lists; that is the only reason
`dist/` and `node_modules/` are not linted. Keep build output gitignored. `dist/` and `node_modules/` are not linted. Keep build output gitignored.
- Vite+ bundles `vite.config.ts` itself and resolves `#` imports in it. - Vite+ bundles `vite.config.ts` itself and resolves `#` imports in it.
+24 -14
View File
@@ -1,28 +1,38 @@
--- ---
description: Every file is .ts (no js/mjs/cjs/jsx/tsx, configs included) and source imports only .ts; CSS comes from HTML, JSON/SVG become .ts modules description: Every file is .ts (no js/mjs/cjs/jsx/tsx, configs included); imports name only .ts files or .js/.mts/.mjs package subpaths; CSS comes from HTML, JSON/SVG become .ts modules
--- ---
The user's rule, from the Deno days and restated for Node (2026-10-12): "No The user's rule, from the Deno days and restated for Node (2026-10-12): "No
JS, not even JSX/TSX, only TS." Source, scripts, tests, and configs are all JS, not even JSX/TSX, only TS." Source, scripts, tests, and configs are all
plain `.ts`. Build output in a gitignored folder is the only JS. plain `.ts`. Build output in a gitignored folder is the only JS.
`tsconfig.json`: no `allowJs`, no `jsx`, and the template's strict flags `tsconfig.json`: no `allowJs`, no `jsx`, and exactly this strictness set
(`verbatimModuleSyntax`, `erasableSyntaxOnly`, `allowImportingTsExtensions`, (user decision, 2026-10-18): `strict` (written out), `noUncheckedIndexedAccess`,
`isolatedDeclarations` in packages, `exactOptionalPropertyTypes`, `noPropertyAccessFromIndexSignature`, `noImplicitReturns`, `noUnusedLocals`,
`noUncheckedIndexedAccess`). If a tool or framework seems to need a JS or TSX `noUnusedParameters`, `noImplicitOverride`, `noFallthroughCasesInSwitch`,
file, stop and flag it instead of adding one. `erasableSyntaxOnly`, plus the mechanical `verbatimModuleSyntax`,
`allowImportingTsExtensions`, and `noEmit` (packages also keep `declaration`
and `isolatedDeclarations`). `exactOptionalPropertyTypes` is off everywhere:
write `{ key: maybeUndefined }` rather than leaving the key out to please it.
If a tool or framework seems to need a JS or TSX file, stop and flag it
instead of adding one.
## Only `.ts` imports (2026-10-14) ## No asset imports (2026-10-14, widened 2026-10-16 and 2026-10-17)
`project/no-non-ts-imports` (in `scripts/project-lint.ts`) fails: `project/no-asset-imports` (in `scripts/project-lint.ts`; it was
`no-non-ts-imports` before 2026-10-16) fails:
- a relative, absolute, `#`, or package-subpath specifier whose last segment - a relative, absolute, `#`, or package-subpath specifier whose last segment
has a non-`.ts` extension: `.css`, `.json`, `.svg`, `.js`, `.mjs`, `.tsx`; has an extension other than `.ts`, `.js`, `.mts`, or `.mjs`: `.css`,
`.json`, `.svg`, `.jsx`, `.tsx`, and every other asset;
- any `?query` suffix (`?raw`, `?url`, `?inline`); - any `?query` suffix (`?raw`, `?url`, `?inline`);
- any import attributes (`with { type: "json" }`, `with { type: "text" }`). - any import attributes (`with { type: "json" }`, `with { type: "text" }`).
Bare packages (`lit`, `@types/json-schema`, `lodash.debounce`), `#` aliases, `.js`, `.mts`, and `.mjs` are allowed (user decision, 2026-10-17) for package
and `node:` builtins pass. `@fortawesome/x/svgs/a.svg` fails. subpaths such as `lit/decorators.js` or `pdfjs-dist/legacy/build/pdf.mjs`;
our own files are still `.ts` only. `.jsx` and `.tsx` stay banned. Bare
packages (`lit`, `@types/json-schema`, `lodash.debounce`), `#` aliases, and
`node:` builtins pass. `@fortawesome/x/svgs/a.svg` fails.
```ts ```ts
import "./theme.css"; // no import "./theme.css"; // no
@@ -37,12 +47,12 @@ import { scenes } from "#data/scenes"; // yes: scenes.ts
import CSS from TS, not even for Vite. (This also removes most of the import CSS from TS, not even for Vite. (This also removes most of the
`import/no-unassigned-import` hits the `suspicious` category reports.) `import/no-unassigned-import` hits the `suspicious` category reports.)
- **JSON data:** turn it into a `.ts` module exporting a typed constant. - **JSON data:** turn it into a `.ts` module exporting a typed constant.
- **SVG:** a `.ts` module exporting a Lit template; see [[icons-memona-style]]. - **SVG:** a `.ts` module exporting the markup.
- **`package.json` fields at runtime:** never `import pkg from - **`package.json` fields at runtime:** never `import pkg from
"./package.json"` and there is no `#package` alias. Write the value out in "./package.json"` and there is no `#package` alias. Write the value out in
`.ts` and add a test in `tests/` that reads `package.json` with `node:fs` `.ts` and add a test in `tests/` that reads `package.json` with `node:fs`
and fails on drift (the plugin repos do this for `meta.name`/`meta.version`). and fails on drift (the plugin repos do this for `meta.name`/`meta.version`).
- **Reading a file is not importing it.** `readFileSync` + `JSON.parse` is - **Reading a file is not importing it.** `readFileSync` + `JSON.parse` is
fine in tooling and tests. fine in tooling and tests.
- **A tool's `.js` entry:** don't `import("pkg/cli.js")`; use the package - **A tool's CLI file:** prefer the package entry or spawning the package's
entry or spawn the package's bin ([[lit-analyzer-ts6]]). bin over importing `pkg/cli.js`, which runs the CLI as a side effect.
-69
View File
@@ -1,69 +0,0 @@
---
name: migrate-deno-repo
description: Checklist for moving a Deno repo (or a leftover Deno part of one) to Node 22+ + Vite+ with npm, same behavior, full lint parity, one PR
---
Template: `cunarist/eslint-plugin-explicit-types` on `main`. Read its
`AGENTS.md`, `vite.config.ts`, `scripts/project-lint.ts`, `package.json`, and
CI before starting, and copy their shape. Never ask the user mid-way when
they're away: decide by the memories, record the choice in the PR.
Branch `node-migration` (apps) or `initial-port` (new plugin repos); one PR
with everything.
**Inventory first**
- [ ] List every `deno.json` task, import-map entry, lint rule (tags,
includes, `@hugoalh`, custom plugins), test, `Deno.*` call, JSON/CSS/SVG
import, and every place the output is consumed (Rust, HTML,
Containerfile, CI).
**Toolchain**
- [ ] `package.json` in the required key order, `engines.node ">=22"`, no
`packageManager`, no `peerDependencies`; `npm install`; commit
`package-lock.json`.
- [ ] `tsconfig.json` from the template; `vite.config.ts` with `fmt:
{ sortPackageJson: false }` and the category-based `lint` block.
- [ ] Delete `deno.json`, `deno.lock`, and Deno-only files. `mod.ts` →
`index.ts`; `deno.json` imports → `package.json` `imports` (`#x/*`
patterns for trailing-slash mappings); `jsr:`/`npm:` → npm packages.
**Code**
- [ ] Only `.ts` files; no non-`.ts` imports: CSS via `<link>`, JSON and SVG
as `.ts` modules, icons in memona's style.
- [ ] `Deno.*` → `node:*` / `process`. Generated JS written as `.ts`, built,
gitignored.
- [ ] Decorators stay standard with `accessor`; add `scripts/decorators.ts`
if the repo has any. Lit repos: `scripts/lit-check.ts` + `npm run
lit-check`.
**Lint**
- [ ] `scripts/project-lint.ts` copied; old local Deno lint rules ported into
it; parity mapping for every former rule; `id-match`; no disable
comments; no source `overrides`; refactor size-rule hits.
**Tests**
- [ ] `Deno.test` → Vitest (codemod, then finish TODOs); unit tests next to
code, integration in `tests/`; no case dropped.
**Build, ship, CI**
- [ ] `vp build` in app mode with the same output paths, or `vp pack` for
packages. Containerfile node stage on `node:24-alpine`. CI with
`setup-vp`: `vp check`, build, `vp test` (+ lit-check). Packages:
release-triggered `publish.yml`.
- [ ] `AGENTS.md` with the damem header, `.agents/` notes from the damem kit,
README updated (Node.js 22+, no Deno instructions). `damem doctor`
clean.
**Finish**
- [ ] `vp check`, `vp test`, build all pass locally; the app runs.
- [ ] Commit in plain imperative sentences; open the PR (summary, changes,
parity mapping, deviations/risks, "CI fails until @cunarist/eslint-plugin-*
are published" if relevant, verification, footer line).
- [ ] Run `review-cycle`. Never merge, publish, or archive.
+26 -6
View File
@@ -15,9 +15,30 @@ explicit permission.
The action's TypeScript runs directly on the job image's Node.js (24.2 or The action's TypeScript runs directly on the job image's Node.js (24.2 or
newer), which strips the types itself: there is no build step and no runtime newer), which strips the types itself: there is no build step and no runtime
dependency. Keep the sources to erasable syntax and `.ts` imports, which dependency. Keep the sources to erasable syntax and `.ts` imports, which
`tsconfig.json` enforces. Checks use Vite+: `vp check` formats, lints, and `tsconfig.json` enforces. Its strictness set is fixed and leaves
type-checks, and `vp test` runs Vitest. Unit tests sit next to the code as `exactOptionalPropertyTypes` off
`src/*.test.ts`; repository-level tests live in `tests/`. ([.agents/memory/ts-only-files-and-imports.md](.agents/memory/ts-only-files-and-imports.md)).
Checks use Vite+: `vp check` formats, lints, and type-checks, and `vp test`
runs Vitest. Unit tests sit next to the code as `src/*.test.ts`;
repository-level tests live in `tests/`.
A `#` alias in `package.json` `imports` may point anywhere and owns its
target: an alias to a folder's `index.ts` owns that folder and everything
below it; an alias to any other file owns only that file. No overlap: no file
may be owned by 2 aliases, so no alias inside another alias's area and no 2
aliases with the same target. Outside an area, only the exact alias reaches
it; a deeper alias specifier or a relative path into the area is a bypass.
Inside an area, relative imports of siblings and subfolders are free. `../`
is banned in every file, owned or not
(`import/no-relative-parent-imports`). Files no alias owns need no `index.ts`
and no alias, and import `./` siblings and subfolders freely. The one alias
is `#testing` (`./src/testing/index.ts`): every test imports it by alias, and
`tests/` reaches `src/` only through it. Local convention, stricter than the
general rule above: in this repo, aliases point only at a direct subfolder of
`src/` (`"#helpers": "./src/helpers/index.ts"`), and code outside `src/` uses
`./` relative imports. `scripts/` is flat: one file per
tool, no subfolders and no `index.ts`, imported directly by file path
([.agents/memory/imports-aliases-barrels.md](.agents/memory/imports-aliases-barrels.md)).
The only config files are `package.json`, `tsconfig.json`, and The only config files are `package.json`, `tsconfig.json`, and
`vite.config.ts`, plus `package-lock.json`. Repo-local lint rules and the rule `vite.config.ts`, plus `package-lock.json`. Repo-local lint rules and the rule
@@ -25,6 +46,5 @@ list live in `scripts/project-lint.ts`. Lint disable comments are not allowed;
fix the source instead. fix the source instead.
The composite action uses npm only to install the externally distributed The composite action uses npm only to install the externally distributed
Claude Code, Codex, and OpenCode command-line tools, plus Deno for agents that Claude Code, Codex, and OpenCode command-line tools; do not treat those
check Deno-based repositories; do not treat those vendor executables as vendor executables as repository TypeScript dependencies.
repository TypeScript dependencies.
+2 -3
View File
@@ -16,11 +16,10 @@ runs:
fetch-depth: 0 fetch-depth: 0
token: ${{ gitea.token }} token: ${{ gitea.token }}
# This step assumes this is `node:24-bookworm` container. The action runs # This step assumes this is `node:24-bookworm` container. The action runs
# its TypeScript on that Node directly; Deno stays for the agents, which # its TypeScript on that Node directly.
# still check Deno-based repositories with it.
- name: Install dependencies - name: Install dependencies
shell: bash shell: bash
run: npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai deno run: npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai
# A local action lives inside the writable checkout. Execute a root-owned # A local action lives inside the writable checkout. Execute a root-owned
# copy so an agent changing this repository cannot replace a later phase. # copy so an agent changing this repository cannot replace a later phase.
- name: Protect action source - name: Protect action source
+6 -3
View File
@@ -1,10 +1,10 @@
{ {
"name": "bot-agents", "name": "bot-agents",
"description": "A Gitea composite action that runs a coding agent for approved issue and pull request automation.",
"private": true, "private": true,
"description": "A Gitea composite action that runs a coding agent for approved issue and pull request automation.",
"type": "module", "type": "module",
"engines": { "imports": {
"node": ">=24.2" "#testing": "./src/testing/index.ts"
}, },
"devDependencies": { "devDependencies": {
"@eslint/core": "^0.17.0", "@eslint/core": "^0.17.0",
@@ -12,5 +12,8 @@
"@typescript-eslint/types": "^8.71.2", "@typescript-eslint/types": "^8.71.2",
"vite-plus": "^1.2.0", "vite-plus": "^1.2.0",
"yaml": "^2.9.1" "yaml": "^2.9.1"
},
"engines": {
"node": ">=24.2"
} }
} }
+65 -31
View File
@@ -1,8 +1,7 @@
/** /**
* `project` — lint rules this repository holds its own source to. Deno lint * `project` — lint rules this repository holds its own source to, on top of
* ran here with its `recommended` rules and no plugins; the Oxlint categories * the Oxlint categories in `vite.config.ts`. Its rules of its own are
* in `vite.config.ts` plus the rule list below keep at least that coverage. * `no-disable-comments` and `no-asset-imports`.
* Its rules of its own are `no-disable-comments` and `no-non-ts-imports`.
* *
* Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry. * Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry.
* *
@@ -10,7 +9,7 @@
*/ */
import type { LanguageOptions, RuleDefinition, RuleVisitor, TextSourceCode } from "@eslint/core"; import type { LanguageOptions, RuleDefinition, RuleVisitor, TextSourceCode } from "@eslint/core";
import type { TSESTree } from "@typescript-eslint/types"; import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/types";
import type { DummyRuleMap } from "vite-plus/lint"; import type { DummyRuleMap } from "vite-plus/lint";
/** The source code both hosts hand a rule, with the comment list. */ /** The source code both hosts hand a rule, with the comment list. */
@@ -73,11 +72,19 @@ const noDisableCommentsRule: ProjectRule = {
const PATH_SPECIFIER_PATTERN: RegExp = /^[./#]/u; const PATH_SPECIFIER_PATTERN: RegExp = /^[./#]/u;
/** /**
* Whether `source` names something other than a `.ts` module: a query string * Script module extensions an import may name: our `.ts` files and `.js`,
* such as `?raw`, or a final path segment with any other extension. Bare * `.mts`, or `.mjs` package subpaths. `.cts`, `.cjs`, `.tsx`, and `.jsx` are
* package names are left alone, dots and all, as are extensionless paths. * not among them.
*/ */
function isNonTsSource(source: string): boolean { const SCRIPT_EXTENSIONS: ReadonlySet<string> = new Set([".ts", ".js", ".mts", ".mjs"]);
/**
* Whether `source` names an asset rather than a script module: a query string
* such as `?raw`, or a final path segment whose extension, from its last dot,
* is not `.ts`, `.js`, `.mts`, or `.mjs`. Bare package names are left alone,
* dots and all, as are extensionless paths.
*/
function isAssetSource(source: string): boolean {
if (source.includes("?")) { if (source.includes("?")) {
return true; return true;
} }
@@ -92,30 +99,34 @@ function isNonTsSource(source: string): boolean {
if (lastSegment === "." || lastSegment === ".." || !lastSegment.includes(".")) { if (lastSegment === "." || lastSegment === ".." || !lastSegment.includes(".")) {
return false; return false;
} }
return !lastSegment.endsWith(".ts"); const extension: string = lastSegment.slice(lastSegment.lastIndexOf("."));
return !SCRIPT_EXTENSIONS.has(extension);
} }
/** /**
* `no-non-ts-imports` — rejects importing anything but a `.ts` module: a * `no-asset-imports` — rejects importing anything but a script module: a
* source path, static or a dynamic `import()` with a string literal, ending in * source path, static or a dynamic `import()` with a string literal, ending in
* another extension (`.css`, `.json`, `.svg`, `.js`, `.tsx`) or carrying a * an extension other than `.ts`, `.js`, `.mts`, or `.mjs` (`.css`, `.json`,
* query string (`?raw`, `?url`), and any import or export with import * `.svg`, `.jsx`, `.tsx`, `.cjs`) or carrying a query string (`?raw`, `?url`),
* attributes. Bare package names, `#` aliases, and `node:` builtins without an * and any import or export with import attributes. `.js`, `.mts`, and `.mjs`
* extension pass; package subpaths are checked like paths. * cover package subpaths such as `lit/decorators.js` or
* `pdfjs-dist/legacy/build/pdf.mjs`; our own files are still `.ts` only. Bare
* package names, `#` aliases, and `node:` builtins
* without an extension pass; package subpaths are checked like paths.
*/ */
const noNonTsImportsRule: ProjectRule = { const noAssetImportsRule: ProjectRule = {
meta: { meta: {
type: "problem", type: "problem",
docs: { description: "Disallow importing modules other than .ts files" }, docs: { description: "Disallow importing assets instead of .ts, .js, .mts, or .mjs modules" },
schema: [], schema: [],
messages: { messages: {
extension: "Only .ts modules may be imported.", extension: "Only .ts, .js, .mts and .mjs modules may be imported.",
attributes: "Import attributes are not allowed.", attributes: "Import attributes are not allowed.",
}, },
}, },
create(context) { create(context) {
function checkSource(source: TSESTree.StringLiteral): void { function checkSource(source: TSESTree.StringLiteral): void {
if (isNonTsSource(source.value)) { if (isAssetSource(source.value)) {
context.report({ node: source, messageId: "extension" }); context.report({ node: source, messageId: "extension" });
} }
} }
@@ -138,7 +149,7 @@ const noNonTsImportsRule: ProjectRule = {
ExportNamedDeclaration: checkDeclaration, ExportNamedDeclaration: checkDeclaration,
ExportAllDeclaration: checkDeclaration, ExportAllDeclaration: checkDeclaration,
ImportExpression(node: TSESTree.ImportExpression): void { ImportExpression(node: TSESTree.ImportExpression): void {
if (node.source.type === "Literal" && typeof node.source.value === "string") { if (node.source.type === AST_NODE_TYPES.Literal && typeof node.source.value === "string") {
checkSource(node.source); checkSource(node.source);
} }
if (node.options !== null) { if (node.options !== null) {
@@ -150,10 +161,9 @@ const noNonTsImportsRule: ProjectRule = {
}; };
/** /**
* This repository's own rule list, spread by `vite.config.ts`: Deno's * This repository's own rule list, spread by `vite.config.ts`: rules that
* `recommended` rules that sit outside the Oxlint categories, plus the * sit outside the Oxlint categories, plus the template's extras. Built-in
* template's extras. Built-in Oxlint rule ids sit here too, so * Oxlint rule ids sit here too, so `vite.config.ts` lists no rule by name.
* `vite.config.ts` lists no rule by name.
*/ */
const rules: DummyRuleMap = { const rules: DummyRuleMap = {
// The explicit-types template's `camelcase` stand-in: no `_` inside a name // The explicit-types template's `camelcase` stand-in: no `_` inside a name
@@ -164,7 +174,7 @@ const rules: DummyRuleMap = {
"^_*[^_]*_*$|^[^a-z]*$", "^_*[^_]*_*$|^[^a-z]*$",
{ properties: false, classFields: false, onlyDeclarations: false, ignoreDestructuring: true }, { properties: false, classFields: false, onlyDeclarations: false, ignoreDestructuring: true },
], ],
// Deno's `recommended` rules outside the categories. // Rules outside the categories.
"no-array-constructor": "error", "no-array-constructor": "error",
"no-case-declarations": "error", "no-case-declarations": "error",
"no-empty": "error", "no-empty": "error",
@@ -189,8 +199,7 @@ const rules: DummyRuleMap = {
"typescript/prefer-as-const": "error", "typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error", "typescript/prefer-namespace-keyword": "error",
"typescript/triple-slash-reference": "error", "typescript/triple-slash-reference": "error",
// Deno's `no-window`, `no-process-global`, and `no-node-globals`: on Node, // On Node, these come from an explicit `node:` import instead of the global scope.
// these come from an explicit `node:` import instead of the global scope.
"no-restricted-globals": [ "no-restricted-globals": [
"error", "error",
"window", "window",
@@ -202,14 +211,39 @@ const rules: DummyRuleMap = {
], ],
// The explicit-types template's extras. // The explicit-types template's extras.
"import/no-cycle": "error", "import/no-cycle": "error",
// `../` is banned in every file; anything above a file needs a `#` alias.
"import/no-relative-parent-imports": "error",
"typescript/consistent-type-imports": "error", "typescript/consistent-type-imports": "error",
"typescript/no-import-type-side-effects": "error", "typescript/no-import-type-side-effects": "error",
"typescript/no-non-null-assertion": "error", "typescript/no-non-null-assertion": "error",
// Deno's `ban-untagged-ignore` and `ban-unused-ignore`, made stricter: no // No disable comments at all.
// disable comments at all.
"project/no-disable-comments": "error", "project/no-disable-comments": "error",
"project/no-non-ts-imports": "error", "project/no-asset-imports": "error",
"unicorn/no-abusive-eslint-disable": "error", "unicorn/no-abusive-eslint-disable": "error",
// Size and complexity limits match Deno's: this repo's `deno lint` ran the
// `recommended` set with no config file, so no size or complexity rule
// applied and none applies here. Each Oxlint limit rule, from a category or
// not, is off:
// - `complexity`: Deno had no `max-complexity`.
// - `max-lines`: Deno had no `max-file-lines`.
// - `max-params`: Deno had no `max-params`.
// - `id-length`: Deno had no `max-identifier-length`.
// - `no-nested-ternary`: Deno had no `max-nest-ternaries`.
// - `max-lines-per-function`, `max-classes-per-file`, `max-depth`,
// `max-nested-callbacks`, `max-statements`, `import/max-dependencies`,
// `unicorn/max-nested-calls`: Deno had none of these.
complexity: "off",
"max-lines": "off",
"max-params": "off",
"id-length": "off",
"no-nested-ternary": "off",
"max-lines-per-function": "off",
"max-classes-per-file": "off",
"max-depth": "off",
"max-nested-callbacks": "off",
"max-statements": "off",
"import/max-dependencies": "off",
"unicorn/max-nested-calls": "off",
// Pedantic, but Node's and the web's own types (`Response`, `Stats`, // Pedantic, but Node's and the web's own types (`Response`, `Stats`,
// `RequestInit`) are mutable, so no parameter of theirs can pass; the // `RequestInit`) are mutable, so no parameter of theirs can pass; the
// explicit-types template and the app repos turn it off too. // explicit-types template and the app repos turn it off too.
@@ -224,7 +258,7 @@ const plugin = {
meta: { name: "project" }, meta: { name: "project" },
rules: { rules: {
"no-disable-comments": noDisableCommentsRule, "no-disable-comments": noDisableCommentsRule,
"no-non-ts-imports": noNonTsImportsRule, "no-asset-imports": noAssetImportsRule,
}, },
configs: { project: { rules } }, configs: { project: { rules } },
}; };
-119
View File
@@ -1,119 +0,0 @@
// Runs Claude Code for one profile and returns its final result text.
import { createInterface } from "node:readline";
import type { Readable } from "node:stream";
import { isClaudeAuthFailure } from "./auth.ts";
import type { ClaudeFailureFields } from "./auth.ts";
import { chunks } from "./command.ts";
import { agentCommand } from "./isolation.ts";
import { isObject } from "./json.ts";
import type { JsonObject } from "./json.ts";
import { AgentAuthError, agentEnvironment } from "./profiles.ts";
import { PROCESS_ENV, WORKSPACE } from "./run-context.ts";
import type { RuntimeProfile } from "./runtime.ts";
// Claude Code's stream-json `result` event, in its own field names.
type ClaudeResult = ClaudeFailureFields &
Partial<Record<"is_error", boolean>> & {
subtype: string;
};
// Echoes stderr as it arrives and resolves with all of it.
async function copyError(stream: Readable): Promise<string> {
const decoder = new TextDecoder();
let error = "";
for await (const chunk of chunks(stream)) {
const text = decoder.decode(chunk, { stream: true });
error += text;
console.error(text);
}
return error + decoder.decode();
}
// Prints the visible text of each streamed message part.
function printParts(event: JsonObject): void {
const content = isObject(event.message) ? event.message.content : undefined;
for (const part of Array.isArray(content) ? content : []) {
if (isObject(part)) {
const text = part.thinking ?? part.text ?? part.name;
if (typeof text === "string" && text !== "") {
console.log(text);
}
}
}
}
function toResult(event: JsonObject): ClaudeResult {
const { is_error: isError, result, subtype, api_error_status: apiErrorStatus } = event;
return {
...(typeof isError === "boolean" && { is_error: isError }),
...(typeof result === "string" && { result }),
subtype: String(subtype),
...((typeof apiErrorStatus === "number" || apiErrorStatus === null) && {
api_error_status: apiErrorStatus,
}),
};
}
// Prints each streamed message part and returns the final `result` event.
async function readEvents(stream: Readable): Promise<ClaudeResult | undefined> {
let result: ClaudeResult | undefined;
for await (const line of createInterface({ input: stream, crlfDelay: Infinity })) {
if (line !== "") {
const event: unknown = JSON.parse(line);
if (isObject(event)) {
printParts(event);
if (event.type === "result") {
result = toResult(event);
}
}
}
}
return result;
}
function checkResult(result: ClaudeResult | undefined, error: string): string {
if (result?.is_error === true) {
const failure = new Error(result.result ?? `claude ended with ${result.subtype}`);
if (isClaudeAuthFailure(result)) {
throw new AgentAuthError("claude sign-in failed", { cause: failure });
}
throw failure;
}
if (result?.result === undefined) {
throw new Error(error.trim() || `claude ended with ${result?.subtype ?? "no result"}`);
}
return result.result;
}
export async function runClaude(
profile: RuntimeProfile,
token: string,
prompt: string,
): Promise<string> {
const args = [
"--print",
"--dangerously-skip-permissions",
"--model",
profile.model,
"--output-format",
"stream-json",
"--verbose",
prompt,
];
const claude = agentCommand(profile, "claude", args, {
cwd: WORKSPACE,
env: { ...agentEnvironment(PROCESS_ENV, profile, token), IS_SANDBOX: "1" },
clearEnv: true,
stdout: "piped",
stderr: "piped",
}).spawn();
const errorText = copyError(claude.stderr);
const result = await readEvents(claude.stdout);
const [status, error] = await Promise.all([claude.status, errorText]);
const body = checkResult(result, error);
if (!status.success) {
throw new Error(error.trim() || `claude exited with ${status.code}`);
}
return body;
}
-174
View File
@@ -1,174 +0,0 @@
// Signs Codex in for one profile, runs it, and returns its result file path.
import process from "node:process";
import type { Readable } from "node:stream";
import { text } from "node:stream/consumers";
import { setTimeout as sleep } from "node:timers/promises";
import { isCodexAuthFailure, retryInvalidToken } from "./auth.ts";
import type { CodexAuthentication } from "./auth.ts";
import { chunks, endWith, writeChunk } from "./command.ts";
import type { CommandStatus } from "./command.ts";
import { agentCommand } from "./isolation.ts";
import { AgentAuthError, agentEnvironment, withProfile } from "./profiles.ts";
import {
makeProfileFile,
postAuthorComment,
PROCESS_ENV,
runAgentCommand,
WORKSPACE,
} from "./run-context.ts";
import type { RuntimeProfile } from "./runtime.ts";
interface CodexAttempt {
status: CommandStatus;
error: string;
}
async function collectText(stream: Readable, append: (chunk: string) => void): Promise<void> {
const decoder = new TextDecoder();
for await (const chunk of chunks(stream)) {
append(decoder.decode(chunk, { stream: true }));
}
append(decoder.decode());
}
// Polls every 100 ms until the login prints something, failing if it exits first.
async function pollForInstructions(
shownText: () => string,
status: Promise<CommandStatus>,
): Promise<void> {
if (shownText().trim() !== "") {
return;
}
const exited = await Promise.race([status.then(() => true), sleep(100, false)]);
if (exited) {
throw new AgentAuthError("AI bot login produced no instructions");
}
await pollForInstructions(shownText, status);
}
async function codexDeviceLogin(
profile: RuntimeProfile,
environment: Record<string, string>,
): Promise<void> {
const login = agentCommand(profile, "codex", ["login", "--device-auth"], {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
stdout: "piped",
stderr: "piped",
}).spawn();
let shown = "";
const append = (chunk: string): void => {
shown += chunk;
};
const drained = Promise.all([
collectText(login.stdout, append),
collectText(login.stderr, append),
]);
await sleep(3000);
await pollForInstructions(() => shown, login.status);
await postAuthorComment(withProfile(shown, profile));
const [result] = await Promise.all([login.status, drained]);
if (!result.success) {
throw new AgentAuthError(`codex login exited with ${result.code}`);
}
}
async function codexApiLogin(
profile: RuntimeProfile,
environment: Record<string, string>,
token: string,
): Promise<void> {
const login = agentCommand(profile, "codex", ["login", "--with-api-key"], {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
stdin: "piped",
stderr: "piped",
}).spawn();
await endWith(login.stdin, `${token}\n`);
const [status, error] = await Promise.all([login.status, text(login.stderr)]);
if (!status.success) {
throw new Error(error.trim() || `codex login exited with ${status.code}`);
}
}
async function signIn(
profile: RuntimeProfile,
environment: Record<string, string>,
authentication: CodexAuthentication,
): Promise<void> {
if (authentication.kind === "api-key") {
await codexApiLogin(profile, environment, authentication.token);
return;
}
const loggedIn = await agentCommand(profile, "codex", ["login", "status"], {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
}).output();
if (!loggedIn.success) {
await codexDeviceLogin(profile, environment);
}
}
async function execCodex(
profile: RuntimeProfile,
environment: Record<string, string>,
prompt: string,
outputPath: string,
): Promise<CodexAttempt> {
const args = [
"exec",
"--model",
profile.model,
"--dangerously-bypass-approvals-and-sandbox",
"--output-last-message",
outputPath,
prompt,
];
const codex = agentCommand(profile, "codex", args, {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
stdout: "inherit",
stderr: "piped",
}).spawn();
const decoder = new TextDecoder();
let error = "";
for await (const chunk of chunks(codex.stderr)) {
await writeChunk(process.stderr, chunk);
error += decoder.decode(chunk, { stream: true });
}
error += decoder.decode();
// Codex echoes the prompt, whose comments may quote auth errors.
return { status: await codex.status, error: error.replaceAll(prompt, "") };
}
export async function runCodex(
profile: RuntimeProfile,
prompt: string,
authentication: CodexAuthentication,
): Promise<string> {
const token = authentication.kind === "api-key" ? authentication.token : undefined;
const environment = agentEnvironment(PROCESS_ENV, profile, token);
await signIn(profile, environment, authentication);
const outputPath = await makeProfileFile(profile, "result.md");
const attempt = (): Promise<CodexAttempt> => execCodex(profile, environment, prompt, outputPath);
const result =
authentication.kind === "api-key"
? await attempt()
: await retryInvalidToken(attempt, async () => {
await runAgentCommand(profile, "codex", ["logout"]);
await codexDeviceLogin(profile, environment);
});
if (!result.status.success) {
const failure = new Error(result.error.trim() || `codex exited with ${result.status.code}`);
if (isCodexAuthFailure(result.error)) {
throw new AgentAuthError("codex sign-in failed", { cause: failure });
}
throw failure;
}
return outputPath;
}
-46
View File
@@ -1,46 +0,0 @@
// Runs OpenCode for one profile and returns its final visible text.
import process from "node:process";
import { chunks, writeChunk } from "./command.ts";
import { agentCommand } from "./isolation.ts";
import {
collectOpenCodeOutput,
openCodeArgs,
openCodeError,
parseOpenCodeEvents,
} from "./opencode.ts";
import { agentEnvironment } from "./profiles.ts";
import { PROCESS_ENV, WORKSPACE } from "./run-context.ts";
import type { RuntimeProfile } from "./runtime.ts";
export async function runOpenCode(
profile: RuntimeProfile,
token: string,
prompt: string,
): Promise<string> {
const child = agentCommand(profile, "opencode", openCodeArgs(profile.model, prompt), {
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
clearEnv: true,
stdout: "piped",
stderr: "piped",
}).spawn();
const collected = collectOpenCodeOutput(
chunks(child.stdout),
chunks(child.stderr),
async (chunk) => {
await writeChunk(process.stdout, chunk);
},
async (chunk) => {
await writeChunk(process.stderr, chunk);
},
);
const [status, streams] = await Promise.all([child.status, collected]);
const error = streams.stderr.trim();
if (!status.success) {
throw (
openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${status.code}`)
);
}
return parseOpenCodeEvents(streams.stdout);
}
-30
View File
@@ -1,30 +0,0 @@
// Dispatches one attempt to the agent CLI its profile names.
import { runClaude } from "./agent-claude.ts";
import { runCodex } from "./agent-codex.ts";
import { runOpenCode } from "./agent-opencode.ts";
import { codexAuthentication } from "./auth.ts";
import type { CodexAuthentication } from "./auth.ts";
import type { RuntimeProfile } from "./runtime.ts";
export interface AgentAnswer {
body?: string;
resultPath?: string;
}
// Runs the selected agent; Codex leaves its answer in a file, the others return it.
export async function runAgent(
profile: RuntimeProfile,
token: string | undefined,
prompt: string,
authentication: CodexAuthentication | null,
): Promise<AgentAnswer> {
if (profile.type === "claude") {
return { body: await runClaude(profile, token ?? "", prompt) };
}
if (profile.type === "codex") {
return {
resultPath: await runCodex(profile, prompt, authentication ?? codexAuthentication(token)),
};
}
return { body: await runOpenCode(profile, token ?? "", prompt) };
}
+6 -6
View File
@@ -9,7 +9,7 @@ import {
retryInvalidToken, retryInvalidToken,
stageCodexAuth, stageCodexAuth,
} from "./auth.ts"; } from "./auth.ts";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "./testing.ts"; import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
test("retries once after an invalid refresh token", async () => { test("retries once after an invalid refresh token", async () => {
let attempts = 0; let attempts = 0;
@@ -17,7 +17,7 @@ test("retries once after an invalid refresh token", async () => {
const result = await retryInvalidToken( const result = await retryInvalidToken(
() => () =>
Promise.resolve({ Promise.resolve({
status: { success: false }, code: 1,
error: attempts++ === 0 ? "ERROR: invalid_refresh_token" : "still failed", error: attempts++ === 0 ? "ERROR: invalid_refresh_token" : "still failed",
}), }),
() => { () => {
@@ -40,7 +40,7 @@ test("does not retry an unrelated failure", async () => {
() => { () => {
attempts++; attempts++;
return Promise.resolve({ return Promise.resolve({
status: { success: false }, code: 1,
error: "rate limited", error: "rate limited",
}); });
}, },
@@ -86,7 +86,7 @@ test("re-logs in after the job 9837 revoked-session stderr", async () => {
const result = await retryInvalidToken( const result = await retryInvalidToken(
() => () =>
Promise.resolve({ Promise.resolve({
status: { success: attempts++ > 0 }, code: attempts++ > 0 ? 0 : 1,
error: attempts === 1 ? REVOKED_CODEX.join("\n") : "", error: attempts === 1 ? REVOKED_CODEX.join("\n") : "",
}), }),
() => { () => {
@@ -95,9 +95,9 @@ test("re-logs in after the job 9837 revoked-session stderr", async () => {
}, },
); );
expect({ relogins, success: result.status.success }).toStrictEqual({ expect({ relogins, code: result.code }).toStrictEqual({
relogins: 1, relogins: 1,
success: true, code: 0,
}); });
}); });
+10 -15
View File
@@ -48,27 +48,18 @@ export function isClaudeAuthFailure(result: ClaudeFailureFields): boolean {
); );
} }
export async function retryInvalidToken<T extends { status: { success: boolean }; error: string }>( export async function retryInvalidToken<T extends { code: number; error: string }>(
attempt: () => Promise<T>, attempt: () => Promise<T>,
relogin: () => Promise<void>, relogin: () => Promise<void>,
): Promise<T> { ): Promise<T> {
let result = await attempt(); let result = await attempt();
if (!result.status.success && isCodexAuthFailure(result.error)) { if (result.code !== 0 && isCodexAuthFailure(result.error)) {
await relogin(); await relogin();
result = await attempt(); result = await attempt();
} }
return result; return result;
} }
function hasCodexTokens(tokens: unknown): boolean {
return (
isObject(tokens) &&
["access_token", "refresh_token", "id_token", "account_id"].every(
(name) => typeof tokens[name] === "string" && tokens[name] !== "",
)
);
}
async function readCodexAuth(path: string): Promise<string> { async function readCodexAuth(path: string): Promise<string> {
const stat = await lstat(path); const stat = await lstat(path);
if (!stat.isFile() || stat.isSymbolicLink()) { if (!stat.isFile() || stat.isSymbolicLink()) {
@@ -84,11 +75,15 @@ async function readCodexAuth(path: string): Promise<string> {
} catch { } catch {
throw new Error("invalid Codex login"); throw new Error("invalid Codex login");
} }
const tokens = isObject(value) ? value["tokens"] : undefined;
if ( if (
!isObject(value) || !isObject(value) ||
typeof value.auth_mode !== "string" || typeof value["auth_mode"] !== "string" ||
value.auth_mode === "" || value["auth_mode"] === "" ||
!hasCodexTokens(value.tokens) !isObject(tokens) ||
!["access_token", "refresh_token", "id_token", "account_id"].every(
(name) => typeof tokens[name] === "string" && tokens[name] !== "",
)
) { ) {
throw new Error("invalid Codex login"); throw new Error("invalid Codex login");
} }
@@ -116,7 +111,7 @@ export async function stageCodexAuth(
return stagedPath; return stagedPath;
} }
// Creates an empty private file under a fresh name, as Deno.makeTempFile did. // Creates an empty private file under a fresh name.
async function makeTempFile(directory: string): Promise<string> { async function makeTempFile(directory: string): Promise<string> {
const path = join(directory, `.tmp-${randomUUID()}`); const path = join(directory, `.tmp-${randomUUID()}`);
const handle = await open(path, "wx", 0o600); const handle = await open(path, "wx", 0o600);
+35
View File
@@ -0,0 +1,35 @@
import { spawn } from "node:child_process";
import { expect, test } from "vite-plus/test";
import { isNotFound } from "./errors.ts";
import { exitCode } from "./child.ts";
test("reports the exit code", async () => {
expect(await exitCode(spawn("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" }))).toBe(0);
expect(await exitCode(spawn("/bin/sh", ["-c", "exit 3"], { stdio: "ignore" }))).toBe(3);
});
test("reports a signal as 128 plus its number", async () => {
const child = spawn("/bin/sh", ["-c", "kill -KILL $$"], { stdio: "ignore" });
expect(await exitCode(child)).toBe(137);
});
test("resolves on exit while a grandchild still holds the output open", async () => {
const child = spawn("/bin/sh", ["-c", "sleep 2 & exit 5"], { stdio: ["ignore", "pipe", "pipe"] });
const started = Date.now();
expect(await exitCode(child)).toBe(5);
expect(Date.now() - started).toBeLessThan(1500);
child.stdout.destroy();
child.stderr.destroy();
});
test("rejects when the command does not exist", async () => {
const child = spawn("/nonexistent/command", [], { stdio: "ignore" });
const code = exitCode(child);
// The rejection must stay unhandled-safe until it is awaited.
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
await expect(code).rejects.toSatisfy(isNotFound);
});
+30
View File
@@ -0,0 +1,30 @@
// What the child process call sites share: the exit status, read right after
// `spawn` so the exit event cannot pass unseen while the caller reads the
// streams, and echoing output without outrunning a slow reader.
import type { ChildProcess } from "node:child_process";
import { once } from "node:events";
import { constants } from "node:os";
import type { Writable } from "node:stream";
// Resolves once the process exits, before its streams close, with its exit
// code, or with 128 plus the signal number if a signal killed it, as a shell
// reports. A process that fails to start rejects with the spawn error.
export function exitCode(child: ChildProcess): Promise<number> {
const code = new Promise<number>((resolve, reject) => {
child.once("error", reject);
child.once("exit", (exit, signal) => {
resolve(exit ?? (signal === null ? 1 : 128 + constants.signals[signal]));
});
});
// Callers await it after draining the streams; a failed start must not be
// reported as an unhandled rejection before then.
code.catch(() => {});
return code;
}
// Writes a chunk, waiting for the stream to drain when its buffer is full.
export async function echo(stream: Writable, chunk: string | Uint8Array): Promise<void> {
if (!stream.write(chunk)) {
await once(stream, "drain");
}
}
-160
View File
@@ -1,160 +0,0 @@
// A child process with the call shapes the runner was reviewed with: `output()`
// collects both streams, `spawn()` hands them out, and `clearEnv` passes only
// the given environment. Streams default as Deno's did: stdin is inherited,
// stdout and stderr are piped for `output()` and inherited for `spawn()`.
import { spawn as spawnProcess } from "node:child_process";
import type { ChildProcess, StdioOptions } from "node:child_process";
import { Buffer } from "node:buffer";
import { constants } from "node:os";
import process from "node:process";
import type { Readable, Writable } from "node:stream";
import { buffer } from "node:stream/consumers";
import { finished } from "node:stream/promises";
export type StdioMode = "inherit" | "piped" | "null";
export interface CommandOptions {
args?: string[];
cwd?: string;
env?: Record<string, string>;
clearEnv?: boolean;
uid?: number;
gid?: number;
stdin?: StdioMode;
stdout?: StdioMode;
stderr?: StdioMode;
}
export interface CommandStatus {
success: boolean;
code: number;
}
export interface CommandOutput extends CommandStatus {
stdout: Uint8Array;
stderr: Uint8Array;
}
export interface CommandChild {
status: Promise<CommandStatus>;
stdin: Writable;
stdout: Readable;
stderr: Readable;
}
const STDIO: Record<StdioMode, "inherit" | "pipe" | "ignore"> = {
inherit: "inherit",
piped: "pipe",
null: "ignore",
};
function signalNumber(signal: NodeJS.Signals): number {
return constants.signals[signal];
}
function startProcess(
command: string,
options: CommandOptions,
outputDefault: StdioMode,
): ChildProcess {
const stdio: StdioOptions = [
STDIO[options.stdin ?? "inherit"],
STDIO[options.stdout ?? outputDefault],
STDIO[options.stderr ?? outputDefault],
];
const environment = options.clearEnv === true ? options.env : { ...process.env, ...options.env };
return spawnProcess(command, options.args ?? [], {
stdio,
...(options.cwd !== undefined && { cwd: options.cwd }),
...(environment !== undefined && { env: environment }),
...(options.uid !== undefined && { uid: options.uid }),
...(options.gid !== undefined && { gid: options.gid }),
});
}
// Settles when the process exits, before its streams close, as Deno's did. A
// signal-killed process reports 128 plus the signal number, as a shell does.
function waitForStatus(child: ChildProcess): Promise<CommandStatus> {
return new Promise((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code, signal) => {
const exitCode = code ?? (signal === null ? 1 : 128 + signalNumber(signal));
resolve({ success: exitCode === 0, code: exitCode });
});
});
}
function piped<T>(stream: T | null, name: string): T {
if (stream === null) {
throw new TypeError(`${name} is not piped`);
}
return stream;
}
function collect(stream: Readable | null): Promise<Uint8Array> {
return stream === null ? Promise.resolve(new Uint8Array()) : buffer(stream);
}
// A stream's chunks as bytes, whether or not it has an encoding set.
export async function* chunks(stream: Readable): AsyncGenerator<Uint8Array> {
for await (const chunk of stream) {
const value: unknown = chunk;
if (value instanceof Uint8Array) {
yield value;
} else if (typeof value === "string") {
yield Buffer.from(value);
}
}
}
export function spawn(command: string, options: CommandOptions = {}): CommandChild {
const child = startProcess(command, options, "inherit");
const status = waitForStatus(child);
// A failed start rejects `status`; the caller sees it once it awaits that.
status.catch(() => {});
return {
status,
get stdin(): Writable {
return piped(child.stdin, "stdin");
},
get stdout(): Readable {
return piped(child.stdout, "stdout");
},
get stderr(): Readable {
return piped(child.stderr, "stderr");
},
};
}
export async function output(
command: string,
options: CommandOptions = {},
): Promise<CommandOutput> {
const child = startProcess(command, options, "piped");
const [status, stdout, stderr] = await Promise.all([
waitForStatus(child),
collect(child.stdout),
collect(child.stderr),
]);
return { ...status, stdout, stderr };
}
// Writes one chunk and settles once the stream has taken it, as Deno's
// `Deno.stdout.write` did.
export async function writeChunk(stream: Writable, chunk: Uint8Array): Promise<void> {
await new Promise<void>((resolve, reject) => {
stream.write(chunk, (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}
// Writes the last chunk and settles once the stream has flushed and closed.
export async function endWith(stream: Writable, chunk: string): Promise<void> {
stream.end(chunk);
await finished(stream);
}
+1 -2
View File
@@ -1,5 +1,4 @@
// Node reports a missing file as ENOENT and a missing process as ESRCH, where // A missing file or a missing process: Node reports them as ENOENT and ESRCH.
// Deno threw `Deno.errors.NotFound` for both.
export function isNotFound(error: unknown): boolean { export function isNotFound(error: unknown): boolean {
if (!(error instanceof Error) || !("code" in error)) { if (!(error instanceof Error) || !("code" in error)) {
return false; return false;
+36 -36
View File
@@ -1,16 +1,13 @@
import { spawnSync } from "node:child_process";
import type { SpawnSyncReturns } from "node:child_process";
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { chmod, chown, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { chmod, chown, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { isNotFound } from "./errors.ts"; import { isNotFound } from "./errors.ts";
import { import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from "./isolation.ts";
agentCommand,
isRunnableProfileProcess,
killProfileProcesses,
readProfileFile,
} from "./isolation.ts";
import type { RuntimeProfile } from "./runtime.ts"; import type { RuntimeProfile } from "./runtime.ts";
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "./testing.ts"; import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "#testing";
const profile = (uid: number, home: string): RuntimeProfile => ({ const profile = (uid: number, home: string): RuntimeProfile => ({
index: uid - 20_000, index: uid - 20_000,
@@ -22,6 +19,23 @@ const profile = (uid: number, home: string): RuntimeProfile => ({
tokenFile: null, tokenFile: null,
}); });
// Runs a command as the profile's user from the root directory. Without `env`
// it inherits this process's environment.
function runAs(
selected: RuntimeProfile,
command: string,
args: string[],
env?: Record<string, string>,
): SpawnSyncReturns<string> {
return spawnSync(command, args, {
cwd: "/",
uid: selected.uid,
gid: selected.gid,
encoding: "utf8",
...(env !== undefined && { env }),
});
}
test("zombie process entries are already quiescent", () => { test("zombie process entries are already quiescent", () => {
const status = "State:\tZ (zombie)\nUid:\t20010\t20010\t20010\t20010\n"; const status = "State:\tZ (zombie)\nUid:\t20010\t20010\t20010\t20010\n";
expect(isRunnableProfileProcess(status, 20_010)).toBe(false); expect(isRunnableProfileProcess(status, 20_010)).toBe(false);
@@ -60,20 +74,14 @@ test.skipIf(!IS_ROOT)("a profile process can read only its own private files", a
await writeFile(`${root}/reviewer`, "root", { mode: 0o600 }); await writeFile(`${root}/reviewer`, "root", { mode: 0o600 });
const selected = profile(20_000, homeA); const selected = profile(20_000, homeA);
const own = await agentCommand(selected, "/bin/cat", [`${homeA}/token`], { const own = runAs(selected, "/bin/cat", [`${homeA}/token`]);
cwd: "/", const sibling = runAs(selected, "/bin/cat", [`${homeB}/token`]);
}).output(); const reviewer = runAs(selected, "/bin/cat", [`${root}/reviewer`]);
const sibling = await agentCommand(selected, "/bin/cat", [`${homeB}/token`], {
cwd: "/",
}).output();
const reviewer = await agentCommand(selected, "/bin/cat", [`${root}/reviewer`], {
cwd: "/",
}).output();
expect(new TextDecoder().decode(own.stdout)).toBe("own"); expect(own.stdout).toBe("own");
expect(own.success).toBe(true); expect(own.status).toBe(0);
expect(sibling.success).toBe(false); expect(sibling.status).not.toBe(0);
expect(reviewer.success).toBe(false); expect(reviewer.status).not.toBe(0);
} finally { } finally {
await rm(root, { recursive: true }); await rm(root, { recursive: true });
} }
@@ -81,13 +89,8 @@ test.skipIf(!IS_ROOT)("a profile process can read only its own private files", a
test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async () => { test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async () => {
const selected = profile(20_010, "/tmp"); const selected = profile(20_010, "/tmp");
const launched = await agentCommand( const launched = runAs(selected, "/bin/sh", ["-c", "sleep 60 >/dev/null 2>&1 & echo $!"]);
selected, const pid = Number(launched.stdout.trim());
"/bin/sh",
["-c", "sleep 60 >/dev/null 2>&1 & echo $!"],
{ cwd: "/" },
).output();
const pid = Number(new TextDecoder().decode(launched.stdout).trim());
expect(Number.isInteger(pid)).toBe(true); expect(Number.isInteger(pid)).toBe(true);
await killProfileProcesses(selected.uid); await killProfileProcesses(selected.uid);
@@ -99,14 +102,11 @@ test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async ()
expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true); expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true);
}); });
test.skipIf(!IS_ROOT)("the agent process cannot read its coordinator environment", async () => { test.skipIf(!IS_ROOT)("the agent process cannot read its coordinator environment", () => {
const selected = profile(20_020, "/tmp"); const selected = profile(20_020, "/tmp");
const result = await agentCommand(selected, "/usr/bin/cat", [`/proc/${process.pid}/environ`], { const result = runAs(selected, "/usr/bin/cat", [`/proc/${process.pid}/environ`], {
cwd: "/", PARENT_SECRET: "must-not-cross-boundary",
env: { PARENT_SECRET: "must-not-cross-boundary" }, });
clearEnv: true,
stderr: "piped", expect(result.status).not.toBe(0);
}).output();
expect(result.success).toBe(false);
}); });
+6 -36
View File
@@ -2,31 +2,9 @@ import { lstat, readdir, readFile } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises"; import { setTimeout as sleep } from "node:timers/promises";
import { output, spawn } from "./command.ts";
import type { CommandChild, CommandOptions, CommandOutput } from "./command.ts";
import { isNotFound } from "./errors.ts"; import { isNotFound } from "./errors.ts";
import type { RuntimeProfile } from "./runtime.ts"; import type { RuntimeProfile } from "./runtime.ts";
export type AgentCommandOptions = Omit<CommandOptions, "args" | "uid" | "gid">;
export interface AgentCommand {
output(): Promise<CommandOutput>;
spawn(): CommandChild;
}
export function agentCommand(
profile: RuntimeProfile,
command: string,
args: string[],
options: AgentCommandOptions = {},
): AgentCommand {
const full: CommandOptions = { ...options, args, uid: profile.uid, gid: profile.gid };
return {
output: () => output(command, full),
spawn: () => spawn(command, full),
};
}
export function isRunnableProfileProcess(status: string, uid: number): boolean { export function isRunnableProfileProcess(status: string, uid: number): boolean {
const owner = /^Uid:\s+(\d+)/mu.exec(status)?.[1]; const owner = /^Uid:\s+(\d+)/mu.exec(status)?.[1];
const state = /^State:\s+(\w)/mu.exec(status)?.[1]; const state = /^State:\s+(\w)/mu.exec(status)?.[1];
@@ -49,27 +27,19 @@ async function profileProcessIds(uid: number): Promise<number[]> {
.map(Number); .map(Number);
} }
function killProcess(pid: number): void {
try {
process.kill(pid, "SIGKILL");
} catch (error) {
if (!isNotFound(error)) {
throw error;
}
}
}
// Kills the UID's processes every 10 ms until none is left, for up to 100 rounds. // Kills the UID's processes every 10 ms until none is left, for up to 100 rounds.
export async function killProfileProcesses(uid: number, attempt = 0): Promise<void> { export async function killProfileProcesses(uid: number, attempt = 0): Promise<void> {
if (attempt === 100) { if (attempt === 100) {
throw new Error(`profile UID ${uid} still owns processes after cleanup`); throw new Error(`profile UID ${uid} still owns processes after cleanup`);
} }
const ids = await profileProcessIds(uid); const ids = await profileProcessIds(uid);
if (ids.length === 0) { if (ids.length === 0) return;
return;
}
for (const pid of ids) { for (const pid of ids) {
killProcess(pid); try {
process.kill(pid, "SIGKILL");
} catch (error) {
if (!isNotFound(error)) throw error;
}
} }
await sleep(10); await sleep(10);
await killProfileProcesses(uid, attempt + 1); await killProfileProcesses(uid, attempt + 1);
-114
View File
@@ -1,114 +0,0 @@
// Reads what the agent phase left behind: its outcome, and the review and
// anchors files it wrote into a profile home.
import type { Stats } from "node:fs";
import { lstat, readFile } from "node:fs/promises";
import { join } from "node:path";
import { isNotFound } from "./errors.ts";
import { isObject } from "./json.ts";
import { isFailureReason, isProfileType } from "./profiles.ts";
import type { ProfileFailure } from "./profiles.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
export interface Anchor {
path: string;
line: number;
side: "new" | "old";
body: string;
}
function parseAnchor(entry: unknown, at: string): Anchor {
if (!isObject(entry)) {
throw new TypeError(`${at}: not an object`);
}
const { path, line, side = "new", body } = entry;
if (typeof path !== "string" || path === "") {
throw new TypeError(`${at}.path: expected a repository path`);
}
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) {
throw new TypeError(`${at}.line: expected a line number`);
}
if (side !== "new" && side !== "old") {
throw new Error(`${at}.side: expected "new" or "old"`);
}
if (typeof body !== "string" || body.trim() === "") {
throw new TypeError(`${at}.body: expected the finding`);
}
return { path, line, side, body };
}
export function parseAnchors(text: string, sourcePath: string): Anchor[] {
const entries: unknown = JSON.parse(text);
if (!Array.isArray(entries)) {
throw new TypeError(`${sourcePath}: not an array`);
}
return entries.map((entry: unknown, index) => parseAnchor(entry, `${sourcePath}[${index}]`));
}
// Only known labels reach the comment, never raw agent output.
function knownFailures(failures: unknown): ProfileFailure[] {
return (Array.isArray(failures) ? failures : []).flatMap((entry: unknown) => {
if (!isObject(entry)) {
return [];
}
const { type, model, reason } = entry;
return isProfileType(type) && typeof model === "string" && isFailureReason(reason)
? [{ type, model, reason }]
: [];
});
}
export async function readOutcome(path: string): Promise<RuntimeOutcome> {
const value: unknown = JSON.parse(await readFile(path, "utf8"));
if (!isObject(value) || !("ok" in value)) {
throw new Error("invalid agent outcome");
}
if (value.ok === false) {
return { ok: false, failures: knownFailures(value.failures) };
}
const { ok, body, reviewPath, anchorsPath, profile } = value;
if (
ok !== true ||
typeof body !== "string" ||
typeof reviewPath !== "string" ||
typeof anchorsPath !== "string" ||
!isObject(profile) ||
!isProfileType(profile.type) ||
typeof profile.model !== "string"
) {
throw new Error("invalid agent outcome");
}
return {
ok: true,
body,
reviewPath,
anchorsPath,
profile: { type: profile.type, model: profile.model },
};
}
export async function readAgentArtifact(
manifest: RuntimeManifest,
path: string,
name: string,
): Promise<string | null> {
const profile = manifest.profiles.find((entry) => join(entry.home, name) === path);
if (profile === undefined) throw new Error(`${name}: outside profile home`);
let stat: Stats;
try {
stat = await lstat(path);
} catch (error) {
if (isNotFound(error)) return null;
throw error;
}
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.uid !== profile.uid ||
(stat.mode & 0o077) !== 0
) {
throw new Error(`${name}: expected a profile-owned regular private file`);
}
const text = await readFile(path, "utf8");
return text === "" ? null : text;
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { isNotFound } from "./errors.ts";
import { postRuntime } from "./post.ts"; import { postRuntime } from "./post.ts";
import type { PostContext } from "./post.ts"; import type { PostContext } from "./post.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts"; import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "./testing.ts"; import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
const context: PostContext = { const context: PostContext = {
apiUrl: "https://gitea.example/api/v1", apiUrl: "https://gitea.example/api/v1",
+141 -86
View File
@@ -1,11 +1,12 @@
import { readFile, rm } from "node:fs/promises"; import type { Stats } from "node:fs";
import { lstat, readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process"; import process from "node:process";
import { isNotFound } from "./errors.ts"; import { isNotFound } from "./errors.ts";
import { parseAnchors, readAgentArtifact, readOutcome } from "./post-artifacts.ts"; import { isObject } from "./json.ts";
import type { Anchor } from "./post-artifacts.ts";
import { failureFooter, withProfile } from "./profiles.ts"; import { failureFooter, withProfile } from "./profiles.ts";
import type { ProfileFailure } from "./profiles.ts"; import type { FailureReason, ProfileFailure } from "./profiles.ts";
import { readRuntimeManifest } from "./runtime.ts"; import { readRuntimeManifest } from "./runtime.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts"; import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
@@ -16,15 +17,11 @@ export interface PostContext {
eventName: string; eventName: string;
} }
type SuccessOutcome = Extract<RuntimeOutcome, { ok: true }>; interface Anchor {
path: string;
type GiteaRequest = (path: string, body: unknown) => Promise<void>; line: number;
side: "new" | "old";
// The reviewer's view of one issue or pull request. body: string;
interface Publisher {
context: PostContext;
request: GiteaRequest;
postComment(body: string): Promise<void>;
} }
const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu"); const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu");
@@ -33,16 +30,122 @@ const VERDICTS: Record<string, [event: string, mark: string]> = {
Approved: ["APPROVED", "✅"], Approved: ["APPROVED", "✅"],
"Changes requested": ["REQUEST_CHANGES", "🛑"], "Changes requested": ["REQUEST_CHANGES", "🛑"],
}; };
const PROFILE_TYPES: Set<unknown> = new Set(["claude", "codex", "opencode"]);
const FAILURE_REASONS: Set<unknown> = new Set(["sign-in failed", "usage exhausted", "failed"]);
const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value);
const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value);
const failureComment = (failures: ProfileFailure[]): string => const failureComment = (failures: ProfileFailure[]): string =>
`AI bot failed.\n\n${failureFooter(failures)}`; `AI bot failed.\n\n${failureFooter(failures)}`;
class GiteaRequestError extends Error {} class GiteaRequestError extends Error {}
function giteaRequest(apiUrl: string, reviewerToken: string, fetcher: typeof fetch): GiteaRequest { function parseAnchors(text: string, sourcePath: string): Anchor[] {
return async (path, body) => { const entries: unknown = JSON.parse(text);
if (!Array.isArray(entries)) throw new TypeError(`${sourcePath}: not an array`);
return entries.map((entry: unknown, index) => {
const at = `${sourcePath}[${index}]`;
if (!isObject(entry)) {
throw new TypeError(`${at}: not an object`);
}
const { path, line, side = "new", body } = entry;
if (typeof path !== "string" || path === "") {
throw new TypeError(`${at}.path: expected a repository path`);
}
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) {
throw new TypeError(`${at}.line: expected a line number`);
}
if (side !== "new" && side !== "old") {
throw new Error(`${at}.side: expected "new" or "old"`);
}
if (typeof body !== "string" || body.trim() === "") {
throw new TypeError(`${at}.body: expected the finding`);
}
return { path, line, side, body };
});
}
async function readOutcome(path: string): Promise<RuntimeOutcome> {
const value: unknown = JSON.parse(await readFile(path, "utf8"));
if (!isObject(value) || !("ok" in value)) {
throw new Error("invalid agent outcome");
}
const outcome = value;
if (outcome["ok"] === false) {
// Only known labels reach the comment, never raw agent output.
const failures: unknown[] = Array.isArray(outcome["failures"]) ? outcome["failures"] : [];
return {
ok: false,
failures: failures.flatMap((entry: unknown) => {
const { type, model, reason } = isObject(entry) ? entry : {};
return isProfileType(type) && typeof model === "string" && isFailureReason(reason)
? [{ type, model, reason }]
: [];
}),
};
}
if (
outcome["ok"] !== true ||
typeof outcome["body"] !== "string" ||
typeof outcome["reviewPath"] !== "string" ||
typeof outcome["anchorsPath"] !== "string" ||
!isObject(outcome["profile"])
) {
throw new Error("invalid agent outcome");
}
const profile = outcome["profile"];
if (!isProfileType(profile["type"]) || typeof profile["model"] !== "string") {
throw new Error("invalid agent outcome");
}
return {
ok: true,
body: outcome["body"],
reviewPath: outcome["reviewPath"],
anchorsPath: outcome["anchorsPath"],
profile: {
type: profile["type"],
model: profile["model"],
},
};
}
async function readAgentArtifact(
manifest: RuntimeManifest,
path: string,
name: string,
): Promise<string | null> {
const profile = manifest.profiles.find((entry) => join(entry.home, name) === path);
if (profile === undefined) throw new Error(`${name}: outside profile home`);
let stat: Stats;
try {
stat = await lstat(path);
} catch (error) {
if (isNotFound(error)) return null;
throw error;
}
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.uid !== profile.uid ||
(stat.mode & 0o077) !== 0
) {
throw new Error(`${name}: expected a profile-owned regular private file`);
}
const text = await readFile(path, "utf8");
return text === "" ? null : text;
}
export async function postRuntime(
manifestPath: string,
context: PostContext,
fetcher: typeof fetch = fetch,
): Promise<void> {
const manifest = await readRuntimeManifest(manifestPath);
try {
const reviewerToken = (await readFile(manifest.reviewerFile, "utf8")).trim();
const request = async (path: string, body: unknown): Promise<void> => {
let response: Response; let response: Response;
try { try {
response = await fetcher(`${apiUrl}/${path}`, { response = await fetcher(`${context.apiUrl}/${path}`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `token ${reviewerToken}`, Authorization: `token ${reviewerToken}`,
@@ -58,37 +161,39 @@ function giteaRequest(apiUrl: string, reviewerToken: string, fetcher: typeof fet
throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`); throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`);
} }
}; };
} const postComment = (body: string): Promise<void> =>
request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, {
body: stripAnsi(body),
});
async function readSuccess(
manifest: RuntimeManifest,
publisher: Publisher,
): Promise<SuccessOutcome> {
let outcome: RuntimeOutcome; let outcome: RuntimeOutcome;
try { try {
outcome = await readOutcome(manifest.outcomeFile); outcome = await readOutcome(manifest.outcomeFile);
} catch (error) { } catch (error) {
await publisher.postComment(failureComment([])); await postComment(failureComment([]));
throw new Error("agent phase failed", { cause: error }); throw new Error("agent phase failed", { cause: error });
} }
if (!outcome.ok) { if (!outcome.ok) {
await publisher.postComment(failureComment(outcome.failures)); await postComment(failureComment(outcome.failures));
throw new Error("agent phase failed"); throw new Error("agent phase failed");
} }
return outcome;
try {
const review = await readAgentArtifact(manifest, outcome.reviewPath, "review.md");
if (review === null) {
if (context.eventName === "pull_request") {
throw new Error("agent did not write a pull request review");
}
await postComment(withProfile(outcome.body, outcome.profile));
return;
} }
async function postReview(
publisher: Publisher,
outcome: SuccessOutcome,
review: string,
anchors: Anchor[],
): Promise<void> {
const { context, request } = publisher;
const [verdict = "", ...rest] = review.split("\n"); const [verdict = "", ...rest] = review.split("\n");
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"]; const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"];
const send = async (entries: Anchor[]): Promise<void> => { const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json");
await request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, { const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath);
const postReview = (entries: Anchor[]): Promise<void> =>
request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, {
body: stripAnsi( body: stripAnsi(
withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile), withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile),
), ),
@@ -100,68 +205,18 @@ async function postReview(
old_position: side === "old" ? line : 0, old_position: side === "old" ? line : 0,
})), })),
}); });
}; await postReview(anchors).catch(async (error: unknown) => {
await send(anchors).catch(async (error: unknown) => {
if (anchors.length === 0) throw error; if (anchors.length === 0) throw error;
console.error( console.error(
`inline comments rejected: ${error instanceof Error ? error.message : String(error)}`, `inline comments rejected: ${error instanceof Error ? error.message : String(error)}`,
); );
await send([]); await postReview([]);
}); });
}
async function publishResult(
manifest: RuntimeManifest,
outcome: SuccessOutcome,
publisher: Publisher,
): Promise<void> {
const review = await readAgentArtifact(manifest, outcome.reviewPath, "review.md");
if (review === null) {
if (publisher.context.eventName === "pull_request") {
throw new Error("agent did not write a pull request review");
}
await publisher.postComment(withProfile(outcome.body, outcome.profile));
return;
}
const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json");
const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath);
await postReview(publisher, outcome, review, anchors);
}
async function publish(
manifest: RuntimeManifest,
context: PostContext,
fetcher: typeof fetch,
): Promise<void> {
const reviewerToken = (await readFile(manifest.reviewerFile, "utf8")).trim();
const request = giteaRequest(context.apiUrl, reviewerToken, fetcher);
const publisher: Publisher = {
context,
request,
postComment: async (body) => {
await request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, {
body: stripAnsi(body),
});
},
};
const outcome = await readSuccess(manifest, publisher);
try {
await publishResult(manifest, outcome, publisher);
} catch (error) { } catch (error) {
if (error instanceof GiteaRequestError) throw error; if (error instanceof GiteaRequestError) throw error;
await publisher.postComment(failureComment([{ ...outcome.profile, reason: "failed" }])); await postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
throw error; throw error;
} }
}
export async function postRuntime(
manifestPath: string,
context: PostContext,
fetcher: typeof fetch = fetch,
): Promise<void> {
const manifest = await readRuntimeManifest(manifestPath);
try {
await publish(manifest, context, fetcher);
} finally { } finally {
await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => { await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => {
if (!isNotFound(error)) throw error; if (!isNotFound(error)) throw error;
+1 -3
View File
@@ -5,9 +5,7 @@ import { prepareRuntime } from "./runtime.ts";
const env = (name: string): string => { const env = (name: string): string => {
const value = process.env[name]; const value = process.env[name];
if (value === undefined || value === "") { if (value === undefined || value === "") throw new Error(`${name} is not set`);
throw new Error(`${name} is not set`);
}
return value; return value;
}; };
+3 -16
View File
@@ -1,18 +1,9 @@
export type ProfileType = "claude" | "codex" | "opencode";
export interface BotProfile { export interface BotProfile {
type: ProfileType; type: "claude" | "codex" | "opencode";
model: string; model: string;
token?: string; token?: string;
} }
const PROFILE_TYPES: Set<unknown> = new Set(["claude", "codex", "opencode"]);
const FAILURE_REASONS: Set<unknown> = new Set(["sign-in failed", "usage exhausted", "failed"]);
export function isProfileType(value: unknown): value is ProfileType {
return PROFILE_TYPES.has(value);
}
export function profileFooter(profile: Pick<BotProfile, "type" | "model">): string { export function profileFooter(profile: Pick<BotProfile, "type" | "model">): string {
return `> Profile: \`${profile.type}\` · \`${profile.model}\``; return `> Profile: \`${profile.type}\` · \`${profile.model}\``;
} }
@@ -23,10 +14,6 @@ export function withProfile(body: string, profile: Pick<BotProfile, "type" | "mo
export type FailureReason = "sign-in failed" | "usage exhausted" | "failed"; export type FailureReason = "sign-in failed" | "usage exhausted" | "failed";
export function isFailureReason(value: unknown): value is FailureReason {
return FAILURE_REASONS.has(value);
}
export type ProfileFailure = Pick<BotProfile, "type" | "model"> & { export type ProfileFailure = Pick<BotProfile, "type" | "model"> & {
reason: FailureReason; reason: FailureReason;
}; };
@@ -123,8 +110,8 @@ export function agentEnvironment(
token?: string, token?: string,
): Record<string, string> { ): Record<string, string> {
const result = sanitizedEnvironment(environment); const result = sanitizedEnvironment(environment);
result.HOME = profile.home; result["HOME"] = profile.home;
if (profile.type === "codex") result.CODEX_HOME = `${profile.home}/.codex`; if (profile.type === "codex") result["CODEX_HOME"] = `${profile.home}/.codex`;
if (token !== undefined && token !== "") { if (token !== undefined && token !== "") {
const tokenName = { const tokenName = {
claude: "CLAUDE_CODE_OAUTH_TOKEN", claude: "CLAUDE_CODE_OAUTH_TOKEN",
-79
View File
@@ -1,79 +0,0 @@
// The agent phase's environment and the helpers every agent runner shares. It
// is read once, when the runner starts, as the single-file runner did.
import { chmod, chown, writeFile } from "node:fs/promises";
import process from "node:process";
import { agentCommand } from "./isolation.ts";
import { agentEnvironment, sanitizedEnvironment } from "./profiles.ts";
import { readRuntimeManifest } from "./runtime.ts";
import type { RuntimeProfile } from "./runtime.ts";
export const env = (name: string): string => {
const value = process.env[name];
if (value === undefined) {
throw new Error(`${name} is not set`);
}
return value;
};
function definedEnvironment(): Record<string, string> {
const result: Record<string, string> = {};
for (const [name, value] of Object.entries(process.env)) {
if (value !== undefined) {
result[name] = value;
}
}
return result;
}
export const API = env("GITEA_API_URL");
export const REPO = env("GITEA_REPOSITORY");
export const INDEX = env("ISSUE_INDEX");
export const EVENT = env("EVENT_NAME");
const AUTHOR_TOKEN = env("GITEA_TOKEN");
export const WORKSPACE = env("GITHUB_WORKSPACE");
export const MANIFEST = await readRuntimeManifest(env("BOT_AGENTS_MANIFEST"));
export const PROCESS_ENV = sanitizedEnvironment(definedEnvironment());
export async function gitea(path: string, body?: unknown): Promise<Response> {
const response = await fetch(`${API}/${path}`, {
method: body === undefined ? "GET" : "POST",
headers: {
Authorization: `token ${AUTHOR_TOKEN}`,
"Content-Type": "application/json",
},
body: body === undefined ? null : JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`${path}: ${response.status} ${await response.text()}`);
}
return response;
}
export async function postAuthorComment(body: string): Promise<void> {
await gitea(`repos/${REPO}/issues/${INDEX}/comments`, { body });
}
export async function runAgentCommand(
profile: RuntimeProfile,
command: string,
args: string[],
token?: string,
): Promise<void> {
const { success, code } = await agentCommand(profile, command, args, {
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
clearEnv: true,
}).output();
if (!success) {
throw new Error(`${command} ${args[0]} exited with ${code}`);
}
}
export async function makeProfileFile(profile: RuntimeProfile, name: string): Promise<string> {
const path = `${profile.home}/${name}`;
await writeFile(path, "", { mode: 0o600 });
await chmod(path, 0o600);
await chown(path, profile.uid, profile.gid);
return path;
}
+387 -55
View File
@@ -1,28 +1,41 @@
// Runs one ordered agent profile. Credential decryption and reviewer posting // Runs one ordered agent profile. Credential decryption and reviewer posting
// live in separate processes so neither privileged secret reaches this runner. // live in separate processes so neither privileged secret reaches this runner.
import { chmod, readFile, writeFile } from "node:fs/promises"; import { spawn } from "node:child_process";
import { chmod, chown, readFile, writeFile } from "node:fs/promises";
import process from "node:process";
import { createInterface } from "node:readline";
import type { Readable } from "node:stream";
import { text as readText } from "node:stream/consumers";
import { finished } from "node:stream/promises";
import { setTimeout as sleep } from "node:timers/promises";
import { codexAuthentication, persistCodexAuth, stageCodexAuth } from "./auth.ts"; import {
import { runAgent } from "./agents.ts"; codexAuthentication,
import type { AgentAnswer } from "./agents.ts"; isClaudeAuthFailure,
import { output } from "./command.ts"; isCodexAuthFailure,
persistCodexAuth,
retryInvalidToken,
stageCodexAuth,
} from "./auth.ts";
import type { ClaudeFailureFields, CodexAuthentication } from "./auth.ts";
import { echo, exitCode } from "./child.ts";
import { killProfileProcesses, readProfileFile } from "./isolation.ts"; import { killProfileProcesses, readProfileFile } from "./isolation.ts";
import { isObject } from "./json.ts"; import { isObject } from "./json.ts";
import { runProfiles } from "./profiles.ts";
import type { ProfileFailure } from "./profiles.ts";
import { import {
API, collectOpenCodeOutput,
env, openCodeArgs,
EVENT, openCodeError,
gitea, parseOpenCodeEvents,
INDEX, } from "./opencode.ts";
makeProfileFile, import {
MANIFEST, AgentAuthError,
PROCESS_ENV, agentEnvironment,
REPO, runProfiles,
runAgentCommand, sanitizedEnvironment,
WORKSPACE, withProfile,
} from "./run-context.ts"; } from "./profiles.ts";
import type { ProfileFailure } from "./profiles.ts";
import { readRuntimeManifest } from "./runtime.ts";
import type { RuntimeOutcome, RuntimeProfile } from "./runtime.ts"; import type { RuntimeOutcome, RuntimeProfile } from "./runtime.ts";
interface AttemptResult { interface AttemptResult {
@@ -32,30 +45,71 @@ interface AttemptResult {
profile: Pick<RuntimeProfile, "type" | "model">; profile: Pick<RuntimeProfile, "type" | "model">;
} }
interface RenderedPrompt { // Claude Code's stream-json `result` event, in its own field names.
prompt: string; type ClaudeResult = ClaudeFailureFields &
reviewPath: string; Partial<Record<"is_error", boolean>> & {
anchorsPath: string; subtype: string;
};
const env = (name: string): string => {
const value = process.env[name];
if (value === undefined) throw new Error(`${name} is not set`);
return value;
};
function definedEnvironment(): Record<string, string> {
const result: Record<string, string> = {};
for (const [name, value] of Object.entries(process.env)) {
if (value !== undefined) {
result[name] = value;
}
}
return result;
} }
const API = env("GITEA_API_URL");
const REPO = env("GITEA_REPOSITORY");
const INDEX = env("ISSUE_INDEX");
const EVENT = env("EVENT_NAME");
const AUTHOR_TOKEN = env("GITEA_TOKEN");
const WORKSPACE = env("GITHUB_WORKSPACE");
const MANIFEST = await readRuntimeManifest(env("BOT_AGENTS_MANIFEST"));
const PROCESS_ENV = sanitizedEnvironment(definedEnvironment());
const RULES_PATH = "repos/commons/code-rules/raw/README.md"; const RULES_PATH = "repos/commons/code-rules/raw/README.md";
const CODEX_AUTH = "/root/.codex/auth.json"; const CODEX_AUTH = "/root/.codex/auth.json";
const SKILLS_INSTALL = "skills add obra/superpowers"; const SKILLS_INSTALL = "skills add obra/superpowers";
async function runRoot(command: string, args: string[]): Promise<void> { async function gitea(path: string, body?: unknown): Promise<Response> {
const { success, code } = await output(command, { args, env: PROCESS_ENV, clearEnv: true }); const response = await fetch(`${API}/${path}`, {
if (!success) { method: body === undefined ? "GET" : "POST",
throw new Error(`${command} ${args[0]} exited with ${code}`); headers: {
Authorization: `token ${AUTHOR_TOKEN}`,
"Content-Type": "application/json",
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`${path}: ${response.status} ${await response.text()}`);
} }
return response;
}
async function postAuthorComment(body: string): Promise<void> {
await gitea(`repos/${REPO}/issues/${INDEX}/comments`, { body });
}
async function runRoot(command: string, args: string[]): Promise<void> {
const code = await exitCode(spawn(command, args, { env: PROCESS_ENV, stdio: "ignore" }));
if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`);
} }
async function configureGitAuthor(): Promise<void> { async function configureGitAuthor(): Promise<void> {
const user: unknown = await (await gitea("user")).json(); const user: unknown = await (await gitea("user")).json();
if (!isObject(user) || typeof user.login !== "string" || typeof user.email !== "string") { if (!isObject(user) || typeof user["login"] !== "string" || typeof user["email"] !== "string") {
throw new Error("user: unexpected Gitea response"); throw new Error("user: unexpected Gitea response");
} }
await runRoot("git", ["config", "user.name", user.login]); await runRoot("git", ["config", "user.name", user["login"]]);
await runRoot("git", ["config", "user.email", user.email]); await runRoot("git", ["config", "user.email", user["email"]]);
} }
async function readProfileToken(profile: RuntimeProfile): Promise<string | undefined> { async function readProfileToken(profile: RuntimeProfile): Promise<string | undefined> {
@@ -63,6 +117,23 @@ async function readProfileToken(profile: RuntimeProfile): Promise<string | undef
return token; return token;
} }
async function runAgentCommand(
profile: RuntimeProfile,
command: string,
args: string[],
token?: string,
): Promise<void> {
const child = spawn(command, args, {
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
uid: profile.uid,
gid: profile.gid,
stdio: "ignore",
});
const code = await exitCode(child);
if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`);
}
async function prepareProfile(profile: RuntimeProfile): Promise<void> { async function prepareProfile(profile: RuntimeProfile): Promise<void> {
await runAgentCommand(profile, "git", [ await runAgentCommand(profile, "git", [
"config", "config",
@@ -84,21 +155,22 @@ async function prepareProfile(profile: RuntimeProfile): Promise<void> {
// One issue comment as the prompt quotes it. // One issue comment as the prompt quotes it.
function formatComment(comment: unknown): string { function formatComment(comment: unknown): string {
const { user, created_at: createdAt, body } = isObject(comment) ? comment : {}; const { user, created_at: createdAt, body } = isObject(comment) ? comment : {};
const login = isObject(user) ? user.login : undefined; const login = isObject(user) ? user["login"] : undefined;
return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`; return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`;
} }
async function promptValues( async function renderPrompt(
reviewPath: string, profile: RuntimeProfile,
anchorsPath: string, ): Promise<{ prompt: string; reviewPath: string; anchorsPath: string }> {
): Promise<Record<string, string>> { const reviewPath = await makeProfileFile(profile, "review.md");
const anchorsPath = await makeProfileFile(profile, "anchors.json");
const comments: unknown = await ( const comments: unknown = await (
await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`) await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`)
).json(); ).json();
if (!Array.isArray(comments)) { if (!Array.isArray(comments)) {
throw new TypeError("comments: unexpected Gitea response"); throw new TypeError("comments: unexpected Gitea response");
} }
return { const values: Record<string, string> = {
COMMENT: env("COMMENT"), COMMENT: env("COMMENT"),
ISSUE_COMMENTS: comments.map((comment: unknown) => formatComment(comment)).join("\n"), ISSUE_COMMENTS: comments.map((comment: unknown) => formatComment(comment)).join("\n"),
CODE_RULES: await (await gitea(RULES_PATH)).text(), CODE_RULES: await (await gitea(RULES_PATH)).text(),
@@ -109,12 +181,6 @@ async function promptValues(
REVIEW_PATH: reviewPath, REVIEW_PATH: reviewPath,
ANCHORS_PATH: anchorsPath, ANCHORS_PATH: anchorsPath,
}; };
}
async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
const reviewPath = await makeProfileFile(profile, "review.md");
const anchorsPath = await makeProfileFile(profile, "anchors.json");
const values = await promptValues(reviewPath, anchorsPath);
const template = await readFile(new URL("../prompt.md", import.meta.url), "utf8"); const template = await readFile(new URL("../prompt.md", import.meta.url), "utf8");
return { return {
prompt: template.replaceAll( prompt: template.replaceAll(
@@ -126,45 +192,311 @@ async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
}; };
} }
async function runClaude(profile: RuntimeProfile, token: string, prompt: string): Promise<string> {
const claude = spawn(
"claude",
[
"--print",
"--dangerously-skip-permissions",
"--model",
profile.model,
"--output-format",
"stream-json",
"--verbose",
prompt,
],
{
cwd: WORKSPACE,
env: {
...agentEnvironment(PROCESS_ENV, profile, token),
IS_SANDBOX: "1",
},
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
},
);
const status = exitCode(claude);
let result: ClaudeResult | undefined;
let error = "";
const copyError = (async (): Promise<void> => {
claude.stderr.setEncoding("utf8");
for await (const chunk of claude.stderr) {
const part = String(chunk);
error += part;
console.error(part);
}
})();
for await (const line of createInterface({ input: claude.stdout, crlfDelay: Infinity })) {
if (line !== "") {
const event: unknown = JSON.parse(line);
if (isObject(event)) {
const content = isObject(event["message"]) ? event["message"]["content"] : undefined;
for (const part of Array.isArray(content) ? content : []) {
if (isObject(part)) {
const text = part["thinking"] ?? part["text"] ?? part["name"];
if (typeof text === "string" && text !== "") {
console.log(text);
}
}
}
if (event["type"] === "result") {
const { is_error: isError, result: answer, subtype, api_error_status: apiStatus } = event;
result = {
is_error: typeof isError === "boolean" ? isError : undefined,
result: typeof answer === "string" ? answer : undefined,
subtype: String(subtype),
api_error_status:
typeof apiStatus === "number" || apiStatus === null ? apiStatus : undefined,
};
}
}
}
}
const [code] = await Promise.all([status, copyError]);
if (result?.is_error === true) {
const failure = new Error(result.result ?? `claude ended with ${result.subtype}`);
if (isClaudeAuthFailure(result)) {
throw new AgentAuthError("claude sign-in failed", { cause: failure });
}
throw failure;
}
if (result?.result === undefined) {
throw new Error(error.trim() || `claude ended with ${result?.subtype ?? "no result"}`);
}
if (code !== 0) {
throw new Error(error.trim() || `claude exited with ${code}`);
}
return result.result;
}
// Polls every 100 ms until the login prints something, failing if it exits first.
async function pollForInstructions(
shownText: () => string,
status: Promise<number>,
): Promise<void> {
if (shownText().trim() !== "") {
return;
}
const exited = await Promise.race([status.then(() => true), sleep(100, false)]);
if (exited) {
throw new AgentAuthError("AI bot login produced no instructions");
}
await pollForInstructions(shownText, status);
}
async function codexDeviceLogin(
profile: RuntimeProfile,
environment: Record<string, string>,
): Promise<void> {
const login = spawn("codex", ["login", "--device-auth"], {
cwd: WORKSPACE,
env: environment,
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
});
const status = exitCode(login);
let shown = "";
const collect = async (stream: Readable): Promise<void> => {
stream.setEncoding("utf8");
for await (const chunk of stream) {
shown += String(chunk);
}
};
const drained = Promise.all([collect(login.stdout), collect(login.stderr)]);
await sleep(3000);
await pollForInstructions(() => shown, status);
await postAuthorComment(withProfile(shown, profile));
const [code] = await Promise.all([status, drained]);
if (code !== 0) {
throw new AgentAuthError(`codex login exited with ${code}`);
}
}
async function codexApiLogin(
profile: RuntimeProfile,
environment: Record<string, string>,
token: string,
): Promise<void> {
const login = spawn("codex", ["login", "--with-api-key"], {
cwd: WORKSPACE,
env: environment,
uid: profile.uid,
gid: profile.gid,
stdio: ["pipe", "inherit", "pipe"],
});
const status = exitCode(login);
login.stdin.end(`${token}\n`);
await finished(login.stdin);
const [code, error] = await Promise.all([status, readText(login.stderr)]);
if (code !== 0) {
throw new Error(error.trim() || `codex login exited with ${code}`);
}
}
async function makeProfileFile(profile: RuntimeProfile, name: string): Promise<string> {
const path = `${profile.home}/${name}`;
await writeFile(path, "", { mode: 0o600 });
await chmod(path, 0o600);
await chown(path, profile.uid, profile.gid);
return path;
}
async function runCodex(
profile: RuntimeProfile,
prompt: string,
authentication: CodexAuthentication,
): Promise<string> {
const token = authentication.kind === "api-key" ? authentication.token : undefined;
const environment = agentEnvironment(PROCESS_ENV, profile, token);
if (authentication.kind === "api-key") {
await codexApiLogin(profile, environment, authentication.token);
} else {
const check = spawn("codex", ["login", "status"], {
cwd: WORKSPACE,
env: environment,
uid: profile.uid,
gid: profile.gid,
stdio: "ignore",
});
if ((await exitCode(check)) !== 0) await codexDeviceLogin(profile, environment);
}
const outputPath = await makeProfileFile(profile, "result.md");
const attempt = async (): Promise<{ code: number; error: string }> => {
const codex = spawn(
"codex",
[
"exec",
"--model",
profile.model,
"--dangerously-bypass-approvals-and-sandbox",
"--output-last-message",
outputPath,
prompt,
],
{
cwd: WORKSPACE,
env: environment,
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "inherit", "pipe"],
},
);
const status = exitCode(codex);
codex.stderr.setEncoding("utf8");
let error = "";
for await (const chunk of codex.stderr) {
const part = String(chunk);
await echo(process.stderr, part);
error += part;
}
// Codex echoes the prompt, whose comments may quote auth errors.
return { code: await status, error: error.replaceAll(prompt, "") };
};
const result =
authentication.kind === "api-key"
? await attempt()
: await retryInvalidToken(attempt, async () => {
await runAgentCommand(profile, "codex", ["logout"]);
await codexDeviceLogin(profile, environment);
});
if (result.code !== 0) {
const failure = new Error(result.error.trim() || `codex exited with ${result.code}`);
if (isCodexAuthFailure(result.error)) {
throw new AgentAuthError("codex sign-in failed", { cause: failure });
}
throw failure;
}
return outputPath;
}
async function runOpenCode(
profile: RuntimeProfile,
token: string,
prompt: string,
): Promise<string> {
const child = spawn("opencode", openCodeArgs(profile.model, prompt), {
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
});
const status = exitCode(child);
const collected = collectOpenCodeOutput(
child.stdout,
child.stderr,
(chunk) => echo(process.stdout, chunk),
(chunk) => echo(process.stderr, chunk),
);
const [code, streams] = await Promise.all([status, collected]);
const error = streams.stderr.trim();
if (code !== 0) {
throw openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${code}`);
}
return parseOpenCodeEvents(streams.stdout);
}
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> { async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
const token = await readProfileToken(profile); const token = await readProfileToken(profile);
const authentication = profile.type === "codex" ? codexAuthentication(token) : null; const authentication = profile.type === "codex" ? codexAuthentication(token) : null;
let stagedCodexAuth: string | null = null; let stagedCodexAuth: string | null = null;
let answer: AgentAnswer = {}; let body: string | undefined;
let rendered: RenderedPrompt | undefined; let resultPath: string | null = null;
let reviewPath: string | undefined;
let anchorsPath: string | undefined;
try { try {
if (authentication?.kind === "persistent") { if (authentication?.kind === "persistent") {
stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid); stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid);
} }
await prepareProfile(profile); await prepareProfile(profile);
rendered = await renderPrompt(profile); const rendered = await renderPrompt(profile);
answer = await runAgent(profile, token, rendered.prompt, authentication); reviewPath = rendered.reviewPath;
anchorsPath = rendered.anchorsPath;
if (profile.type === "claude") {
body = await runClaude(profile, token ?? "", rendered.prompt);
} else if (profile.type === "codex") {
resultPath = await runCodex(
profile,
rendered.prompt,
authentication ?? codexAuthentication(token),
);
} else {
body = await runOpenCode(profile, token ?? "", rendered.prompt);
}
} finally { } finally {
await killProfileProcesses(profile.uid); await killProfileProcesses(profile.uid);
if (stagedCodexAuth !== null) { if (stagedCodexAuth !== null) {
await persistCodexAuth(stagedCodexAuth, CODEX_AUTH); await persistCodexAuth(stagedCodexAuth, CODEX_AUTH);
} }
} }
const body = if (resultPath !== null) body = await readProfileFile(profile, resultPath);
answer.resultPath === undefined if (body === undefined || reviewPath === undefined || anchorsPath === undefined) {
? answer.body
: await readProfileFile(profile, answer.resultPath);
if (body === undefined || rendered === undefined) {
throw new Error("agent attempt produced no result"); throw new Error("agent attempt produced no result");
} }
const { reviewPath, anchorsPath } = rendered; return {
return { body, reviewPath, anchorsPath, profile: { type: profile.type, model: profile.model } }; body,
reviewPath,
anchorsPath,
profile: { type: profile.type, model: profile.model },
};
} }
async function writeOutcome(outcome: RuntimeOutcome): Promise<void> { async function writeOutcome(outcome: RuntimeOutcome): Promise<void> {
await writeFile(MANIFEST.outcomeFile, JSON.stringify(outcome), { mode: 0o600 }); await writeFile(MANIFEST.outcomeFile, JSON.stringify(outcome), {
mode: 0o600,
});
await chmod(MANIFEST.outcomeFile, 0o600); await chmod(MANIFEST.outcomeFile, 0o600);
} }
const failures: ProfileFailure[] = []; const failures: ProfileFailure[] = [];
try { try {
await configureGitAuthor(); await configureGitAuthor();
await writeOutcome({ ok: true, ...(await runProfiles(MANIFEST.profiles, runProfile, failures)) }); await writeOutcome({
ok: true,
...(await runProfiles(MANIFEST.profiles, runProfile, failures)),
});
} catch (error) { } catch (error) {
console.error(error); console.error(error);
await writeOutcome({ ok: false, failures }); await writeOutcome({ ok: false, failures });
+18 -21
View File
@@ -1,26 +1,21 @@
import { spawnSync } from "node:child_process";
import { chmod, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { chmod, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { assert, expect, test } from "vite-plus/test"; import { assert, expect, test } from "vite-plus/test";
import { output } from "./command.ts";
import { prepareRuntime } from "./runtime.ts"; import { prepareRuntime } from "./runtime.ts";
import type { RuntimeProfile } from "./runtime.ts"; import type { RuntimeProfile } from "./runtime.ts";
import { IS_ROOT, makeTempDir } from "./testing.ts"; import { IS_ROOT, makeTempDir } from "#testing";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA"; const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
// Runs `command` as the profile's user, from the root directory. // Runs `command` as the profile's user, from the root directory.
async function runAs(profile: RuntimeProfile, command: string, path: string): Promise<boolean> { function runAs(profile: RuntimeProfile, command: string, path: string): boolean {
const result = await output(command, { const result = spawnSync(command, [path], { cwd: "/", uid: profile.uid, gid: profile.gid });
args: [path], return result.status === 0;
cwd: "/",
uid: profile.uid,
gid: profile.gid,
});
return result.success;
} }
async function expectPrivate(path: string, uid: number, mode: number): Promise<void> { async function expectPrivate(path: string, uid: number, mode: number): Promise<void> {
@@ -58,16 +53,16 @@ test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credenti
expect((await stat(codex.home)).uid).toStrictEqual(codex.uid); expect((await stat(codex.home)).uid).toStrictEqual(codex.uid);
expect(codex.tokenFile).toBe(null); expect(codex.tokenFile).toBe(null);
const ownToken = await output("/bin/cat", { const ownToken = spawnSync("/bin/cat", [claude.tokenFile], {
args: [claude.tokenFile],
cwd: "/", cwd: "/",
uid: claude.uid, uid: claude.uid,
gid: claude.gid, gid: claude.gid,
encoding: "utf8",
}); });
expect(ownToken.success).toBe(true); expect(ownToken.status).toBe(0);
expect(new TextDecoder().decode(ownToken.stdout)).toBe("secret-token"); expect(ownToken.stdout).toBe("secret-token");
expect(await runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false); expect(runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false);
expect(await runAs(claude, "/bin/ls", codex.home)).toBe(false); expect(runAs(claude, "/bin/ls", codex.home)).toBe(false);
} finally { } finally {
await rm(root, { recursive: true }); await rm(root, { recursive: true });
await rm(action, { recursive: true }); await rm(action, { recursive: true });
@@ -85,18 +80,20 @@ test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fail
profiles: [{ type: "claude", token: ENCRYPTED }], profiles: [{ type: "claude", token: ENCRYPTED }],
}), }),
); );
const result = await output(process.execPath, { const result = spawnSync(
args: [fileURLToPath(new URL("prepare.ts", import.meta.url))], process.execPath,
[fileURLToPath(new URL("prepare.ts", import.meta.url))],
{
env: { env: {
ACTION_PATH: action, ACTION_PATH: action,
RUNNER_TEMP: root, RUNNER_TEMP: root,
BOT_AGENTS_MASTER_KEY: MASTER_KEY, BOT_AGENTS_MASTER_KEY: MASTER_KEY,
GITHUB_OUTPUT: action, GITHUB_OUTPUT: action,
}, },
clearEnv: true, },
}); );
expect(result.success).toBe(false); expect(result.status).not.toBe(0);
expect(await readdir(root)).toStrictEqual([]); expect(await readdir(root)).toStrictEqual([]);
} finally { } finally {
await rm(root, { recursive: true }); await rm(root, { recursive: true });
+28 -27
View File
@@ -39,24 +39,6 @@ async function writePrivate(path: string, value: string, uid: number, gid: numbe
await chown(path, uid, gid); await chown(path, uid, gid);
} }
async function prepareProfile(
runtimeDir: string,
profile: BotProfile,
index: number,
): Promise<RuntimeProfile> {
const uid = FIRST_PROFILE_UID + index;
const home = join(runtimeDir, `profile-${index}`);
await mkdir(home, { mode: 0o700 });
await chmod(home, 0o700);
await chown(home, uid, uid);
const tokenFile =
profile.token === undefined || profile.token === "" ? null : join(home, "token");
if (tokenFile !== null && profile.token !== undefined) {
await writePrivate(tokenFile, profile.token, uid, uid);
}
return { index, type: profile.type, model: profile.model, uid, gid: uid, home, tokenFile };
}
export async function prepareRuntime( export async function prepareRuntime(
actionPath: string, actionPath: string,
runtimeRoot: string, runtimeRoot: string,
@@ -74,10 +56,29 @@ export async function prepareRuntime(
// One profile at a time, in order, as each claims the next UID. // One profile at a time, in order, as each claims the next UID.
const profiles = await credentials.profiles.reduce<Promise<RuntimeProfile[]>>( const profiles = await credentials.profiles.reduce<Promise<RuntimeProfile[]>>(
async (previous, profile, index) => [ async (previous, profile, index) => {
...(await previous), const prepared = await previous;
await prepareProfile(runtimeDir, profile, index), const uid = FIRST_PROFILE_UID + index;
], const home = join(runtimeDir, `profile-${index}`);
await mkdir(home, { mode: 0o700 });
await chmod(home, 0o700);
await chown(home, uid, uid);
const tokenFile =
profile.token === undefined || profile.token === "" ? null : join(home, "token");
if (tokenFile !== null && profile.token !== undefined) {
await writePrivate(tokenFile, profile.token, uid, uid);
}
prepared.push({
index,
type: profile.type,
model: profile.model,
uid,
gid: uid,
home,
tokenFile,
});
return prepared;
},
Promise.resolve([]), Promise.resolve([]),
); );
@@ -100,11 +101,11 @@ export async function prepareRuntime(
function isRuntimeManifest(value: unknown): value is RuntimeManifest { function isRuntimeManifest(value: unknown): value is RuntimeManifest {
return ( return (
isMapping(value) && isMapping(value) &&
typeof value.runtimeDir === "string" && typeof value["runtimeDir"] === "string" &&
typeof value.manifestPath === "string" && typeof value["manifestPath"] === "string" &&
typeof value.reviewerFile === "string" && typeof value["reviewerFile"] === "string" &&
typeof value.outcomeFile === "string" && typeof value["outcomeFile"] === "string" &&
Array.isArray(value.profiles) Array.isArray(value["profiles"])
); );
} }
-23
View File
@@ -1,23 +0,0 @@
// Test support: the current user's IDs and a fresh temporary directory.
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import process from "node:process";
export function currentUid(): number {
return process.getuid?.() ?? -1;
}
export function currentGid(): number {
return process.getgid?.() ?? -1;
}
// The isolation tests switch UIDs, which only root may do.
export const IS_ROOT: boolean = currentUid() === 0;
// Symlinks and POSIX modes, as these tests use them, need a Unix host.
export const IS_WINDOWS: boolean = process.platform === "win32";
export function makeTempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), "bot-agents-test-"));
}
+47
View File
@@ -0,0 +1,47 @@
// Test support: the current user's IDs, a fresh temporary directory, and a
// plain command runner. It imports no source module, so the repository-level
// tests in tests/, which reach src/ only through `#testing`, never touch
// internals.
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import process from "node:process";
import { text } from "node:stream/consumers";
export function currentUid(): number {
return process.getuid?.() ?? -1;
}
export function currentGid(): number {
return process.getgid?.() ?? -1;
}
// The isolation tests switch UIDs, which only root may do.
export const IS_ROOT: boolean = currentUid() === 0;
// Symlinks and POSIX modes, as these tests use them, need a Unix host.
export const IS_WINDOWS: boolean = process.platform === "win32";
export function makeTempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), "bot-agents-test-"));
}
export interface CommandResult {
success: boolean;
stderr: string;
}
// Runs a command with stdin closed and only the given environment, and
// collects its stderr.
export async function runCommand(
command: string,
args: string[],
env: Record<string, string>,
): Promise<CommandResult> {
const child = spawn(command, args, { env, stdio: ["ignore", "ignore", "pipe"] });
const [stderr, closed] = await Promise.all([text(child.stderr), once(child, "close")]);
const exitCode: unknown = closed[0];
return { success: exitCode === 0, stderr };
}
+3 -8
View File
@@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url";
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { output } from "../src/command.ts"; import { runCommand } from "#testing";
// Each phase runs as `node src/<phase>.ts` with no build step. Node loads the // Each phase runs as `node src/<phase>.ts` with no build step. Node loads the
// whole import graph, stripping types, before the phase reads its first // whole import graph, stripping types, before the phase reads its first
@@ -16,12 +16,7 @@ const PHASES: [phase: string, firstVariable: string][] = [
test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => { test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => {
const path = fileURLToPath(new URL(`../src/${phase}.ts`, import.meta.url)); const path = fileURLToPath(new URL(`../src/${phase}.ts`, import.meta.url));
const result = await output(process.execPath, { const result = await runCommand(process.execPath, [path], { PATH: process.env["PATH"] ?? "" });
args: [path],
env: { PATH: process.env.PATH ?? "" },
clearEnv: true,
stdin: "null",
});
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(new TextDecoder().decode(result.stderr)).toContain(`${firstVariable} is not set`); expect(result.stderr).toContain(`${firstVariable} is not set`);
}); });
+39 -32
View File
@@ -4,11 +4,12 @@ import process from "node:process";
import { assert, expect, test } from "vite-plus/test"; import { assert, expect, test } from "vite-plus/test";
import { parse } from "yaml"; import { parse } from "yaml";
import { output } from "../src/command.ts"; import { makeTempDir, runCommand } from "#testing";
import { isNotFound } from "../src/errors.ts";
import { isMapping } from "../src/json.ts"; type JsonObject = Record<string, unknown>;
import type { JsonObject } from "../src/json.ts";
import { makeTempDir } from "../src/testing.ts"; const isMapping = (value: unknown): value is JsonObject =>
typeof value === "object" && value !== null && !Array.isArray(value);
const mapping = (value: unknown, at: string): JsonObject => { const mapping = (value: unknown, at: string): JsonObject => {
assert(isMapping(value), at); assert(isMapping(value), at);
@@ -47,11 +48,11 @@ test("all committed YAML parses", async () => {
test("the action exposes only the master key and scopes it to preparation", async () => { test("the action exposes only the master key and scopes it to preparation", async () => {
const action = await yaml("action.yml"); const action = await yaml("action.yml");
expect(Object.keys(mapping(action.inputs, "inputs"))).toStrictEqual(["master-key"]); expect(Object.keys(mapping(action["inputs"], "inputs"))).toStrictEqual(["master-key"]);
const steps = sequence(mapping(action.runs, "runs").steps, "runs.steps"); const steps = sequence(mapping(action["runs"], "runs")["steps"], "runs.steps");
for (const step of steps) { for (const step of steps) {
if (typeof step.run === "string") { if (typeof step["run"] === "string") {
expect(step.run.includes("\n"), String(step.name)).toBe(false); expect(step["run"].includes("\n"), String(step["name"])).toBe(false);
} }
} }
const checkout = findStep(steps, "uses", "actions/checkout@v4"); const checkout = findStep(steps, "uses", "actions/checkout@v4");
@@ -59,16 +60,16 @@ test("the action exposes only the master key and scopes it to preparation", asyn
const agent = findStep(steps, "id", "agent"); const agent = findStep(steps, "id", "agent");
const post = findStep(steps, "id", "post"); const post = findStep(steps, "id", "post");
findStep(steps, "id", "source"); findStep(steps, "id", "source");
expect(mapping(checkout.with, "checkout.with").token).toBe("${{ gitea.token }}"); expect(mapping(checkout["with"], "checkout.with")["token"]).toBe("${{ gitea.token }}");
expect(mapping(prepare.env, "prepare.env").BOT_AGENTS_MASTER_KEY).toBe( expect(mapping(prepare["env"], "prepare.env")["BOT_AGENTS_MASTER_KEY"]).toBe(
"${{ inputs.master-key }}", "${{ inputs.master-key }}",
); );
expect(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY")).toBe(false); expect(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY")).toBe(false); expect(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(mapping(agent.env, "agent.env").GITEA_TOKEN).toBe("${{ gitea.token }}"); expect(mapping(agent["env"], "agent.env")["GITEA_TOKEN"]).toBe("${{ gitea.token }}");
assert(String(post.if).includes("always()")); assert(String(post["if"]).includes("always()"));
for (const step of [prepare, agent, post]) { for (const step of [prepare, agent, post]) {
assert(String(step.run).includes("steps.source.outputs.path")); assert(String(step["run"]).includes("steps.source.outputs.path"));
} }
}); });
@@ -76,12 +77,13 @@ test("the source protection script stages immutable action files", async () => {
const temp = await makeTempDir(); const temp = await makeTempDir();
const outputFile = `${temp}/output`; const outputFile = `${temp}/output`;
try { try {
const result = await output("/bin/bash", { const result = await runCommand("/bin/bash", ["scripts/protect-source.sh"], {
args: ["scripts/protect-source.sh"], ...process.env,
env: { ACTION_PATH: process.cwd(), GITHUB_OUTPUT: outputFile, RUNNER_TEMP: temp }, ACTION_PATH: process.cwd(),
stderr: "piped", GITHUB_OUTPUT: outputFile,
RUNNER_TEMP: temp,
}); });
assert(result.success, new TextDecoder().decode(result.stderr)); assert(result.success, result.stderr);
const path = (await readFile(outputFile, "utf8")).trim().slice(5); const path = (await readFile(outputFile, "utf8")).trim().slice(5);
const files = [ const files = [
@@ -116,17 +118,17 @@ test("module-relative runtime assets exist", async () => {
test("the repository uses its trusted main action", async () => { test("the repository uses its trusted main action", async () => {
const workflow = await yaml(".gitea/workflows/agents.yml"); const workflow = await yaml(".gitea/workflows/agents.yml");
const job = mapping(mapping(workflow.jobs, "jobs").agent, "jobs.agent"); const job = mapping(mapping(workflow["jobs"], "jobs")["agent"], "jobs.agent");
const permissions = mapping(job.permissions, "jobs.agent.permissions"); const permissions = mapping(job["permissions"], "jobs.agent.permissions");
expect(permissions).toStrictEqual({ expect(permissions).toStrictEqual({
code: "write", code: "write",
issues: "write", issues: "write",
"pull-requests": "write", "pull-requests": "write",
}); });
const steps = sequence(job.steps, "jobs.agent.steps"); const steps = sequence(job["steps"], "jobs.agent.steps");
expect(steps.findIndex((step) => step.uses === "./")).toStrictEqual(-1); expect(steps.findIndex((step) => step["uses"] === "./")).toStrictEqual(-1);
const trusted = findStep(steps, "uses", "https://git.capsulizers.com/commons/bot-agents@main"); const trusted = findStep(steps, "uses", "https://git.capsulizers.com/commons/bot-agents@main");
expect(mapping(trusted.with, "trusted.with")["master-key"]).toBe( expect(mapping(trusted["with"], "trusted.with")["master-key"]).toBe(
"${{ secrets.BOT_AGENTS_MASTER_KEY }}", "${{ secrets.BOT_AGENTS_MASTER_KEY }}",
); );
expect(JSON.stringify(workflow).includes("gitea.actor != 'bot'")).toBe(true); expect(JSON.stringify(workflow).includes("gitea.actor != 'bot'")).toBe(true);
@@ -134,9 +136,9 @@ test("the repository uses its trusted main action", async () => {
test("CI checks every phase and the root isolation suite", async () => { test("CI checks every phase and the root isolation suite", async () => {
const workflow = await yaml(".gitea/workflows/check.yml"); const workflow = await yaml(".gitea/workflows/check.yml");
const job = mapping(mapping(workflow.jobs, "jobs").check, "jobs.check"); const job = mapping(mapping(workflow["jobs"], "jobs")["check"], "jobs.check");
const commands = sequence(job.steps, "jobs.check.steps") const commands = sequence(job["steps"], "jobs.check.steps")
.map((step) => (typeof step.run === "string" ? step.run : "")) .map((step) => (typeof step["run"] === "string" ? step["run"] : ""))
.join("\n"); .join("\n");
// `vp check` type-checks every phase, and `vp test` runs each of them under // `vp check` type-checks every phase, and `vp test` runs each of them under
// Node as well as the root-only isolation tests. // Node as well as the root-only isolation tests.
@@ -150,7 +152,7 @@ test("uses the standard Skills installer without custom discovery", async () =>
assert(runner.includes("skills add obra/superpowers")); assert(runner.includes("skills add obra/superpowers"));
const removed = await stat("superpowers.ts").then( const removed = await stat("superpowers.ts").then(
() => false, () => false,
(error: unknown) => isNotFound(error), (error: unknown) => error instanceof Error && "code" in error && error.code === "ENOENT",
); );
assert(removed, "superpowers.ts should be removed"); assert(removed, "superpowers.ts should be removed");
}); });
@@ -160,6 +162,11 @@ test("installs the OpenCode CLI", async () => {
assert(action.includes("opencode-ai")); assert(action.includes("opencode-ai"));
}); });
test("does not install Deno", async () => {
const action = await readFile("action.yml", "utf8");
expect(action).not.toMatch(/\bdeno\b/iu);
});
test("profile preparation does not receive provider credentials", async () => { test("profile preparation does not receive provider credentials", async () => {
const runner = await readFile("src/run.ts", "utf8"); const runner = await readFile("src/run.ts", "utf8");
assert(runner.includes("await prepareProfile(profile);")); assert(runner.includes("await prepareProfile(profile);"));
@@ -168,8 +175,8 @@ test("profile preparation does not receive provider credentials", async () => {
test("central profiles fall back from Codex to Claude to OpenCode", async () => { test("central profiles fall back from Codex to Claude to OpenCode", async () => {
const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8")); const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8"));
const profiles = sequence(mapping(credentials, "profiles.json").profiles, "profiles"); const profiles = sequence(mapping(credentials, "profiles.json")["profiles"], "profiles");
expect(profiles.map((profile) => profile.type)).toStrictEqual(["codex", "claude", "opencode"]); expect(profiles.map((profile) => profile["type"])).toStrictEqual(["codex", "claude", "opencode"]);
expect(profiles[2]?.model).toBe("openrouter/qwen/qwen3.8-27b"); expect(profiles[2]?.["model"]).toBe("openrouter/qwen/qwen3.8-27b");
assert(String(profiles[2]?.token).startsWith("v1:")); assert(String(profiles[2]?.["token"]).startsWith("v1:"));
}); });
+2 -4
View File
@@ -9,14 +9,12 @@
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"strict": true, "strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitOverride": true, "noImplicitOverride": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true
"allowUnreachableCode": false,
"allowUnusedLabels": false
} }
} }
-7
View File
@@ -3,18 +3,11 @@ import { defineConfig } from "vite-plus";
import projectLint from "./scripts/project-lint.ts"; import projectLint from "./scripts/project-lint.ts";
export default defineConfig({ export default defineConfig({
// Keep package.json in its hand-written order.
fmt: { sortPackageJson: false },
lint: { lint: {
plugins: ["typescript", "unicorn", "oxc", "import"], plugins: ["typescript", "unicorn", "oxc", "import"],
jsPlugins: ["./scripts/project-lint.ts"], jsPlugins: ["./scripts/project-lint.ts"],
categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" }, categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" },
rules: projectLint.configs.project.rules, rules: projectLint.configs.project.rules,
// Repo tooling, not app code: the lint plugin compares AST node `type`s,
// which the AST types declare as an enum.
overrides: [
{ files: ["scripts/**"], rules: { "typescript/no-unsafe-enum-comparison": "off" } },
],
options: { options: {
typeAware: true, typeAware: true,
typeCheck: true, typeCheck: true,