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)
This commit is contained in:
2026-07-11 07:11:07 -04:00
parent 21a075f8cd
commit 38026f963d
34 changed files with 3892 additions and 0 deletions

193
src/context.ts Normal file
View File

@@ -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 <name>` 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 <name>` 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 <name>`",
],
);
}
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 <name>` to select one"],
);
}
export async function resolveRepoContext(deps: CliDeps): Promise<RepoContext> {
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 <name>`",
],
);
}
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,
};
}