Move the action from Deno to Node.js and Vite+ (#20)
## Summary
Moves `commons/bot-agents` off Deno. The action's TypeScript now runs directly on the job image's Node.js (`node src/<phase>.ts`, using Node's built-in type stripping), with no build step and still no runtime dependency. Repository checks move to Vite+ (`vp check`, `vp test`).
## What changed
- **Runtime.** `action.yml` runs `prepare.ts`, `run.ts`, and `post.ts` with `node` instead of `deno run -A`. `package.json` (`"type": "module"`, `engines.node >=24.2` for `import.meta.main`) is now copied into the root-owned source copy by `scripts/protect-source.sh`.
- **Profiles.** `profiles.yml` → `profiles.json`, with the same content (encrypted values unchanged), parsed with `JSON.parse`. This drops the only runtime dependency (`@std/yaml`).
- **Deno APIs → Node.**
- `src/command.ts` wraps `child_process.spawn` with the old `Deno.Command` shapes: `output()`/`spawn()`, `clearEnv`, `uid`/`gid`, and Deno's stdio defaults (stdin inherited; stdout/stderr piped for `output()`, inherited for `spawn()`). A signal exit maps to `128 + signo`. Status settles on process exit.
- `src/errors.ts` maps `NotFound` to `ENOENT`/`ESRCH`.
- fs, path, stream, and readline calls are mapped one for one. `makeTempFile` becomes an exclusive `wx` 0600 create.
- **Size refactors.** These are needed for Oxlint's default limits. There are no behavior changes and no overrides for source files.
- `run.ts` is split into `run-context.ts`, `agents.ts`, and `agent-{claude,codex,opencode}.ts`.
- `post.ts` is split into `post.ts` and `post-artifacts.ts`.
- `parseCredentials`, `readOutcome`, and `postRuntime` are broken into helpers.
- Loops that awaited sequentially became recursion or a `reduce` chain, so they stay sequential. `/proc` status reads became `Promise.all`.
- `src/json.ts` adds type guards in place of `as` casts.
- **Tests.** The 58 `Deno.test` cases were converted to Vitest with the codemod, and its TODOs were finished by hand.
- Unit tests are `src/*.test.ts`; the repository-level workflow test is `tests/workflow.test.ts`.
- Root-only tests are now `test.skipIf(!IS_ROOT)`, so they show as skipped instead of silently passing.
- New `tests/entrypoints.test.ts` runs each phase under real Node to prove its whole import graph loads.
- **Config.** The only config files are `package.json`, `tsconfig.json`, and `vite.config.ts`, plus `package-lock.json`.
- Lint uses Oxlint correctness/suspicious/pedantic/perf at error, with default limits and type-aware checks.
- `scripts/project-lint.ts` holds the rule list. It maps Deno's `recommended` rules that fall outside those categories, adds the template's `id-match`, `no-disable-comments`, and `no-non-ts-imports`, and maps Deno's `no-window`/`no-process-global`/`no-node-globals` to `no-restricted-globals` (so `process`/`Buffer` come from `node:` imports).
- **CI.** `.gitea/workflows/check.yml` runs `node --version`, `npm ci`, `npx vp check`, and `npx vp test` in the default runner image.
- **Formatting.** Oxfmt formatting is a separate first commit, so the second commit shows only the semantic diff.
- **AGENTS.md** is updated for the new toolchain.
## Deviations / risks
- **Live `@main` switch.** Six consumer repos use `commons/bot-agents@main`, so merging switches all of them at once. Suggested before merging: tag the current `main` (e.g. `deno-final`) as a rollback target, and canary this branch from one consumer (`uses: …/commons/bot-agents@node-migration`) on a throwaway PR.
- **Node version.** `post.ts` relies on `import.meta.main`, which needs Node ≥ 24.2. DANNYEC's cached `node:24-bookworm` minor is unknown (`forcePull=false`), and the CI job now prints `node --version` to confirm it.
- **Deno stays in the global install.** `deno` remains in the action's global npm install because agents still verify Deno-based consumers (memona, memona-home). Remove it once those repos move.
- **`prefer-readonly-parameter-types` is off.** This pedantic rule is turned off, as in the explicit-types template and the app repos, because Node/web types (`Response`, `Stats`, `RequestInit`) can never satisfy it. `scripts/**` has one narrow override (`no-unsafe-enum-comparison`) for AST node `type` comparisons.
- **Stricter validation of malformed input.** For valid input, behavior is unchanged. A few validations are stricter where the old code would have used a malformed value:
- Gitea `/user` and comment responses are checked.
- The runtime manifest and the outcome's profile type are checked against known values.
- Some type-check errors are now `TypeError` (same messages).
- **@cunarist lint plugins.** "CI fails until @cunarist/eslint-plugin-* are published" does **not** apply here. This repo's Deno setup enabled no plugin, so it depends on none of `@cunarist/eslint-plugin-*`, and CI can pass as is.
## Verification
- `vp check`: formatting, lint (0 warnings), and type check all pass.
- `vp test` as a normal user: 56 passed, 5 root-only skipped.
- `vp test` as root in `node:24-bookworm` (Node v24.22.0, the CI image): all 61 passed, including the UID isolation, process cleanup, and `prepare.ts` cleanup tests.
- End-to-end smoke as root in `node:24-bookworm`, with fake `codex`/`claude`/`npx` binaries and a fake Gitea, through `protect-source.sh` → `prepare.ts` → `run.ts` → `post.ts`:
- Codex hit "usage limit" and fell through to Claude.
- Agents ran as UIDs 20000/20001 without the master key.
- The Codex login was persisted to `/root/.codex/auth.json` (0600).
- The PR review with an inline comment was posted with the reviewer token.
- Runtime credentials were removed and the git author was configured.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: #20
Reviewed-by: bot <noreply@capsulizers.com>
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
This commit was merged in pull request #20.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
import { readdir, readFile, rm, stat } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
|
||||
import { assert, expect, test } from "vite-plus/test";
|
||||
import { parse } from "yaml";
|
||||
|
||||
import { output } from "../src/command.ts";
|
||||
import { isNotFound } from "../src/errors.ts";
|
||||
import { isMapping } from "../src/json.ts";
|
||||
import type { JsonObject } from "../src/json.ts";
|
||||
import { makeTempDir } from "../src/testing.ts";
|
||||
|
||||
const mapping = (value: unknown, at: string): JsonObject => {
|
||||
assert(isMapping(value), at);
|
||||
return value;
|
||||
};
|
||||
|
||||
const sequence = (value: unknown, at: string): JsonObject[] => {
|
||||
assert(Array.isArray(value), at);
|
||||
return value.map((entry: unknown, index) => mapping(entry, `${at}[${index}]`));
|
||||
};
|
||||
|
||||
async function yaml(path: string): Promise<JsonObject> {
|
||||
const value: unknown = parse(await readFile(path, "utf8"));
|
||||
return mapping(value, path);
|
||||
}
|
||||
|
||||
async function yamlFiles(): Promise<string[]> {
|
||||
const entries = await readdir(".", { recursive: true, withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && /\.ya?ml$/u.test(entry.name))
|
||||
.map((entry) => `${entry.parentPath}/${entry.name}`.replace(/^\.\//u, ""))
|
||||
.filter((path) => !/^(?:\.git|\.superpowers|node_modules)\//u.test(path));
|
||||
}
|
||||
|
||||
const findStep = (steps: JsonObject[], key: string, value: string): JsonObject => {
|
||||
const step = steps.find((entry) => entry[key] === value);
|
||||
assert(step !== undefined, `${key}: ${value}`);
|
||||
return step;
|
||||
};
|
||||
|
||||
test("all committed YAML parses", async () => {
|
||||
const files = await yamlFiles();
|
||||
assert(files.length > 0);
|
||||
await Promise.all(files.map((file) => yaml(file)));
|
||||
});
|
||||
|
||||
test("the action exposes only the master key and scopes it to preparation", async () => {
|
||||
const action = await yaml("action.yml");
|
||||
expect(Object.keys(mapping(action.inputs, "inputs"))).toStrictEqual(["master-key"]);
|
||||
const steps = sequence(mapping(action.runs, "runs").steps, "runs.steps");
|
||||
for (const step of steps) {
|
||||
if (typeof step.run === "string") {
|
||||
expect(step.run.includes("\n"), String(step.name)).toBe(false);
|
||||
}
|
||||
}
|
||||
const checkout = findStep(steps, "uses", "actions/checkout@v4");
|
||||
const prepare = findStep(steps, "id", "prepare");
|
||||
const agent = findStep(steps, "id", "agent");
|
||||
const post = findStep(steps, "id", "post");
|
||||
findStep(steps, "id", "source");
|
||||
expect(mapping(checkout.with, "checkout.with").token).toBe("${{ gitea.token }}");
|
||||
expect(mapping(prepare.env, "prepare.env").BOT_AGENTS_MASTER_KEY).toBe(
|
||||
"${{ inputs.master-key }}",
|
||||
);
|
||||
expect(JSON.stringify(agent).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 }}");
|
||||
assert(String(post.if).includes("always()"));
|
||||
for (const step of [prepare, agent, post]) {
|
||||
assert(String(step.run).includes("steps.source.outputs.path"));
|
||||
}
|
||||
});
|
||||
|
||||
test("the source protection script stages immutable action files", async () => {
|
||||
const temp = await makeTempDir();
|
||||
const outputFile = `${temp}/output`;
|
||||
try {
|
||||
const result = await output("/bin/bash", {
|
||||
args: ["scripts/protect-source.sh"],
|
||||
env: { ACTION_PATH: process.cwd(), GITHUB_OUTPUT: outputFile, RUNNER_TEMP: temp },
|
||||
stderr: "piped",
|
||||
});
|
||||
assert(result.success, new TextDecoder().decode(result.stderr));
|
||||
|
||||
const path = (await readFile(outputFile, "utf8")).trim().slice(5);
|
||||
const files = [
|
||||
"src/prepare.ts",
|
||||
"src/run.ts",
|
||||
"src/post.ts",
|
||||
"package.json",
|
||||
"profiles.json",
|
||||
"prompt.md",
|
||||
];
|
||||
const stats = await Promise.all(files.map((file) => stat(`${path}/${file}`)));
|
||||
for (const [index, info] of stats.entries()) {
|
||||
assert(info.isFile(), files[index]);
|
||||
expect(info.mode & 0o022, files[index]).toStrictEqual(0);
|
||||
}
|
||||
} finally {
|
||||
await rm(temp, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("module-relative runtime assets exist", async () => {
|
||||
const module = new URL("../src/run.ts", import.meta.url);
|
||||
const source = await readFile(module, "utf8");
|
||||
const assets = [...source.matchAll(/new URL\("([^"]+)", import\.meta\.url\)/gu)].map(
|
||||
([, asset = ""]) => new URL(asset, module),
|
||||
);
|
||||
const stats = await Promise.all(assets.map((asset) => stat(asset)));
|
||||
for (const [index, info] of stats.entries()) {
|
||||
assert(info.isFile(), assets[index]?.pathname);
|
||||
}
|
||||
});
|
||||
|
||||
test("the repository uses its trusted main action", async () => {
|
||||
const workflow = await yaml(".gitea/workflows/agents.yml");
|
||||
const job = mapping(mapping(workflow.jobs, "jobs").agent, "jobs.agent");
|
||||
const permissions = mapping(job.permissions, "jobs.agent.permissions");
|
||||
expect(permissions).toStrictEqual({
|
||||
code: "write",
|
||||
issues: "write",
|
||||
"pull-requests": "write",
|
||||
});
|
||||
const steps = sequence(job.steps, "jobs.agent.steps");
|
||||
expect(steps.findIndex((step) => step.uses === "./")).toStrictEqual(-1);
|
||||
const trusted = findStep(steps, "uses", "https://git.capsulizers.com/commons/bot-agents@main");
|
||||
expect(mapping(trusted.with, "trusted.with")["master-key"]).toBe(
|
||||
"${{ secrets.BOT_AGENTS_MASTER_KEY }}",
|
||||
);
|
||||
expect(JSON.stringify(workflow).includes("gitea.actor != 'bot'")).toBe(true);
|
||||
});
|
||||
|
||||
test("CI checks every phase and the root isolation suite", async () => {
|
||||
const workflow = await yaml(".gitea/workflows/check.yml");
|
||||
const job = mapping(mapping(workflow.jobs, "jobs").check, "jobs.check");
|
||||
const commands = sequence(job.steps, "jobs.check.steps")
|
||||
.map((step) => (typeof step.run === "string" ? step.run : ""))
|
||||
.join("\n");
|
||||
// `vp check` type-checks every phase, and `vp test` runs each of them under
|
||||
// Node as well as the root-only isolation tests.
|
||||
assert(commands.includes("npm ci"));
|
||||
assert(commands.includes("vp check"));
|
||||
assert(commands.includes("vp test"));
|
||||
});
|
||||
|
||||
test("uses the standard Skills installer without custom discovery", async () => {
|
||||
const runner = await readFile("src/run.ts", "utf8");
|
||||
assert(runner.includes("skills add obra/superpowers"));
|
||||
const removed = await stat("superpowers.ts").then(
|
||||
() => false,
|
||||
(error: unknown) => isNotFound(error),
|
||||
);
|
||||
assert(removed, "superpowers.ts should be removed");
|
||||
});
|
||||
|
||||
test("installs the OpenCode CLI", async () => {
|
||||
const action = await readFile("action.yml", "utf8");
|
||||
assert(action.includes("opencode-ai"));
|
||||
});
|
||||
|
||||
test("profile preparation does not receive provider credentials", async () => {
|
||||
const runner = await readFile("src/run.ts", "utf8");
|
||||
assert(runner.includes("await prepareProfile(profile);"));
|
||||
expect(runner.includes("prepareProfile(profile, token)")).toBe(false);
|
||||
});
|
||||
|
||||
test("central profiles fall back from Codex to Claude to OpenCode", async () => {
|
||||
const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8"));
|
||||
const profiles = sequence(mapping(credentials, "profiles.json").profiles, "profiles");
|
||||
expect(profiles.map((profile) => profile.type)).toStrictEqual(["codex", "claude", "opencode"]);
|
||||
expect(profiles[2]?.model).toBe("openrouter/qwen/qwen3.8-27b");
|
||||
assert(String(profiles[2]?.token).startsWith("v1:"));
|
||||
});
|
||||
Reference in New Issue
Block a user