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:
+79
-67
@@ -1,24 +1,32 @@
|
||||
export type BotProfile = {
|
||||
type: "claude" | "codex" | "opencode";
|
||||
export type ProfileType = "claude" | "codex" | "opencode";
|
||||
|
||||
export interface BotProfile {
|
||||
type: ProfileType;
|
||||
model: string;
|
||||
token?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function profileFooter(
|
||||
profile: Pick<BotProfile, "type" | "model">,
|
||||
): 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 {
|
||||
return `> Profile: \`${profile.type}\` · \`${profile.model}\``;
|
||||
}
|
||||
|
||||
export function withProfile(
|
||||
body: string,
|
||||
profile: Pick<BotProfile, "type" | "model">,
|
||||
): string {
|
||||
export function withProfile(body: string, profile: Pick<BotProfile, "type" | "model">): string {
|
||||
return `${body}\n\n${profileFooter(profile)}`;
|
||||
}
|
||||
|
||||
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"> & {
|
||||
reason: FailureReason;
|
||||
};
|
||||
@@ -27,8 +35,9 @@ export type ProfileFailure = Pick<BotProfile, "type" | "model"> & {
|
||||
export class AgentAuthError extends Error {}
|
||||
|
||||
function isUsageExhausted(error: unknown): boolean {
|
||||
return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/i
|
||||
.test(String(error));
|
||||
return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/iu.test(
|
||||
String(error),
|
||||
);
|
||||
}
|
||||
|
||||
function failureReason(error: unknown): FailureReason {
|
||||
@@ -38,69 +47,72 @@ function failureReason(error: unknown): FailureReason {
|
||||
|
||||
export function failureFooter(failures: ProfileFailure[]): string {
|
||||
if (failures.length === 0) return "> Profile: none";
|
||||
return `> Tried: ${
|
||||
failures
|
||||
.map(({ type, model, reason }) =>
|
||||
`\`${type}\` · \`${model}\` (${reason})`
|
||||
)
|
||||
.join(", ")
|
||||
}`;
|
||||
return `> Tried: ${failures
|
||||
.map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${reason})`)
|
||||
.join(", ")}`;
|
||||
}
|
||||
|
||||
export async function runProfiles<
|
||||
T,
|
||||
P extends Pick<BotProfile, "type" | "model">,
|
||||
>(
|
||||
// Tries the profiles from `index` on, moving on only after a sign-in failure
|
||||
// or exhausted usage, and records every profile it tried.
|
||||
async function attemptFrom<T, P extends Pick<BotProfile, "type" | "model">>(
|
||||
profiles: P[],
|
||||
attempt: (profile: P) => Promise<T>,
|
||||
failures: ProfileFailure[],
|
||||
index: number,
|
||||
): Promise<T> {
|
||||
const profile = profiles[index];
|
||||
if (profile === undefined) {
|
||||
throw new Error("profiles must contain at least one profile");
|
||||
}
|
||||
try {
|
||||
return await attempt(profile);
|
||||
} catch (error) {
|
||||
const reason = failureReason(error);
|
||||
failures.push({ type: profile.type, model: profile.model, reason });
|
||||
if (reason === "failed" || index === profiles.length - 1) throw error;
|
||||
console.error(`${profile.type} ${reason}; trying next profile`);
|
||||
return attemptFrom(profiles, attempt, failures, index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function runProfiles<T, P extends Pick<BotProfile, "type" | "model">>(
|
||||
profiles: P[],
|
||||
attempt: (profile: P) => Promise<T>,
|
||||
failures: ProfileFailure[] = [],
|
||||
): Promise<T> {
|
||||
for (const [index, profile] of profiles.entries()) {
|
||||
try {
|
||||
return await attempt(profile);
|
||||
} catch (error) {
|
||||
const reason = failureReason(error);
|
||||
failures.push({ type: profile.type, model: profile.model, reason });
|
||||
if (reason === "failed" || index === profiles.length - 1) throw error;
|
||||
console.error(`${profile.type} ${reason}; trying next profile`);
|
||||
}
|
||||
}
|
||||
throw new Error("profiles must contain at least one profile");
|
||||
return attemptFrom(profiles, attempt, failures, 0);
|
||||
}
|
||||
|
||||
export function sanitizedEnvironment(
|
||||
environment: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
export function sanitizedEnvironment(environment: Record<string, string>): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (
|
||||
const name of [
|
||||
"PATH",
|
||||
"HOME",
|
||||
"LANG",
|
||||
"LANGUAGE",
|
||||
"LC_ALL",
|
||||
"TERM",
|
||||
"COLORTERM",
|
||||
"CI",
|
||||
"TMPDIR",
|
||||
"GITEA_TOKEN",
|
||||
"GITEA_API_URL",
|
||||
"GITEA_REPOSITORY",
|
||||
"GITHUB_WORKSPACE",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"NO_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"all_proxy",
|
||||
"no_proxy",
|
||||
"SSL_CERT_FILE",
|
||||
"SSL_CERT_DIR",
|
||||
"NODE_EXTRA_CA_CERTS",
|
||||
]
|
||||
) {
|
||||
if (environment[name] !== undefined) result[name] = environment[name];
|
||||
for (const name of [
|
||||
"PATH",
|
||||
"HOME",
|
||||
"LANG",
|
||||
"LANGUAGE",
|
||||
"LC_ALL",
|
||||
"TERM",
|
||||
"COLORTERM",
|
||||
"CI",
|
||||
"TMPDIR",
|
||||
"GITEA_TOKEN",
|
||||
"GITEA_API_URL",
|
||||
"GITEA_REPOSITORY",
|
||||
"GITHUB_WORKSPACE",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"NO_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"all_proxy",
|
||||
"no_proxy",
|
||||
"SSL_CERT_FILE",
|
||||
"SSL_CERT_DIR",
|
||||
"NODE_EXTRA_CA_CERTS",
|
||||
]) {
|
||||
const value = environment[name];
|
||||
if (value !== undefined) result[name] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -113,7 +125,7 @@ export function agentEnvironment(
|
||||
const result = sanitizedEnvironment(environment);
|
||||
result.HOME = profile.home;
|
||||
if (profile.type === "codex") result.CODEX_HOME = `${profile.home}/.codex`;
|
||||
if (token) {
|
||||
if (token !== undefined && token !== "") {
|
||||
const tokenName = {
|
||||
claude: "CLAUDE_CODE_OAUTH_TOKEN",
|
||||
codex: "OPENAI_API_KEY",
|
||||
|
||||
Reference in New Issue
Block a user