From 38026f963d1aed036ea2408f93104d2a5584e497 Mon Sep 17 00:00:00 2001 From: alexion Date: Sat, 11 Jul 2026 07:11:07 -0400 Subject: [PATCH] feat: scaffold CLI and minimal issue list (task 0001) Tracer bullet for gitea-axi: runnable npm package on axi-sdk-js with gitea-js as the sole HTTP layer, ESM on Node 20+. - issue list with --state/--limit, default fields, count line from X-Total-Count, type=issues guard, explicit empty state, and next-step suggestions - repo context detection from the git origin remote (SSH/scp/HTTPS), tea credential discovery with the three-way login-matching split, and -R/--repo and --login overrides (flag > env > auto) - token retrieval via tea login helper get: tea's login list JSON carries no token (ADR 0001 amended) - full AxiError classification table with path-based 404 split, TOON errors on stdout, exit codes 0/1/2 - test mode (GITEA_AXI_API_URL/TOKEN/REPO) suppressing subprocesses, fixture server, and vitest suites driving the CLI seam (50 tests) --- .../adr/0001-diff-auth-via-tea-login-list.md | 9 + .../0001-scaffold-and-issue-list-core.md | 59 + .gitignore | 2 + package-lock.json | 1693 +++++++++++++++++ package.json | 33 + src/cli.ts | 116 ++ src/client.ts | 10 + src/commands/issue.ts | 162 ++ src/context.ts | 193 ++ src/deps.ts | 10 + src/errors.ts | 133 ++ src/fields.ts | 54 + src/flags.ts | 81 + src/git.ts | 55 + src/main.ts | 17 + src/render.ts | 44 + src/subprocess.ts | 55 + src/suggestions.ts | 21 + src/tea.ts | 102 + src/time.ts | 32 + test/context.test.ts | 119 ++ test/detection.test.ts | 257 +++ test/errors.test.ts | 71 + test/fixture-server.ts | 96 + test/fixtures/issues-closed.json | 27 + test/fixtures/issues-open.json | 78 + test/git.test.ts | 53 + test/harness.ts | 42 + test/help.test.ts | 46 + test/issue-list.test.ts | 167 ++ test/time.test.ts | 24 + tsconfig.build.json | 9 + tsconfig.json | 15 + vitest.config.ts | 7 + 34 files changed, 3892 insertions(+) create mode 100644 .claude/tasks/0001-scaffold-and-issue-list-core.md create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/cli.ts create mode 100644 src/client.ts create mode 100644 src/commands/issue.ts create mode 100644 src/context.ts create mode 100644 src/deps.ts create mode 100644 src/errors.ts create mode 100644 src/fields.ts create mode 100644 src/flags.ts create mode 100644 src/git.ts create mode 100644 src/main.ts create mode 100644 src/render.ts create mode 100644 src/subprocess.ts create mode 100644 src/suggestions.ts create mode 100644 src/tea.ts create mode 100644 src/time.ts create mode 100644 test/context.test.ts create mode 100644 test/detection.test.ts create mode 100644 test/errors.test.ts create mode 100644 test/fixture-server.ts create mode 100644 test/fixtures/issues-closed.json create mode 100644 test/fixtures/issues-open.json create mode 100644 test/git.test.ts create mode 100644 test/harness.ts create mode 100644 test/help.test.ts create mode 100644 test/issue-list.test.ts create mode 100644 test/time.test.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.claude/adr/0001-diff-auth-via-tea-login-list.md b/.claude/adr/0001-diff-auth-via-tea-login-list.md index b24c6ec..49a5a06 100644 --- a/.claude/adr/0001-diff-auth-via-tea-login-list.md +++ b/.claude/adr/0001-diff-auth-via-tea-login-list.md @@ -8,3 +8,12 @@ To get the auth token for that request, gitea-axi calls `tea login list --output **Read `~/.config/tea/config.yml` directly** — one fewer subprocess call, but couples gitea-axi to tea's internal storage format rather than its stable JSON output interface. **Shell out to `tea login list --output json`** (chosen) — consistent with the rest of the architecture, which goes through tea's JSON interface for everything; decoupled from tea's file format internals. + +## Amendment (2026-07-10): token comes from `tea login helper get`, not the list output + +The original decision assumed `tea login list --output json` includes the token. +It does not: the list output carries only `name`, `url`, `ssh_host`, `user`, and `default` (verified against tea 0.14.2 and current tea main). + +`tea login list --output json` remains the discovery interface (profile names, URLs, default flag, hostname matching). +The token for the selected login is fetched via `tea login helper get --login `, tea's git-credential-protocol interface (`host=` on stdin, `password=` on stdout). +This stays on tea's stable machine interfaces rather than its YAML internals, and additionally gets OAuth token refresh for free — `helper get` refreshes near-expiry OAuth tokens in place, which reading `config.yml` directly could never do. diff --git a/.claude/tasks/0001-scaffold-and-issue-list-core.md b/.claude/tasks/0001-scaffold-and-issue-list-core.md new file mode 100644 index 0000000..ffb8ef4 --- /dev/null +++ b/.claude/tasks/0001-scaffold-and-issue-list-core.md @@ -0,0 +1,59 @@ +--- +spec: gitea-axi +--- + +## What to build + +The tracer bullet: a runnable `gitea-axi` npm package whose first command, a minimal `issue list`, works end-to-end — argv in, TOON out — against both the fixture server and a real Gitea instance. +Scaffold the project on axi-sdk-js (`runAxiCli`, `AxiError`, `exitCodeForError`, output helpers) with gitea-js as the sole HTTP layer, ESM on Node 20+. +Implement repository context detection from the git `origin` remote (SSH and HTTPS forms), tea credential discovery via `tea login list --output json` with the three-way login-matching split, and the `-R`/`--repo` and `--login` context override flags with their env equivalents (priority: flag > env > auto-detection). +Implement the full error classification table (ten AxiError codes, HTTP status + path-based 404 mapping, TOON error output to stdout, exit codes 0/1/2). +Implement test mode (`GITEA_AXI_API_URL` + `GITEA_AXI_TOKEN` + `GITEA_AXI_REPO`) and the fixture server, with Vitest as the runner. +`issue list` itself stays minimal in this slice: `--state` and `--limit` only, default fields (`number`, `title`, `state`, `author`, `created`), count line from `X-Total-Count`, explicit empty state, `type=issues` guard, and a next-step suggestion block. + +## Acceptance criteria + +- [x] `gitea-axi issue list` returns a TOON list with default fields `number`, `title`, `state` (lowercased), `author`, `created` (relative time), preceded by a `count: N of T total` line +- [x] `--state ` (default open) and `--limit ` (default 30) work +- [x] Every issues-list API call passes `type=issues`, so PRs never appear in issue lists +- [x] Empty result emits `issues[0]: (none)` plus a next-step suggestion, never silent output +- [x] Every command run appends at least one `help[N]:` next-step suggestion +- [x] Repo owner, name, and hostname are detected from the git `origin` remote in both SSH and HTTPS forms; no recognizable remote yields `REPO_NOT_FOUND` +- [x] Credentials come from `tea login list --output json`; missing tea binary yields `TEA_NOT_INSTALLED`, zero logins yields `AUTH_REQUIRED`, no hostname match yields `REPO_NOT_FOUND`, ambiguous multi-match without a default yields `VALIDATION_ERROR` listing profile names +- [x] `-R`/`--repo` and `--login` flags (accepted anywhere on the command line) and `GITEA_AXI_REPO`/`GITEA_AXI_LOGIN` env vars override auto-detection with flag > env > auto priority; suggestions include the override flags only when context did not come from the git remote +- [x] A nonexistent `--login` profile name yields `VALIDATION_ERROR` listing available profiles +- [x] Errors are TOON-encoded to stdout as `error:` + `code:` + optional `help[N]:`, classified per the spec's status table (401→`AUTH_REQUIRED`, 403→`FORBIDDEN`, path-based 404 split, 405/409/422→`VALIDATION_ERROR`, 429→`RATE_LIMITED`, other→`UNKNOWN`) +- [x] Exit codes: 0 on success, 1 on error, 2 on `VALIDATION_ERROR` including unknown flags +- [x] `--help` on the root and on `issue list` prints a concise flag reference and exits 0; no command ever prompts interactively +- [x] Setting `GITEA_AXI_API_URL` + `GITEA_AXI_TOKEN` + `GITEA_AXI_REPO` suppresses the git and tea subprocesses and routes all HTTP to the fixture server +- [x] Vitest tests drive the CLI seam (argv in, stdout/exit-code out) against the fixture server for the happy path, empty state, and at least one error classification per category exercised here + +## Implementation Notes + +**Token discovery deviates from the letter of the spec and original ADR 0001.** +`tea login list --output json` factually carries no token — its columns are only `name`, `url`, `ssh_host`, `user`, `default` (verified against tea 0.14.2 and tea main). +The list output is still used for login discovery and the three-way matching split; the token for the selected login comes from `tea login helper get --login ` (tea's git-credential interface, which also refreshes OAuth tokens in place). +ADR 0001 was amended in this change; the spec's Auth paragraph ("extracts the token") still reflects the old assumption and should be updated — left untouched here because the spec file carries unrelated pending edits. + +**404 classification is slightly broader than the spec table.** +The spec maps 404 on `/repos/{owner}/{repo}` itself to `REPO_NOT_FOUND` and "other paths" to `UNKNOWN`. +Gitea returns 404 for every path under a nonexistent repository, so a 404 on a repo-subtree path that is not an indexed `/issues/{n}` or `/pulls/{n}` lookup (e.g. the issue-list endpoint itself) is classified `REPO_NOT_FOUND` rather than `UNKNOWN`. +Without this, `issue list -R bad/repo` would report `UNKNOWN`, which defeats the code's self-correction purpose. + +**Login hostname matching compares hostnames, ignoring ports.** +A login matches when its URL hostname or its `ssh_host` equals the remote's hostname. +SSH remotes (like this repo's own `ssh://gitea@git.alexion.dev:2022/...`) use a different port than the login's HTTPS URL, so port-inclusive matching would never match. + +**A minimal home view exists as a placeholder.** +`runAxiCli` requires a `home` handler; bare `gitea-axi` currently prints the SDK header, a `repo:` line, and a `help:` block. +Task 0017 replaces it with the real two-tier dashboard. +Similarly, the SDK's built-in `update` command remains unshadowed until task 0018. + +**Global flags are extracted before the SDK parses argv.** +`runAxiCli` rejects any flag placed before the command, so `-R`/`--repo`/`--login` are pulled out of argv first (satisfying "accepted anywhere"); all other leading flags still exit 2 via the SDK's own error. + +**Other notes.** +`GITEA_AXI_API_URL` is the instance base URL without `/api/v1` (gitea-js appends it); the fixture server serves `/api/v1/...` paths. +The SDK's `renderError` helper is not exported from the axi-sdk-js package index (contrary to ADR 0004's summary), so `src/render.ts` builds the identical error TOON locally. +The bin entry guards against EPIPE so `gitea-axi | head` exits quietly instead of crashing. +Tests drive the CLI seam in-process via `runCli({ argv, env, cwd, stdout })` — the same function the binary calls — with a fully explicit environment; context-detection tests use real `git` repos plus a fake `tea` script on a sandboxed `PATH`. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..826c174 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1693 @@ +{ + "name": "gitea-axi", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gitea-axi", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@toon-format/toon": "^2.3.0", + "axi-sdk-js": "^0.1.8", + "gitea-js": "^1.23.0" + }, + "bin": { + "gitea-axi": "dist/main.js" + }, + "devDependencies": { + "@types/node": "^20.19.0", + "typescript": "^5.8.0", + "vitest": "^3.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@toon-format/toon": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-2.3.0.tgz", + "integrity": "sha512-/Ew9etdRQKVMnm9fDaCG0JjyAOK/O7T0M97oum1aW4W+UR8ZhVVPBanIV7oWgHBiGlnVxV9M55PWQCHofDV07w==", + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axi-sdk-js": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/axi-sdk-js/-/axi-sdk-js-0.1.8.tgz", + "integrity": "sha512-N8Qd/9sVBpG8QRVSw0lLevdFuaBfjHHPdcbcj8DCuxr68sA1/c5KCnGEDqO7lI3kt52/bgymCZajmpTqz/rLOw==", + "license": "MIT", + "dependencies": { + "@toon-format/toon": "^2.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gitea-js": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/gitea-js/-/gitea-js-1.23.0.tgz", + "integrity": "sha512-f4+UPoWgDetZeZ+Awo5iI1nVdO5bjxA8+2QCeLo3oYWUYxKyzLfXgbW1EPD635wb8hLgS0DRBu5XhtiuYKEeUA==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..17c9532 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "gitea-axi", + "version": "0.1.0", + "description": "Agent-ergonomic CLI for Gitea issues and pull requests", + "type": "module", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "bin": { + "gitea-axi": "dist/main.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "prepublishOnly": "npm run build", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@toon-format/toon": "^2.3.0", + "axi-sdk-js": "^0.1.8", + "gitea-js": "^1.23.0" + }, + "devDependencies": { + "@types/node": "^20.19.0", + "typescript": "^5.8.0", + "vitest": "^3.2.0" + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..044ae44 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,116 @@ +import { readFileSync } from "node:fs"; +import { exitCodeForError, runAxiCli, AxiError } from "axi-sdk-js"; +import { issueCommand } from "./commands/issue.js"; +import { resolveRepoContext } from "./context.js"; +import type { CliDeps, GlobalFlags } from "./deps.js"; +import { consumeFlagValue, splitFlag } from "./flags.js"; +import { renderErrorOutput } from "./render.js"; +import { suggestCommand } from "./suggestions.js"; + +const DESCRIPTION = "Agent-ergonomic CLI for Gitea issues and pull requests"; + +const TOP_LEVEL_HELP = `usage: gitea-axi [flags] + +commands: + issue list List issues in the current repository + +global flags: + -R, --repo Override the repository detected from the git origin remote + --login Select a tea login profile by name + --help Show help (also available on every command) + -v, --version Show version + +environment: + GITEA_AXI_REPO Repository override, as OWNER/NAME + GITEA_AXI_LOGIN Login profile override +`; + +const GLOBAL_FLAG_NAMES: Record = { + "-R": "repo", + "--repo": "repo", + "--login": "login", +}; + +interface ExtractedArgv { + argv: string[]; + globals: GlobalFlags; +} + +/** + * Pull the context override flags out of argv before the SDK sees it: they are + * accepted anywhere on the command line, while the SDK rejects any flag placed + * before the command. + */ +export function extractGlobalFlags(argv: string[]): ExtractedArgv { + const globals: GlobalFlags = {}; + const rest: string[] = []; + for (let i = 0; i < argv.length; i++) { + const flag = splitFlag(argv[i]!); + const target = GLOBAL_FLAG_NAMES[flag.name]; + if (!target) { + rest.push(argv[i]!); + continue; + } + const consumed = consumeFlagValue(argv, i, flag); + globals[target] = consumed.value; + i = consumed.lastIndex; + } + return { argv: rest, globals }; +} + +function readVersion(): string { + const packageJson = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ) as { version: string }; + return packageJson.version; +} + +function homeCommand(deps: CliDeps) { + return async (): Promise> => { + const context = await resolveRepoContext(deps); + return { + repo: `${context.owner}/${context.name}`, + help: [ + suggestCommand(context, "issue list", "to list open issues"), + "Run `gitea-axi --help` to see available commands", + ], + }; + }; +} + +export interface RunCliOptions { + argv: string[]; + env: Record; + cwd: string; + stdout: { write: (chunk: string) => unknown }; +} + +export async function runCli(options: RunCliOptions): Promise { + process.exitCode = 0; + let extracted: ExtractedArgv; + try { + extracted = extractGlobalFlags(options.argv); + } catch (error) { + const axi = error as AxiError; + options.stdout.write(`${renderErrorOutput(axi.message, axi.code, axi.suggestions)}\n`); + process.exitCode = exitCodeForError(error); + return process.exitCode; + } + const deps: CliDeps = { + env: options.env, + cwd: options.cwd, + globals: extracted.globals, + }; + await runAxiCli({ + description: DESCRIPTION, + version: readVersion(), + argv: extracted.argv, + topLevelHelp: TOP_LEVEL_HELP, + commands: { + issue: issueCommand(deps), + }, + home: homeCommand(deps), + stdout: options.stdout, + }); + return typeof process.exitCode === "number" ? process.exitCode : 0; +} diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..102903e --- /dev/null +++ b/src/client.ts @@ -0,0 +1,10 @@ +import { giteaApi, type Api } from "gitea-js"; +import type { RepoContext } from "./context.js"; + +export type GiteaClient = Api; + +export function createClient(context: RepoContext): GiteaClient { + return giteaApi(context.apiUrl, { + token: context.token || undefined, + }); +} diff --git a/src/commands/issue.ts b/src/commands/issue.ts new file mode 100644 index 0000000..5b1dca3 --- /dev/null +++ b/src/commands/issue.ts @@ -0,0 +1,162 @@ +import type { Issue } from "gitea-js"; +import { createClient } from "../client.js"; +import { resolveRepoContext, type RepoContext } from "../context.js"; +import type { CliDeps } from "../deps.js"; +import { axiError, classifyHttpError } from "../errors.js"; +import { extractRow, lowercased, pluck, relativeTimeField, type FieldDef } from "../fields.js"; +import { parseFlags } from "../flags.js"; +import { formatCountLine, renderList } from "../render.js"; +import { suggestCommand } from "../suggestions.js"; + +export const ISSUE_HELP = `usage: gitea-axi issue [flags] + +commands: + list List issues in the current repository + +Run \`gitea-axi issue list --help\` for the flags of a command. +`; + +export const ISSUE_LIST_HELP = `usage: gitea-axi issue list [flags] + +List issues in the current repository. Pull requests are never included. + +flags: + --state Filter by state (default: open) + --limit Maximum number of issues to return (default: 30) + --help Show this help + +global flags: + -R, --repo Override the repository detected from the git origin remote + --login Select a tea login profile by name +`; + +const ISSUE_LIST_FIELDS: FieldDef[] = [ + pluck("number"), + pluck("title"), + lowercased("state"), + pluck("author", "user.login"), + relativeTimeField("created", "created_at"), +]; + +const ISSUE_STATES = ["open", "closed", "all"] as const; +type IssueState = (typeof ISSUE_STATES)[number]; + +const DEFAULT_LIMIT = 30; + +const ISSUE_LIST_HELP_SUGGESTION = [ + "Run `gitea-axi issue list --help` to see available flags", +]; + +function parseState(value: string | true | undefined): IssueState { + if (value === undefined) { + return "open"; + } + if (value === true || !ISSUE_STATES.includes(value as IssueState)) { + throw axiError( + `Invalid --state value: ${String(value)} (expected open, closed, or all)`, + "VALIDATION_ERROR", + ISSUE_LIST_HELP_SUGGESTION, + ); + } + return value as IssueState; +} + +function parseLimit(value: string | true | undefined): number { + if (value === undefined) { + return DEFAULT_LIMIT; + } + const limit = Number(value); + if (value === true || !Number.isInteger(limit) || limit < 1) { + throw axiError( + `Invalid --limit value: ${String(value)} (expected a positive integer)`, + "VALIDATION_ERROR", + ISSUE_LIST_HELP_SUGGESTION, + ); + } + return limit; +} + +function issueListSuggestions( + context: RepoContext, + state: IssueState, + shown: number, + total: number | undefined, +): string[] { + const help: string[] = []; + if (state !== "all") { + help.push(suggestCommand(context, "issue list --state all", "to list issues in any state")); + } + if (total !== undefined && shown < total) { + help.push( + suggestCommand(context, "issue list --limit ", `to fetch more of the ${total} issues`), + ); + } + if (help.length === 0) { + help.push( + suggestCommand(context, "issue list --help", "to see all issue list flags"), + ); + } + return help; +} + +async function issueList(deps: CliDeps, args: string[]): Promise { + if (args.includes("--help")) { + return ISSUE_LIST_HELP; + } + const { flags, positionals } = parseFlags( + args, + { "--state": { takesValue: true }, "--limit": { takesValue: true } }, + "issue list", + ); + if (positionals.length > 0) { + throw axiError( + `Unexpected argument: ${positionals[0]}`, + "VALIDATION_ERROR", + ISSUE_LIST_HELP_SUGGESTION, + ); + } + const state = parseState(flags["--state"]); + const limit = parseLimit(flags["--limit"]); + + const context = await resolveRepoContext(deps); + const api = createClient(context); + let response; + try { + response = await api.repos.issueListIssues(context.owner, context.name, { + state, + type: "issues", + limit, + page: 1, + }); + } catch (error) { + throw classifyHttpError(error); + } + const issues = response.data ?? []; + const totalHeader = response.headers.get("x-total-count"); + const total = totalHeader !== null ? Number(totalHeader) : undefined; + const resolvedTotal = total !== undefined && Number.isFinite(total) ? total : undefined; + + const now = new Date(); + const rows = issues.map((issue) => extractRow(issue, ISSUE_LIST_FIELDS, { now })); + return renderList({ + noun: "issues", + rows, + countLine: formatCountLine(rows.length, resolvedTotal, rows.length >= limit), + help: issueListSuggestions(context, state, rows.length, resolvedTotal), + }); +} + +export function issueCommand(deps: CliDeps) { + return async (args: string[]): Promise => { + const [subcommand, ...rest] = args; + if (!subcommand || subcommand === "--help") { + return ISSUE_HELP; + } + if (subcommand === "list") { + return issueList(deps, rest); + } + throw axiError(`Unknown issue command: ${subcommand}`, "VALIDATION_ERROR", [ + "Run `gitea-axi issue --help` to see available issue commands", + ]); + }; +} diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..1fd9067 --- /dev/null +++ b/src/context.ts @@ -0,0 +1,193 @@ +import type { CliDeps } from "./deps.js"; +import { axiError } from "./errors.js"; +import { detectRemote } from "./git.js"; +import { getToken, listLogins, type TeaLogin } from "./tea.js"; + +export type ContextSource = "flag" | "env" | "auto"; + +export interface RepoContext { + owner: string; + name: string; + host: string; + /** Gitea instance base URL, without the /api/v1 suffix. */ + apiUrl: string; + token: string; + repoSource: ContextSource; + loginSource: ContextSource; + loginName?: string; +} + +/** The repo/login overrides resolved from flags and environment, pre-context. */ +interface ContextOverrides { + repoSpec?: string; + repoSource: ContextSource; + /** Human label for where the repo spec came from, for error messages. */ + repoOrigin: string; + loginName?: string; + loginSource: ContextSource; +} + +function resolveOverrides(deps: CliDeps): ContextOverrides { + const repoFromFlag = deps.globals.repo; + const repoFromEnv = deps.env.GITEA_AXI_REPO; + const loginFromFlag = deps.globals.login; + const loginFromEnv = deps.env.GITEA_AXI_LOGIN; + return { + repoSpec: repoFromFlag ?? repoFromEnv, + repoSource: repoFromFlag ? "flag" : repoFromEnv ? "env" : "auto", + repoOrigin: repoFromFlag ? "`-R`" : "`GITEA_AXI_REPO`", + loginName: loginFromFlag ?? loginFromEnv, + loginSource: loginFromFlag ? "flag" : loginFromEnv ? "env" : "auto", + }; +} + +function parseRepoSpec(spec: string, origin: string): { owner: string; name: string } { + const segments = spec.split("/"); + if (segments.length !== 2 || !segments[0] || !segments[1]) { + throw axiError( + `Invalid repository "${spec}" from ${origin}: expected OWNER/NAME`, + "VALIDATION_ERROR", + ); + } + return { owner: segments[0], name: segments[1] }; +} + +function hostnameOf(url: string, origin: string): string { + try { + return new URL(url).hostname; + } catch { + throw axiError(`Invalid URL "${url}" from ${origin}`, "VALIDATION_ERROR"); + } +} + +function resolveTestModeContext( + deps: CliDeps, + apiUrl: string, + overrides: ContextOverrides, +): RepoContext { + if (!overrides.repoSpec) { + throw axiError( + "Repository context is required when GITEA_AXI_API_URL is set", + "REPO_NOT_FOUND", + ["Set `GITEA_AXI_REPO=OWNER/NAME` or pass `-R OWNER/NAME`"], + ); + } + return { + ...parseRepoSpec(overrides.repoSpec, overrides.repoOrigin), + host: hostnameOf(apiUrl, "`GITEA_AXI_API_URL`"), + apiUrl: apiUrl.replace(/\/+$/, ""), + token: deps.env.GITEA_AXI_TOKEN ?? "", + repoSource: overrides.repoSource, + loginSource: overrides.loginSource, + loginName: overrides.loginName, + }; +} + +function matchLoginsByHost(logins: TeaLogin[], host: string): TeaLogin[] { + return logins.filter((login) => { + if (login.sshHost && login.sshHost === host) { + return true; + } + try { + return new URL(login.url).hostname === host; + } catch { + return false; + } + }); +} + +function selectLogin( + logins: TeaLogin[], + loginName: string | undefined, + remoteHost: string | undefined, +): TeaLogin { + if (logins.length === 0) { + throw axiError("No tea logins are configured", "AUTH_REQUIRED", [ + "Run `tea login add` to configure a login", + ]); + } + if (loginName) { + const login = logins.find((entry) => entry.name === loginName); + if (!login) { + throw axiError( + `Login profile "${loginName}" not found (available: ${logins.map((l) => l.name).join(", ")})`, + "VALIDATION_ERROR", + ["Pass `--login ` with one of the listed profiles"], + ); + } + return login; + } + if (!remoteHost) { + throw axiError( + "Cannot select a tea login without a git remote hostname", + "REPO_NOT_FOUND", + ["Pass `--login ` together with `-R OWNER/NAME`"], + ); + } + const matches = matchLoginsByHost(logins, remoteHost); + if (matches.length === 0) { + throw axiError( + `No tea login matches host "${remoteHost}" — this may not be a Gitea repository`, + "REPO_NOT_FOUND", + [ + `Run \`tea login add --url ${remoteHost}\` if this is a Gitea instance`, + "Or pass `-R OWNER/NAME` together with `--login `", + ], + ); + } + if (matches.length === 1) { + return matches[0]!; + } + const fallback = matches.find((login) => login.isDefault); + if (fallback) { + return fallback; + } + throw axiError( + `Multiple tea logins match host "${remoteHost}": ${matches.map((l) => l.name).join(", ")}`, + "VALIDATION_ERROR", + ["Pass `--login ` to select one"], + ); +} + +export async function resolveRepoContext(deps: CliDeps): Promise { + const overrides = resolveOverrides(deps); + + const testApiUrl = deps.env.GITEA_AXI_API_URL; + if (testApiUrl) { + return resolveTestModeContext(deps, testApiUrl, overrides); + } + + const remote = await detectRemote(deps); + let owner: string; + let name: string; + if (overrides.repoSpec) { + ({ owner, name } = parseRepoSpec(overrides.repoSpec, overrides.repoOrigin)); + } else if (remote) { + ({ owner, name } = remote); + } else { + throw axiError( + "Could not detect a Gitea repository from the git `origin` remote", + "REPO_NOT_FOUND", + [ + "Run inside a git repository whose `origin` remote points at a Gitea instance", + "Or pass `-R OWNER/NAME` together with `--login `", + ], + ); + } + + const logins = await listLogins(deps); + const login = selectLogin(logins, overrides.loginName, remote?.host); + const host = hostnameOf(login.url, `tea login "${login.name}"`); + const token = await getToken(deps, login, host); + + return { + owner, + name, + host, + apiUrl: login.url.replace(/\/+$/, ""), + token, + repoSource: overrides.repoSource, + loginSource: overrides.loginSource, + loginName: login.name, + }; +} diff --git a/src/deps.ts b/src/deps.ts new file mode 100644 index 0000000..b8cb3ce --- /dev/null +++ b/src/deps.ts @@ -0,0 +1,10 @@ +export interface GlobalFlags { + repo?: string; + login?: string; +} + +export interface CliDeps { + env: Record; + cwd: string; + globals: GlobalFlags; +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..333924e --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,133 @@ +import { AxiError } from "axi-sdk-js"; + +export type AxiErrorCode = + | "REPO_NOT_FOUND" + | "ISSUE_NOT_FOUND" + | "PR_NOT_FOUND" + | "AUTH_REQUIRED" + | "FORBIDDEN" + | "RATE_LIMITED" + | "TEA_NOT_INSTALLED" + | "VALIDATION_ERROR" + | "GIT_ERROR" + | "UNKNOWN"; + +export function axiError( + message: string, + code: AxiErrorCode, + suggestions: string[] = [], +): AxiError { + return new AxiError(message, code, suggestions); +} + +interface HttpResponseLike { + status: number; + url: string; + error: unknown; +} + +function isHttpResponseLike(value: unknown): value is HttpResponseLike { + return ( + typeof value === "object" && + value !== null && + typeof (value as HttpResponseLike).status === "number" && + typeof (value as HttpResponseLike).url === "string" + ); +} + +function bodyMessage(response: HttpResponseLike): string | undefined { + const error = response.error; + if (typeof error === "object" && error !== null) { + const message = (error as { message?: unknown }).message; + if (typeof message === "string" && message.length > 0) { + return message; + } + } + return undefined; +} + +function pathname(url: string): string { + try { + return new URL(url).pathname; + } catch { + return url; + } +} + +const ISSUE_PATH = /\/repos\/[^/]+\/[^/]+\/issues\/(\d+)(?:\/|$)/; +const PULL_PATH = /\/repos\/[^/]+\/[^/]+\/pulls\/(\d+)(?:\/|$)/; +const REPO_PATH = /\/repos\/([^/]+)\/([^/]+)(?:\/|$)/; + +function classify404(response: HttpResponseLike): AxiError { + const path = pathname(response.url); + const issue = ISSUE_PATH.exec(path); + if (issue) { + return axiError(`Issue #${issue[1]} not found`, "ISSUE_NOT_FOUND", [ + "Run `gitea-axi issue list` to see existing issues", + ]); + } + const pull = PULL_PATH.exec(path); + if (pull) { + return axiError(`Pull request #${pull[1]} not found`, "PR_NOT_FOUND"); + } + // Gitea returns 404 for every path under a nonexistent repository, so any + // repo-subtree 404 that is not an indexed issue/pull lookup means the + // repository itself was not found. + const repo = REPO_PATH.exec(path); + if (repo) { + return axiError( + `Repository ${repo[1]}/${repo[2]} not found`, + "REPO_NOT_FOUND", + [ + "Check the repository owner and name", + "Pass `-R OWNER/NAME` to target a different repository", + ], + ); + } + return axiError(`Not found: ${path}`, "UNKNOWN"); +} + +export function classifyHttpError(error: unknown): AxiError { + if (error instanceof AxiError) { + return error; + } + if (!isHttpResponseLike(error)) { + const message = error instanceof Error ? error.message : String(error); + const cause = + error instanceof Error && error.cause instanceof Error + ? ` (${error.cause.message})` + : ""; + return axiError(`Request failed: ${message}${cause}`, "UNKNOWN"); + } + const detail = bodyMessage(error); + switch (error.status) { + case 401: + return axiError(detail ?? "Authentication required", "AUTH_REQUIRED", [ + "Run `tea login add` to configure credentials, or verify the token is still valid", + ]); + case 403: + return axiError(detail ?? "Access forbidden", "FORBIDDEN", [ + "Verify the token has permission to access this repository", + ]); + case 404: + return classify404(error); + case 405: + case 409: + case 422: + return axiError( + detail ?? `Validation failed (HTTP ${error.status})`, + "VALIDATION_ERROR", + ); + case 429: + return axiError(detail ?? "Rate limited", "RATE_LIMITED", [ + "Wait and retry, or reduce `--limit` to make smaller requests", + ]); + default: + return axiError( + detail + ? `Gitea API error (HTTP ${error.status}): ${detail}` + : `Gitea API error (HTTP ${error.status})`, + "UNKNOWN", + ); + } +} diff --git a/src/fields.ts b/src/fields.ts new file mode 100644 index 0000000..e8eace0 --- /dev/null +++ b/src/fields.ts @@ -0,0 +1,54 @@ +import { relativeTime } from "./time.js"; + +export interface ExtractContext { + now: Date; +} + +export interface FieldDef { + name: string; + extract: (raw: T, context: ExtractContext) => unknown; +} + +function pluckPath(raw: unknown, path: string): unknown { + let value: unknown = raw; + for (const key of path.split(".")) { + if (typeof value !== "object" || value === null) { + return undefined; + } + value = (value as Record)[key]; + } + return value; +} + +export function pluck(name: string, path: string = name): FieldDef { + return { name, extract: (raw) => pluckPath(raw, path) ?? "" }; +} + +export function lowercased(name: string, path: string = name): FieldDef { + return { + name, + extract: (raw) => String(pluckPath(raw, path) ?? "").toLowerCase(), + }; +} + +export function relativeTimeField(name: string, path: string): FieldDef { + return { + name, + extract: (raw, context) => { + const value = pluckPath(raw, path); + return relativeTime(typeof value === "string" ? value : undefined, context.now); + }, + }; +} + +export function extractRow( + raw: T, + fields: FieldDef[], + context: ExtractContext, +): Record { + const row: Record = {}; + for (const field of fields) { + row[field.name] = field.extract(raw, context); + } + return row; +} diff --git a/src/flags.ts b/src/flags.ts new file mode 100644 index 0000000..eb7f680 --- /dev/null +++ b/src/flags.ts @@ -0,0 +1,81 @@ +import { axiError } from "./errors.js"; + +export interface FlagSpec { + /** Flag names (e.g. "--state") mapped to whether they take a value. */ + [name: string]: { takesValue: boolean }; +} + +export interface ParsedFlags { + flags: Record; + positionals: string[]; +} + +export interface SplitFlag { + name: string; + inlineValue?: string; +} + +/** Split "--flag=value" into name and inline value; "--flag" has none. */ +export function splitFlag(arg: string): SplitFlag { + const equals = arg.indexOf("="); + if (equals === -1) { + return { name: arg }; + } + return { name: arg.slice(0, equals), inlineValue: arg.slice(equals + 1) }; +} + +/** + * Resolve a value-taking flag's value from its inline form or the next + * argument, returning the index of the last argument consumed. + */ +export function consumeFlagValue( + args: string[], + index: number, + flag: SplitFlag, + suggestions: string[] = [], +): { value: string; lastIndex: number } { + if (flag.inlineValue !== undefined) { + if (!flag.inlineValue) { + throw axiError(`Flag ${flag.name} requires a value`, "VALIDATION_ERROR", suggestions); + } + return { value: flag.inlineValue, lastIndex: index }; + } + const next = args[index + 1]; + if (next === undefined || next.startsWith("-")) { + throw axiError(`Flag ${flag.name} requires a value`, "VALIDATION_ERROR", suggestions); + } + return { value: next, lastIndex: index + 1 }; +} + +export function parseFlags( + args: string[], + spec: FlagSpec, + helpCommand: string, +): ParsedFlags { + const flags: Record = {}; + const positionals: string[] = []; + const helpSuggestion = [`Run \`gitea-axi ${helpCommand} --help\` to see available flags`]; + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (!arg.startsWith("-")) { + positionals.push(arg); + continue; + } + const flag = splitFlag(arg); + const entry = spec[flag.name]; + if (!entry) { + throw axiError(`Unknown flag: ${flag.name}`, "VALIDATION_ERROR", helpSuggestion); + } + if (!entry.takesValue) { + if (flag.inlineValue !== undefined) { + throw axiError(`Flag ${flag.name} does not take a value`, "VALIDATION_ERROR", helpSuggestion); + } + flags[flag.name] = true; + continue; + } + const consumed = consumeFlagValue(args, i, flag, helpSuggestion); + flags[flag.name] = consumed.value; + i = consumed.lastIndex; + } + return { flags, positionals }; +} diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..ee81649 --- /dev/null +++ b/src/git.ts @@ -0,0 +1,55 @@ +import type { CliDeps } from "./deps.js"; +import { runSubprocess } from "./subprocess.js"; + +export interface RemoteRepo { + host: string; + owner: string; + name: string; +} + +function parseRepoPath(rawPath: string): { owner: string; name: string } | null { + const path = rawPath.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/, ""); + const segments = path.split("/"); + if (segments.length !== 2 || !segments[0] || !segments[1]) { + return null; + } + return { owner: segments[0], name: segments[1] }; +} + +export function parseRemoteUrl(url: string): RemoteRepo | null { + const trimmed = url.trim(); + if (/^(https?|ssh):\/\//.test(trimmed)) { + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return null; + } + const repo = parseRepoPath(parsed.pathname); + if (!repo || !parsed.hostname) { + return null; + } + return { host: parsed.hostname, ...repo }; + } + // scp-like SSH form: [user@]host:owner/name[.git] + const scp = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/.exec(trimmed); + if (scp) { + const repo = parseRepoPath(scp[2]!); + if (!repo) { + return null; + } + return { host: scp[1]!, ...repo }; + } + return null; +} + +export async function detectRemote(deps: CliDeps): Promise { + const result = await runSubprocess("git", ["remote", "get-url", "origin"], { + cwd: deps.cwd, + env: deps.env, + }); + if (result.enoent || result.code !== 0) { + return null; + } + return parseRemoteUrl(result.stdout); +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..32863b3 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,17 @@ +#!/usr/bin/env node +import { runCli } from "./cli.js"; + +// Exit quietly when the consumer closes the pipe early (e.g. `gitea-axi | head`). +process.stdout.on("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EPIPE") { + process.exit(0); + } + throw error; +}); + +await runCli({ + argv: process.argv.slice(2), + env: process.env, + cwd: process.cwd(), + stdout: process.stdout, +}); diff --git a/src/render.ts b/src/render.ts new file mode 100644 index 0000000..67eeb0d --- /dev/null +++ b/src/render.ts @@ -0,0 +1,44 @@ +import { encode } from "@toon-format/toon"; + +// Same shape the SDK's own error formatter produces; its renderError helper +// exists in axi-sdk-js but is not exported from the package index. +export function renderErrorOutput( + message: string, + code: string, + suggestions: string[] = [], +): string { + const output: Record = { error: message, code }; + if (suggestions.length > 0) { + output.help = suggestions; + } + return encode(output); +} + +export function formatCountLine( + shown: number, + total: number | undefined, + atLimit: boolean, +): string { + if (total === undefined) { + if (atLimit) { + return `count: ${shown} (showing first ${shown})`; + } + return `count: ${shown} of ${shown} total`; + } + return `count: ${shown} of ${total} total`; +} + +export interface RenderListOptions { + noun: string; + rows: Record[]; + countLine: string; + help: string[]; +} + +export function renderList(options: RenderListOptions): string { + const body = + options.rows.length > 0 + ? encode({ [options.noun]: options.rows }) + : `${options.noun}[0]: (none)`; + return [options.countLine, body, encode({ help: options.help })].join("\n"); +} diff --git a/src/subprocess.ts b/src/subprocess.ts new file mode 100644 index 0000000..5fcdc79 --- /dev/null +++ b/src/subprocess.ts @@ -0,0 +1,55 @@ +import { spawn } from "node:child_process"; + +export interface SubprocessResult { + enoent: boolean; + code: number | null; + stdout: string; + stderr: string; +} + +export interface SubprocessOptions { + cwd?: string; + env: Record; + stdin?: string; +} + +export function runSubprocess( + command: string, + args: string[], + options: SubprocessOptions, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env as NodeJS.ProcessEnv, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.on("error", (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") { + resolve({ enoent: true, code: null, stdout, stderr }); + } else { + reject(error); + } + }); + child.on("close", (code) => { + resolve({ enoent: false, code, stdout, stderr }); + }); + if (options.stdin !== undefined) { + // The child may exit without reading stdin; a late write then raises + // EPIPE, which must not crash the parent. + child.stdin.on("error", () => {}); + child.stdin.write(options.stdin); + } + child.stdin.end(); + }); +} diff --git a/src/suggestions.ts b/src/suggestions.ts new file mode 100644 index 0000000..981cf4f --- /dev/null +++ b/src/suggestions.ts @@ -0,0 +1,21 @@ +import type { RepoContext } from "./context.js"; + +/** + * Build a next-step suggestion line, appending `-R`/`--login` overrides when + * the context did not come from the git remote (an agent's next call in the + * same working directory would auto-detect the same context otherwise). + */ +export function suggestCommand( + context: RepoContext, + commandLine: string, + note: string, +): string { + let command = `gitea-axi ${commandLine}`; + if (context.repoSource !== "auto") { + command += ` -R ${context.owner}/${context.name}`; + } + if (context.loginSource !== "auto" && context.loginName) { + command += ` --login ${context.loginName}`; + } + return `Run \`${command}\` ${note}`; +} diff --git a/src/tea.ts b/src/tea.ts new file mode 100644 index 0000000..bdc96e8 --- /dev/null +++ b/src/tea.ts @@ -0,0 +1,102 @@ +import type { CliDeps } from "./deps.js"; +import { axiError } from "./errors.js"; +import { runSubprocess } from "./subprocess.js"; + +export interface TeaLogin { + name: string; + url: string; + sshHost: string; + isDefault: boolean; +} + +interface RawTeaLogin { + name?: string; + url?: string; + ssh_host?: string; + default?: string; +} + +function teaNotInstalled(): never { + throw axiError("tea is not installed", "TEA_NOT_INSTALLED", [ + "Install tea (https://gitea.com/gitea/tea) — gitea-axi discovers credentials from tea's login store", + "Then run `tea login add` to configure a login", + ]); +} + +function failureDetail(result: { stderr: string; code: number | null }): string { + return firstLine(result.stderr) || `exit code ${result.code}`; +} + +export async function listLogins(deps: CliDeps): Promise { + const result = await runSubprocess("tea", ["login", "list", "--output", "json"], { + env: deps.env, + }); + if (result.enoent) { + teaNotInstalled(); + } + if (result.code !== 0) { + throw axiError(`\`tea login list\` failed: ${failureDetail(result)}`, "UNKNOWN"); + } + let raw: unknown; + try { + raw = JSON.parse(result.stdout); + } catch { + throw axiError("`tea login list --output json` returned invalid JSON", "UNKNOWN"); + } + if (!Array.isArray(raw)) { + throw axiError("`tea login list --output json` returned unexpected output", "UNKNOWN"); + } + return (raw as RawTeaLogin[]).map((entry) => ({ + name: entry.name ?? "", + url: entry.url ?? "", + sshHost: entry.ssh_host ?? "", + isDefault: entry.default === "true", + })); +} + +// The list output carries no token (its columns are name, url, ssh_host, user, +// default), so the token comes from tea's git-credential interface, which also +// refreshes OAuth tokens transparently. +export async function getToken(deps: CliDeps, login: TeaLogin, host: string): Promise { + let protocol = "https"; + try { + protocol = new URL(login.url).protocol.replace(/:$/, ""); + } catch { + // Fall back to https; the helper only requires the host. + } + const result = await runSubprocess( + "tea", + ["login", "helper", "get", "--login", login.name], + { + env: deps.env, + stdin: `protocol=${protocol}\nhost=${host}\n\n`, + }, + ); + if (result.enoent) { + teaNotInstalled(); + } + if (result.code !== 0) { + throw axiError( + `tea could not provide a token for login "${login.name}": ${failureDetail(result)}`, + "AUTH_REQUIRED", + [`Run \`tea login edit ${login.name}\` to repair the login`], + ); + } + for (const line of result.stdout.split("\n")) { + if (line.startsWith("password=")) { + const token = line.slice("password=".length).trim(); + if (token) { + return token; + } + } + } + throw axiError( + `tea returned no token for login "${login.name}"`, + "AUTH_REQUIRED", + [`Run \`tea login edit ${login.name}\` to repair the login`], + ); +} + +function firstLine(text: string): string { + return text.split("\n", 1)[0]?.trim() ?? ""; +} diff --git a/src/time.ts b/src/time.ts new file mode 100644 index 0000000..66c06a2 --- /dev/null +++ b/src/time.ts @@ -0,0 +1,32 @@ +const MINUTE = 60; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; +const MONTH = 30 * DAY; +const YEAR = 365 * DAY; + +export function relativeTime(iso: string | undefined, now: Date): string { + if (!iso) { + return "unknown"; + } + const then = Date.parse(iso); + if (Number.isNaN(then)) { + return "unknown"; + } + const seconds = Math.max(0, Math.floor((now.getTime() - then) / 1000)); + if (seconds < MINUTE) { + return "just now"; + } + if (seconds < HOUR) { + return `${Math.floor(seconds / MINUTE)}m ago`; + } + if (seconds < DAY) { + return `${Math.floor(seconds / HOUR)}h ago`; + } + if (seconds < MONTH) { + return `${Math.floor(seconds / DAY)}d ago`; + } + if (seconds < YEAR) { + return `${Math.floor(seconds / MONTH)}mo ago`; + } + return `${Math.floor(seconds / YEAR)}y ago`; +} diff --git a/test/context.test.ts b/test/context.test.ts new file mode 100644 index 0000000..1e2ec3f --- /dev/null +++ b/test/context.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { startFixtureServer, type FixtureServer } from "./fixture-server.js"; +import { runCliTest, testModeEnv } from "./harness.js"; + +let server: FixtureServer | undefined; + +afterEach(async () => { + await server?.close(); + server = undefined; +}); + +describe("context overrides", () => { + it("prefers the -R flag over GITEA_AXI_REPO", async () => { + server = await startFixtureServer([ + { method: "GET", path: "/api/v1/repos/flagowner/flagrepo/issues", body: [] }, + ]); + const { exitCode } = await runCliTest( + ["issue", "list", "-R", "flagowner/flagrepo"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(server.requests[0]!.path).toBe("/api/v1/repos/flagowner/flagrepo/issues"); + }); + + it("accepts -R before the command", async () => { + server = await startFixtureServer([ + { method: "GET", path: "/api/v1/repos/flagowner/flagrepo/issues", body: [] }, + ]); + const { exitCode } = await runCliTest( + ["-R", "flagowner/flagrepo", "issue", "list"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(server.requests[0]!.path).toBe("/api/v1/repos/flagowner/flagrepo/issues"); + }); + + it("accepts the --repo=OWNER/NAME form", async () => { + server = await startFixtureServer([ + { method: "GET", path: "/api/v1/repos/flagowner/flagrepo/issues", body: [] }, + ]); + const { exitCode } = await runCliTest( + ["issue", "list", "--repo=flagowner/flagrepo"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(server.requests[0]!.path).toBe("/api/v1/repos/flagowner/flagrepo/issues"); + }); + + it("includes the -R override in next-step suggestions when context came from a flag", async () => { + server = await startFixtureServer([ + { method: "GET", path: "/api/v1/repos/flagowner/flagrepo/issues", body: [] }, + ]); + const { stdout } = await runCliTest( + ["issue", "list", "-R", "flagowner/flagrepo"], + { env: testModeEnv(server.url) }, + ); + + expect(stdout).toContain("-R flagowner/flagrepo"); + }); + + it("includes the -R override in suggestions when context came from the environment", async () => { + server = await startFixtureServer([ + { method: "GET", path: "/api/v1/repos/testowner/testrepo/issues", body: [] }, + ]); + const { stdout } = await runCliTest(["issue", "list"], { + env: testModeEnv(server.url), + }); + + expect(stdout).toContain("-R testowner/testrepo"); + }); + + it("fails with REPO_NOT_FOUND when test mode has no repository context", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["issue", "list"], { + env: { GITEA_AXI_API_URL: server.url, GITEA_AXI_TOKEN: "test-token" }, + }); + + expect(exitCode).toBe(1); + expect(stdout).toContain("code: REPO_NOT_FOUND"); + expect(stdout).toContain("GITEA_AXI_REPO"); + }); + + it("rejects a malformed -R value with exit code 2", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest( + ["issue", "list", "-R", "not-a-repo"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(stdout).toContain("OWNER/NAME"); + }); + + it("rejects -R without a value with exit code 2", async () => { + const { stdout, exitCode } = await runCliTest(["issue", "list", "-R"], { + env: { GITEA_AXI_API_URL: "http://127.0.0.1:1" }, + }); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + }); + + it("test mode never spawns git or tea", async () => { + // An empty PATH makes any subprocess spawn fail loudly; success here + // proves the git and tea subprocesses were suppressed. + server = await startFixtureServer([ + { method: "GET", path: "/api/v1/repos/testowner/testrepo/issues", body: [] }, + ]); + const { exitCode } = await runCliTest(["issue", "list"], { + env: { ...testModeEnv(server.url), PATH: "" }, + }); + + expect(exitCode).toBe(0); + }); +}); diff --git a/test/detection.test.ts b/test/detection.test.ts new file mode 100644 index 0000000..b6112be --- /dev/null +++ b/test/detection.test.ts @@ -0,0 +1,257 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { startFixtureServer, type FixtureServer } from "./fixture-server.js"; +import { runCliTest } from "./harness.js"; + +interface FakeLogin { + name: string; + url: string; + ssh_host?: string; + user?: string; + default?: string; +} + +const root = mkdtempSync(join(tmpdir(), "gitea-axi-detect-")); +const gitPath = execFileSync("which", ["git"], { encoding: "utf8" }).trim(); +// The fake tea script needs cat on the sandbox PATH for its heredoc branches. +const catPath = execFileSync("which", ["cat"], { encoding: "utf8" }).trim(); +let sandboxCounter = 0; + +afterAll(() => { + rmSync(root, { recursive: true, force: true }); +}); + +/** A PATH dir with real git and optionally a fake tea baked to fixed replies. */ +function makeSandbox(options: { logins?: FakeLogin[]; token?: string; tea?: boolean }): string { + const bin = join(root, `bin-${sandboxCounter++}`); + mkdirSync(bin); + symlinkSync(gitPath, join(bin, "git")); + symlinkSync(catPath, join(bin, "cat")); + if (options.tea !== false) { + const script = `#!/bin/sh +if [ "$1" = "login" ] && [ "$2" = "list" ]; then + cat <<'JSON' +${JSON.stringify(options.logins ?? [])} +JSON + exit 0 +fi +if [ "$1" = "login" ] && [ "$2" = "helper" ] && [ "$3" = "get" ]; then + cat > /dev/null + printf 'protocol=http\\nhost=fixture\\nusername=u\\npassword=%s\\n' '${options.token ?? ""}' + exit 0 +fi +echo "unexpected tea invocation: $*" >&2 +exit 1 +`; + writeFileSync(join(bin, "tea"), script, { mode: 0o755 }); + } + return bin; +} + +function makeRepo(remoteUrl: string | undefined): string { + const dir = join(root, `repo-${sandboxCounter++}`); + mkdirSync(dir); + execFileSync("git", ["init", "--quiet"], { cwd: dir }); + if (remoteUrl) { + execFileSync("git", ["remote", "add", "origin", remoteUrl], { cwd: dir }); + } + return dir; +} + +const ISSUES_PATH = "/api/v1/repos/testowner/testrepo/issues"; + +let server: FixtureServer | undefined; + +afterEach(async () => { + await server?.close(); + server = undefined; +}); + +async function startIssuesServer(): Promise { + server = await startFixtureServer([ + { + method: "GET", + path: ISSUES_PATH, + headers: { "X-Total-Count": "3" }, + fixture: "issues-open.json", + }, + ]); + return server; +} + +describe("repository context detection", () => { + it("detects the repo from an HTTPS origin remote and authenticates via tea", async () => { + const { url } = await startIssuesServer(); + const cwd = makeRepo(`${url}/testowner/testrepo.git`); + const bin = makeSandbox({ + logins: [{ name: "fixture", url, user: "u", default: "true" }], + token: "detected-token", + }); + + const { stdout, exitCode } = await runCliTest(["issue", "list"], { + env: { PATH: bin }, + cwd, + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("count: 3 of 3 total"); + expect(server!.requests[0]!.headers.authorization).toBe("Bearer detected-token"); + // Auto-detected context: suggestions must not carry override flags. + expect(stdout).not.toContain("-R testowner/testrepo"); + expect(stdout).not.toContain("--login"); + }); + + it("detects the repo from an SSH (scp-form) origin remote", async () => { + const { url } = await startIssuesServer(); + const cwd = makeRepo("git@127.0.0.1:testowner/testrepo.git"); + const bin = makeSandbox({ + logins: [{ name: "fixture", url, user: "u", default: "true" }], + token: "detected-token", + }); + + const { stdout, exitCode } = await runCliTest(["issue", "list"], { + env: { PATH: bin }, + cwd, + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("count: 3 of 3 total"); + }); + + it("fails with REPO_NOT_FOUND when there is no recognizable origin remote", async () => { + const cwd = makeRepo(undefined); + const bin = makeSandbox({ logins: [] }); + + const { stdout, exitCode } = await runCliTest(["issue", "list"], { + env: { PATH: bin }, + cwd, + }); + + expect(exitCode).toBe(1); + expect(stdout).toContain("code: REPO_NOT_FOUND"); + }); + + it("fails with TEA_NOT_INSTALLED when the tea binary is missing", async () => { + const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git"); + const bin = makeSandbox({ tea: false }); + + const { stdout, exitCode } = await runCliTest(["issue", "list"], { + env: { PATH: bin }, + cwd, + }); + + expect(exitCode).toBe(1); + expect(stdout).toContain("code: TEA_NOT_INSTALLED"); + }); + + it("fails with AUTH_REQUIRED when tea has zero logins", async () => { + const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git"); + const bin = makeSandbox({ logins: [] }); + + const { stdout, exitCode } = await runCliTest(["issue", "list"], { + env: { PATH: bin }, + cwd, + }); + + expect(exitCode).toBe(1); + expect(stdout).toContain("code: AUTH_REQUIRED"); + expect(stdout).toContain("tea login add"); + }); + + it("fails with REPO_NOT_FOUND when no login matches the remote hostname", async () => { + const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git"); + const bin = makeSandbox({ + logins: [{ name: "other", url: "https://other.example.net", default: "true" }], + }); + + const { stdout, exitCode } = await runCliTest(["issue", "list"], { + env: { PATH: bin }, + cwd, + }); + + expect(exitCode).toBe(1); + expect(stdout).toContain("code: REPO_NOT_FOUND"); + expect(stdout).toContain("gitea.example.com"); + }); + + it("fails with VALIDATION_ERROR listing profiles on an ambiguous multi-match", async () => { + const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git"); + const bin = makeSandbox({ + logins: [ + { name: "work", url: "https://gitea.example.com" }, + { name: "personal", url: "https://gitea.example.com" }, + ], + }); + + const { stdout, exitCode } = await runCliTest(["issue", "list"], { + env: { PATH: bin }, + cwd, + }); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(stdout).toContain("work"); + expect(stdout).toContain("personal"); + }); + + it("uses tea's default login when several match the hostname", async () => { + const { url } = await startIssuesServer(); + const cwd = makeRepo(`${url}/testowner/testrepo.git`); + const bin = makeSandbox({ + logins: [ + { name: "work", url }, + { name: "personal", url, default: "true" }, + ], + token: "default-token", + }); + + const { exitCode } = await runCliTest(["issue", "list"], { + env: { PATH: bin }, + cwd, + }); + + expect(exitCode).toBe(0); + expect(server!.requests[0]!.headers.authorization).toBe("Bearer default-token"); + }); + + it("fails with VALIDATION_ERROR listing available profiles for a nonexistent --login", async () => { + const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git"); + const bin = makeSandbox({ + logins: [ + { name: "work", url: "https://gitea.example.com", default: "true" }, + { name: "personal", url: "https://gitea.example.com" }, + ], + }); + + const { stdout, exitCode } = await runCliTest( + ["issue", "list", "--login", "missing"], + { env: { PATH: bin }, cwd }, + ); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(stdout).toContain("missing"); + expect(stdout).toContain("available: work, personal"); + }); + + it("selects a login by name with --login and adds it to suggestions", async () => { + const { url } = await startIssuesServer(); + const cwd = makeRepo("https://unrelated.example.org/testowner/testrepo.git"); + const bin = makeSandbox({ + logins: [{ name: "fixture", url }], + token: "named-token", + }); + + const { stdout, exitCode } = await runCliTest( + ["--login", "fixture", "issue", "list"], + { env: { PATH: bin }, cwd }, + ); + + expect(exitCode).toBe(0); + expect(server!.requests[0]!.headers.authorization).toBe("Bearer named-token"); + expect(stdout).toContain("--login fixture"); + }); +}); diff --git a/test/errors.test.ts b/test/errors.test.ts new file mode 100644 index 0000000..dcac9eb --- /dev/null +++ b/test/errors.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { startFixtureServer, type FixtureServer } from "./fixture-server.js"; +import { runCliTest, testModeEnv } from "./harness.js"; + +const ISSUES_PATH = "/api/v1/repos/testowner/testrepo/issues"; + +let server: FixtureServer; + +afterEach(async () => { + await server.close(); +}); + +async function listWithStatus(status: number, body: unknown = { message: "boom" }) { + server = await startFixtureServer([ + { method: "GET", path: ISSUES_PATH, status, body }, + ]); + return runCliTest(["issue", "list"], { env: testModeEnv(server.url) }); +} + +describe("error classification", () => { + it("maps 401 to AUTH_REQUIRED with exit code 1", async () => { + const { stdout, exitCode } = await listWithStatus(401, { message: "token is required" }); + expect(exitCode).toBe(1); + expect(stdout).toContain("code: AUTH_REQUIRED"); + expect(stdout).toContain("error: token is required"); + }); + + it("maps 403 to FORBIDDEN with exit code 1", async () => { + const { stdout, exitCode } = await listWithStatus(403); + expect(exitCode).toBe(1); + expect(stdout).toContain("code: FORBIDDEN"); + }); + + it("maps a 404 on the repo subtree to REPO_NOT_FOUND", async () => { + const { stdout, exitCode } = await listWithStatus(404, {}); + expect(exitCode).toBe(1); + expect(stdout).toContain("code: REPO_NOT_FOUND"); + expect(stdout).toContain("testowner/testrepo"); + }); + + it("maps 422 to VALIDATION_ERROR with the body message and exit code 2", async () => { + const { stdout, exitCode } = await listWithStatus(422, { + message: "state must be one of open, closed, all", + }); + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(stdout).toContain("state must be one of"); + }); + + it("maps 429 to RATE_LIMITED with exit code 1", async () => { + const { stdout, exitCode } = await listWithStatus(429, {}); + expect(exitCode).toBe(1); + expect(stdout).toContain("code: RATE_LIMITED"); + expect(stdout).toMatch(/help\[\d+\]:.*retry/i); + }); + + it("maps unexpected statuses to UNKNOWN with exit code 1", async () => { + const { stdout, exitCode } = await listWithStatus(500, { message: "internal error" }); + expect(exitCode).toBe(1); + expect(stdout).toContain("code: UNKNOWN"); + expect(stdout).toContain("internal error"); + }); + + it("errors are TOON error blocks on stdout", async () => { + const { stdout } = await listWithStatus(403, { message: "no access" }); + const lines = stdout.trimEnd().split("\n"); + expect(lines[0]).toBe("error: no access"); + expect(lines[1]).toBe("code: FORBIDDEN"); + expect(lines[2]).toMatch(/^help\[\d+\]:/); + }); +}); diff --git a/test/fixture-server.ts b/test/fixture-server.ts new file mode 100644 index 0000000..f80a25c --- /dev/null +++ b/test/fixture-server.ts @@ -0,0 +1,96 @@ +import { readFileSync } from "node:fs"; +import { createServer, type Server } from "node:http"; + +export interface FixtureRoute { + method: string; + /** Exact pathname to match, e.g. "/api/v1/repos/o/r/issues". */ + path: string; + /** Query params that must all be present with these exact values. */ + query?: Record; + status?: number; + headers?: Record; + /** Inline JSON body; mutually exclusive with `fixture`. */ + body?: unknown; + /** Name of a JSON file in test/fixtures to serve as the body. */ + fixture?: string; +} + +export interface RecordedRequest { + method: string; + path: string; + query: Record; + headers: Record; +} + +export interface FixtureServer { + url: string; + requests: RecordedRequest[]; + close: () => Promise; +} + +function loadBody(route: FixtureRoute): unknown { + if (route.fixture !== undefined) { + return JSON.parse( + readFileSync(new URL(`./fixtures/${route.fixture}`, import.meta.url), "utf8"), + ); + } + return route.body ?? {}; +} + +function matches(route: FixtureRoute, request: RecordedRequest): boolean { + if (route.method !== request.method || route.path !== request.path) { + return false; + } + for (const [key, value] of Object.entries(route.query ?? {})) { + if (request.query[key] !== value) { + return false; + } + } + return true; +} + +export async function startFixtureServer(routes: FixtureRoute[]): Promise { + const requests: RecordedRequest[] = []; + const server: Server = createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://fixture"); + const recorded: RecordedRequest = { + method: req.method ?? "GET", + path: url.pathname, + query: Object.fromEntries(url.searchParams), + headers: Object.fromEntries( + Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(",") : (v ?? "")]), + ), + }; + requests.push(recorded); + const route = routes.find((candidate) => matches(candidate, recorded)); + if (!route) { + res.writeHead(599, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + message: `no fixture route matched ${recorded.method} ${recorded.path} ${JSON.stringify(recorded.query)}`, + }), + ); + return; + } + res.writeHead(route.status ?? 200, { + "content-type": "application/json", + ...route.headers, + }); + res.end(JSON.stringify(loadBody(route))); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("fixture server has no address"); + } + return { + url: `http://127.0.0.1:${address.port}`, + requests, + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + }; +} diff --git a/test/fixtures/issues-closed.json b/test/fixtures/issues-closed.json new file mode 100644 index 0000000..462936c --- /dev/null +++ b/test/fixtures/issues-closed.json @@ -0,0 +1,27 @@ +[ + { + "id": 300, + "number": 37, + "title": "Crash on empty config", + "body": "Fixed in 1.2.1", + "state": "closed", + "is_locked": false, + "comments": 1, + "created_at": "2026-04-10T11:00:00Z", + "updated_at": "2026-04-12T11:00:00Z", + "closed_at": "2026-04-12T11:00:00Z", + "html_url": "http://gitea.example/testowner/testrepo/issues/37", + "url": "http://gitea.example/api/v1/repos/testowner/testrepo/issues/37", + "user": { + "id": 9, + "login": "contributor", + "full_name": "A Contributor", + "email": "contributor@example.com" + }, + "labels": [], + "milestone": null, + "assignee": null, + "assignees": null, + "pull_request": null + } +] diff --git a/test/fixtures/issues-open.json b/test/fixtures/issues-open.json new file mode 100644 index 0000000..18653c2 --- /dev/null +++ b/test/fixtures/issues-open.json @@ -0,0 +1,78 @@ +[ + { + "id": 301, + "number": 42, + "title": "Fix login redirect loop, please", + "body": "Steps to reproduce: log in twice.", + "state": "open", + "is_locked": false, + "comments": 2, + "created_at": "2026-07-01T10:00:00Z", + "updated_at": "2026-07-08T09:30:00Z", + "html_url": "http://gitea.example/testowner/testrepo/issues/42", + "url": "http://gitea.example/api/v1/repos/testowner/testrepo/issues/42", + "user": { + "id": 7, + "login": "alexion", + "full_name": "Alexion", + "email": "alexion@example.com" + }, + "labels": [ + { "id": 1, "name": "bug", "color": "ee0701" } + ], + "milestone": null, + "assignee": null, + "assignees": null, + "pull_request": null + }, + { + "id": 302, + "number": 41, + "title": "Add dark mode", + "body": "", + "state": "open", + "is_locked": false, + "comments": 0, + "created_at": "2026-06-20T15:45:00Z", + "updated_at": "2026-06-20T15:45:00Z", + "html_url": "http://gitea.example/testowner/testrepo/issues/41", + "url": "http://gitea.example/api/v1/repos/testowner/testrepo/issues/41", + "user": { + "id": 9, + "login": "contributor", + "full_name": "A Contributor", + "email": "contributor@example.com" + }, + "labels": [], + "milestone": null, + "assignee": null, + "assignees": null, + "pull_request": null + }, + { + "id": 303, + "number": 38, + "title": "Docs: document the release process", + "body": "The release process lives only in someone's head.", + "state": "open", + "is_locked": false, + "comments": 5, + "created_at": "2026-05-02T08:00:00Z", + "updated_at": "2026-07-01T12:00:00Z", + "html_url": "http://gitea.example/testowner/testrepo/issues/38", + "url": "http://gitea.example/api/v1/repos/testowner/testrepo/issues/38", + "user": { + "id": 7, + "login": "alexion", + "full_name": "Alexion", + "email": "alexion@example.com" + }, + "labels": [ + { "id": 2, "name": "documentation", "color": "0075ca" } + ], + "milestone": null, + "assignee": null, + "assignees": null, + "pull_request": null + } +] diff --git a/test/git.test.ts b/test/git.test.ts new file mode 100644 index 0000000..232bbab --- /dev/null +++ b/test/git.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { parseRemoteUrl } from "../src/git.js"; + +describe("parseRemoteUrl", () => { + it("parses HTTPS remotes with and without .git", () => { + expect(parseRemoteUrl("https://git.example.com/owner/repo.git")).toEqual({ + host: "git.example.com", + owner: "owner", + name: "repo", + }); + expect(parseRemoteUrl("https://git.example.com/owner/repo")).toEqual({ + host: "git.example.com", + owner: "owner", + name: "repo", + }); + }); + + it("parses HTTP remotes with a port", () => { + expect(parseRemoteUrl("http://git.example.com:3000/owner/repo.git")).toEqual({ + host: "git.example.com", + owner: "owner", + name: "repo", + }); + }); + + it("parses scp-form SSH remotes", () => { + expect(parseRemoteUrl("git@git.example.com:owner/repo.git")).toEqual({ + host: "git.example.com", + owner: "owner", + name: "repo", + }); + expect(parseRemoteUrl("git.example.com:owner/repo")).toEqual({ + host: "git.example.com", + owner: "owner", + name: "repo", + }); + }); + + it("parses ssh:// remotes with a port", () => { + expect(parseRemoteUrl("ssh://git@git.example.com:2222/owner/repo.git")).toEqual({ + host: "git.example.com", + owner: "owner", + name: "repo", + }); + }); + + it("rejects URLs without an owner/name path", () => { + expect(parseRemoteUrl("https://git.example.com/owner")).toBeNull(); + expect(parseRemoteUrl("https://git.example.com/a/b/c")).toBeNull(); + expect(parseRemoteUrl("not a url")).toBeNull(); + expect(parseRemoteUrl("/local/path/repo.git")).toBeNull(); + }); +}); diff --git a/test/harness.ts b/test/harness.ts new file mode 100644 index 0000000..f2e695c --- /dev/null +++ b/test/harness.ts @@ -0,0 +1,42 @@ +import { runCli } from "../src/cli.js"; + +export interface CliResult { + stdout: string; + exitCode: number; +} + +export interface CliTestOptions { + env?: Record; + cwd?: string; +} + +/** + * Drive the CLI seam: argv in, stdout and exit code out. The environment is + * fully explicit — nothing leaks in from the test process's own env. + */ +export async function runCliTest( + argv: string[], + options: CliTestOptions = {}, +): Promise { + let stdout = ""; + const exitCode = await runCli({ + argv, + env: options.env ?? {}, + cwd: options.cwd ?? process.cwd(), + stdout: { + write: (chunk: string) => { + stdout += chunk; + }, + }, + }); + process.exitCode = 0; + return { stdout, exitCode }; +} + +export function testModeEnv(apiUrl: string): Record { + return { + GITEA_AXI_API_URL: apiUrl, + GITEA_AXI_TOKEN: "test-token", + GITEA_AXI_REPO: "testowner/testrepo", + }; +} diff --git a/test/help.test.ts b/test/help.test.ts new file mode 100644 index 0000000..27d3227 --- /dev/null +++ b/test/help.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { runCliTest } from "./harness.js"; + +describe("--help", () => { + it("prints a top-level flag reference and exits 0", async () => { + const { stdout, exitCode } = await runCliTest(["--help"]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("usage: gitea-axi"); + expect(stdout).toContain("issue list"); + expect(stdout).toContain("-R, --repo"); + expect(stdout).toContain("--login"); + }); + + it("prints the issue list flag reference and exits 0", async () => { + const { stdout, exitCode } = await runCliTest(["issue", "list", "--help"]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("usage: gitea-axi issue list"); + expect(stdout).toContain("--state"); + expect(stdout).toContain("--limit"); + }); + + it("prints the issue group help and exits 0", async () => { + const { stdout, exitCode } = await runCliTest(["issue", "--help"]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("usage: gitea-axi issue"); + expect(stdout).toContain("list"); + }); + + it("prints the version for --version", async () => { + const { stdout, exitCode } = await runCliTest(["--version"]); + + expect(exitCode).toBe(0); + expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it("rejects unknown commands with exit code 2", async () => { + const { stdout, exitCode } = await runCliTest(["frobnicate"]); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(stdout).toContain("frobnicate"); + }); +}); diff --git a/test/issue-list.test.ts b/test/issue-list.test.ts new file mode 100644 index 0000000..5fb7401 --- /dev/null +++ b/test/issue-list.test.ts @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { startFixtureServer, type FixtureServer } from "./fixture-server.js"; +import { runCliTest, testModeEnv } from "./harness.js"; + +const ISSUES_PATH = "/api/v1/repos/testowner/testrepo/issues"; + +let server: FixtureServer; + +afterEach(async () => { + await server.close(); +}); + +describe("issue list", () => { + it("lists open issues with default fields and a count line", async () => { + server = await startFixtureServer([ + { + method: "GET", + path: ISSUES_PATH, + query: { state: "open", type: "issues", limit: "30", page: "1" }, + headers: { "X-Total-Count": "17" }, + fixture: "issues-open.json", + }, + ]); + const { stdout, exitCode } = await runCliTest(["issue", "list"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + const lines = stdout.split("\n"); + expect(lines[0]).toBe("count: 3 of 17 total"); + expect(lines[1]).toBe("issues[3]{number,title,state,author,created}:"); + expect(lines[2]).toMatch(/^ {2}42,"Fix login redirect loop, please",open,alexion,\d+(mo|[smhdy]) ago$/); + expect(lines[3]).toMatch(/^ {2}41,Add dark mode,open,contributor,\d+(mo|[smhdy]) ago$/); + expect(lines[4]).toMatch(/^ {2}38,"Docs: document the release process",open,alexion,\d+(mo|[smhdy]) ago$/); + expect(stdout).toMatch(/^help\[\d+\]:/m); + }); + + it("passes type=issues on every issues-list API call", async () => { + server = await startFixtureServer([ + { method: "GET", path: ISSUES_PATH, query: { type: "issues" }, body: [] }, + ]); + await runCliTest(["issue", "list"], { env: testModeEnv(server.url) }); + + expect(server.requests).toHaveLength(1); + expect(server.requests[0]!.query.type).toBe("issues"); + }); + + it("sends the token as a bearer Authorization header", async () => { + server = await startFixtureServer([ + { method: "GET", path: ISSUES_PATH, body: [] }, + ]); + await runCliTest(["issue", "list"], { env: testModeEnv(server.url) }); + + expect(server.requests[0]!.headers.authorization).toBe("Bearer test-token"); + }); + + it("passes --state and --limit through to the API", async () => { + server = await startFixtureServer([ + { + method: "GET", + path: ISSUES_PATH, + query: { state: "closed", limit: "5" }, + headers: { "X-Total-Count": "1" }, + fixture: "issues-closed.json", + }, + ]); + const { stdout, exitCode } = await runCliTest( + ["issue", "list", "--state", "closed", "--limit", "5"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("count: 1 of 1 total"); + expect(stdout).toContain("37,Crash on empty config,closed,contributor"); + }); + + it("defaults to state=open and limit=30", async () => { + server = await startFixtureServer([ + { method: "GET", path: ISSUES_PATH, body: [] }, + ]); + await runCliTest(["issue", "list"], { env: testModeEnv(server.url) }); + + expect(server.requests[0]!.query.state).toBe("open"); + expect(server.requests[0]!.query.limit).toBe("30"); + }); + + it("emits an explicit empty state with a next-step suggestion", async () => { + server = await startFixtureServer([ + { + method: "GET", + path: ISSUES_PATH, + headers: { "X-Total-Count": "0" }, + body: [], + }, + ]); + const { stdout, exitCode } = await runCliTest(["issue", "list"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("count: 0 of 0 total"); + expect(stdout).toContain("issues[0]: (none)"); + expect(stdout).toMatch(/^help\[\d+\]:/m); + }); + + it("suggests raising --limit when more issues exist than were shown", async () => { + server = await startFixtureServer([ + { + method: "GET", + path: ISSUES_PATH, + query: { limit: "3" }, + headers: { "X-Total-Count": "17" }, + fixture: "issues-open.json", + }, + ]); + const { stdout } = await runCliTest(["issue", "list", "--limit", "3"], { + env: testModeEnv(server.url), + }); + + expect(stdout).toContain("issue list --limit "); + }); + + it("rejects an invalid --state value with exit code 2", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest( + ["issue", "list", "--state", "banana"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(server.requests).toHaveLength(0); + }); + + it("rejects an invalid --limit value with exit code 2", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest( + ["issue", "list", "--limit", "0"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + }); + + it("rejects unknown flags with exit code 2", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest( + ["issue", "list", "--frobnicate"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(stdout).toContain("--frobnicate"); + }); + + it("rejects unknown issue subcommands with exit code 2", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["issue", "destroy"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + }); +}); diff --git a/test/time.test.ts b/test/time.test.ts new file mode 100644 index 0000000..630177f --- /dev/null +++ b/test/time.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { relativeTime } from "../src/time.js"; + +const now = new Date("2026-07-10T12:00:00Z"); + +describe("relativeTime", () => { + it("formats each magnitude bucket", () => { + expect(relativeTime("2026-07-10T11:59:30Z", now)).toBe("just now"); + expect(relativeTime("2026-07-10T11:45:00Z", now)).toBe("15m ago"); + expect(relativeTime("2026-07-10T07:00:00Z", now)).toBe("5h ago"); + expect(relativeTime("2026-07-03T12:00:00Z", now)).toBe("7d ago"); + expect(relativeTime("2026-05-10T12:00:00Z", now)).toBe("2mo ago"); + expect(relativeTime("2024-07-10T12:00:00Z", now)).toBe("2y ago"); + }); + + it("clamps future timestamps to just now", () => { + expect(relativeTime("2026-07-10T13:00:00Z", now)).toBe("just now"); + }); + + it("returns unknown for missing or invalid input, matching gh-axi", () => { + expect(relativeTime(undefined, now)).toBe("unknown"); + expect(relativeTime("garbage", now)).toBe("unknown"); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..469ac55 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b3612e5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "types": ["node"], + "noEmit": true + }, + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..8b5840a --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + }, +});