Compare commits
25 Commits
main
...
75045b18c9
| Author | SHA1 | Date | |
|---|---|---|---|
| 75045b18c9 | |||
| 5d960b5260 | |||
| 90dfd474a8 | |||
| 0a31dbb820 | |||
| 6ad859be62 | |||
| 8b5c249248 | |||
| 5af53b0882 | |||
| eba9b9c57c | |||
| 3cbe4171ea | |||
| cd1864e1a9 | |||
| 0c5059e3b4 | |||
| c6fc183ad6 | |||
| 1f54518320 | |||
| 999a089374 | |||
| 166476dee9 | |||
| c778c00068 | |||
| 6b87ff66b9 | |||
| d2d3cbffdb | |||
| bfa8269801 | |||
| 15fe481181 | |||
| 662e946fad | |||
| ffb7cb9a75 | |||
| 2e09945a6a | |||
| 934a3cfd03 | |||
| b170f25c7d |
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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]].
|
||||||
@@ -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.
|
||||||
|
|||||||
+9
-9
@@ -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
|
||||||
- **Node 24.2+**, not 22: `src/post.ts` uses `import.meta.main`. `engines.node`
|
dependencies (`@eslint/core` and the other devDependencies serve only the
|
||||||
is `>=24.2`, which the user accepted for this repo
|
tooling).
|
||||||
([[bot-agents-runtime-no-build]]).
|
- **Node 24.2+**, not 22: `src/post.ts` uses `import.meta.main`. There is no
|
||||||
- **Deno stays in the action's global npm install** (`action.yml`), only
|
`engines` (user decision 2026-10-18): the `node:24-bookworm` job image
|
||||||
because the agents still check Deno-based consumer repos (memona,
|
decides the version ([[bot-agents-runtime-no-build]]).
|
||||||
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]].
|
|
||||||
@@ -10,22 +10,22 @@ dependency**. Hence `tsconfig.json` has `erasableSyntaxOnly` and
|
|||||||
properties, no decorators) and relative `.ts` imports.
|
properties, no decorators) and relative `.ts` imports.
|
||||||
|
|
||||||
- **Image:** the steps assume the `node:24-bookworm` job container. Node must
|
- **Image:** the steps assume the `node:24-bookworm` job container. Node must
|
||||||
be 24.2+ for `import.meta.main` in `post.ts`; `check.yml` prints
|
be 24.2+ for `import.meta.main` in `post.ts`. That image is the only Node
|
||||||
`node --version` to confirm the cached image's minor.
|
pin (`package.json` has no `engines`); `check.yml` prints `node --version`
|
||||||
|
to confirm the cached image's minor.
|
||||||
- **Root-owned source copy:** `scripts/protect-source.sh` copies the action
|
- **Root-owned source copy:** `scripts/protect-source.sh` copies the action
|
||||||
into `$RUNNER_TEMP` so an agent editing the checkout can't swap a later
|
into `$RUNNER_TEMP` so an agent editing the checkout can't swap a later
|
||||||
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]]).
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -1,39 +1,22 @@
|
|||||||
---
|
---
|
||||||
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
|
||||||
|
|
||||||
- **Standard fields only.** `engines` has only `node` (never `engines.bun`).
|
- **Standard fields only.** App repos have no `engines` field (user decision
|
||||||
|
2026-10-18; images pin Node, [[toolchain-node-vite-plus]]). Published
|
||||||
|
packages keep `engines` with only `node` (never `engines.bun`).
|
||||||
A tool-owned key (`eslint-doc-generator`) is allowed only because the tool
|
A tool-owned key (`eslint-doc-generator`) is allowed only because the tool
|
||||||
reads nothing else and no other config file is allowed.
|
reads nothing else and no other config file is allowed.
|
||||||
- **No `packageManager`.** npm is detected from `package-lock.json`.
|
- **No `packageManager`.** npm is detected from `package-lock.json`.
|
||||||
@@ -49,3 +32,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]]).
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
---
|
---
|
||||||
description: Node.js 22+ with Vite+ (vp) and npm; only package.json, tsconfig.json, vite.config.ts plus a committed package-lock.json
|
description: Node.js (no engines in apps; images pin 24) with Vite+ (vp) and npm; only package.json, tsconfig.json, vite.config.ts plus a committed package-lock.json
|
||||||
---
|
---
|
||||||
|
|
||||||
## The stack
|
## The stack
|
||||||
|
|
||||||
- **Runtime:** Node.js 22 or newer. `engines` is `{ "node": ">=22" }` and
|
- **Runtime:** Node.js. App repos have no `engines` field (user decision
|
||||||
nothing else. Containers and the publish job use Node 24 (LTS); a repo may
|
2026-10-18): the container or runner image decides the version, so the
|
||||||
need more where a feature demands it (bot-agents uses Node 24.2+).
|
Containerfile base image (`node:24-alpine`) and CI's `node-version: "24"`
|
||||||
|
are the pins. Published packages (the lint plugins) keep
|
||||||
|
`engines: { "node": ">=22" }`. `npm run lit-check` runs
|
||||||
|
`node scripts/lit-check.ts`, which needs Node 22.18+ (unflagged type
|
||||||
|
stripping and `module.registerHooks`); Node 24 satisfies it. Without
|
||||||
|
`engines` or a `.node-version`, local `vp` uses its default Node
|
||||||
|
(`vp env default`). bot-agents has no Containerfile or setup step: the
|
||||||
|
self-hosted runner's `node:24-bookworm` job image runs the action and its
|
||||||
|
checks, and `src/post.ts` needs 24.2+ (`import.meta.main`).
|
||||||
- **Bun is not supported** (dropped 2026-10-15). No Bun CI legs, docs, or
|
- **Bun is not supported** (dropped 2026-10-15). No Bun CI legs, docs, or
|
||||||
runtime branches. Oxlint's `RuleTester` refuses to run under Bun anyway, and
|
runtime branches. Oxlint's `RuleTester` refuses to run under Bun anyway, and
|
||||||
`vp` brings its own Node, the way uv brings its own Python.
|
`vp` brings its own Node, the way uv brings its own Python.
|
||||||
@@ -44,8 +52,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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -13,11 +13,33 @@ explicit permission.
|
|||||||
# Runtime and checks
|
# Runtime and checks
|
||||||
|
|
||||||
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; `package.json` has no `engines`, the `node:24-bookworm` image pins it),
|
||||||
|
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 +47,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
@@ -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
|
||||||
|
|||||||
Generated
-3
@@ -11,9 +11,6 @@
|
|||||||
"@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"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
|
|||||||
+3
-3
@@ -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",
|
||||||
|
|||||||
+65
-31
@@ -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 } },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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"])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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-"));
|
|
||||||
}
|
|
||||||
@@ -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,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
@@ -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
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user