32806120b3
Groups the flat `src/` into cohesive modules and turns on the published lint plugins. No behavior change. ## Module layout Each module is a direct `src/` subfolder with an `index.ts` barrel and a `#` alias in `package.json` `imports` (layer order, top first): | Alias | Files | | --- | --- | | `#prepare` | `index.ts` (the prepare phase entry point) | | `#run` | `index.ts` (the run phase entry point) | | `#post` | `index.ts` (the post phase entry point), `post.ts`, `post.test.ts` | | `#agents` | `auth.ts`, `opencode.ts` (Codex / Claude Code sign-in, OpenCode args and output) | | `#processes` | `child.ts`, `isolation.ts` | | `#runtime` | `runtime.ts`, `credentials.ts` | | `#profiles` | `profiles.ts` | | `#common` | `errors.ts`, `json.ts` | | `#testing` | `index.ts` (unchanged) | Every top-level entry of `src/` is an aliased module folder. Tests stay next to their code as `*.test.ts`. Each phase's `index.ts` is its entry point, and `action.yml` now runs `node src/<phase>/index.ts`. The post phase's logic moved to `src/post/post.ts` so its test can import it; `index.ts` keeps the env reading and the `import.meta.main` call. `run`'s prompt URL is now `../../prompt.md`. `scripts/protect-source.sh` now copies `src/` recursively (it used to copy `src/*.ts` only), and the workflow test checks that module files get staged too. ## Lint plugins - New devDependencies: `eslint-plugin-explicit-types@^0.6.1` and `eslint-plugin-import-structure@^0.10.2`, both in `jsPlugins`. The lockfile comes from a real `npm install`. - Enabled configs: `importStructure.configs.modules`, `explicitTypes.configs.concrete`, `explicitTypes.configs.absence`. - **explicit-types `naming` is off** (the other app repos turn it on). Its `upper-snake-string-unions` and `no-upper-snake-object-keys` rules fire on wire tokens this repo doesn't choose: profile types `"claude" | "codex" | "opencode"` from `profiles.json`, failure reasons posted to Gitea, Gitea's `"new" | "old"` side, and environment variable names such as `PATH`. Satisfying them would change behavior or need opt-outs. This is logged as an open question for the user. Lint fixes, all type-level: - `Pick<BotProfile, "type" | "model">` became a named `ProfileIdentity` interface that `BotProfile`, `ProfileFailure`, and `RuntimeProfile` extend. `RuntimeOutcome` is now `RuntimeSuccess | RuntimeFailure`. - Inline object types became named interfaces. - `Record<string, string>` env maps became `NodeJS.ProcessEnv`. `JsonObject` and the verdict table became `NodeJS.Dict<…>`. - `T | undefined` locals and returns became `T | null` with matching checks. `typeof fetch` became a `Fetcher` type. - Claude's result fields are held in camelCase (`isError`, `apiErrorStatus`) after parsing, because `id-match` rejects snake_case interface members. - Duplicate import sources are merged (`import-structure/no-duplicate-import-source`). ## Verification - `vp check`: clean. - `vp test`: 61 passed and 5 skipped as a normal user. All 66 passed as root in `node:24-bookworm`. - Each phase (`node src/<phase>/index.ts`) run from a `protect-source.sh` copy loads its full alias graph under Node. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #23 Reviewed-by: bot <noreply@capsulizers.com> Co-authored-by: Danny Kim <temeddix@gmail.com> Co-committed-by: Danny Kim <temeddix@gmail.com>
184 lines
7.3 KiB
TypeScript
184 lines
7.3 KiB
TypeScript
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 { makeTempDir, runCommand } from "#testing";
|
|
|
|
type JsonObject = NodeJS.Dict<unknown>;
|
|
|
|
const isMapping = (value: unknown): value is JsonObject =>
|
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
|
|
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 runCommand("/bin/bash", ["scripts/protect-source.sh"], {
|
|
...process.env,
|
|
ACTION_PATH: process.cwd(),
|
|
GITHUB_OUTPUT: outputFile,
|
|
RUNNER_TEMP: temp,
|
|
});
|
|
assert(result.success, result.stderr);
|
|
|
|
const path = (await readFile(outputFile, "utf8")).trim().slice(5);
|
|
const files = [
|
|
"src/prepare/index.ts",
|
|
"src/run/index.ts",
|
|
"src/post/index.ts",
|
|
"src/runtime/index.ts",
|
|
"src/agents/auth.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/index.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/index.ts", "utf8");
|
|
assert(runner.includes("skills add obra/superpowers"));
|
|
const removed = await stat("superpowers.ts").then(
|
|
() => false,
|
|
(error: unknown) => error instanceof Error && "code" in error && error.code === "ENOENT",
|
|
);
|
|
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("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 () => {
|
|
const runner = await readFile("src/run/index.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:"));
|
|
});
|