@knowttl/gitea-axi 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -2
- package/axi-credential.schema.json +21 -0
- package/dist/api.js +134 -0
- package/dist/cli.js +11 -0
- package/dist/credentials.js +133 -0
- package/dist/error.js +9 -0
- package/dist/main.js +156 -0
- package/dist/private-file.js +49 -0
- package/dist/reads.js +230 -0
- package/dist/registry.js +55 -0
- package/dist/repository.js +37 -0
- package/dist/transport.js +65 -0
- package/dist/vendor.generated.js +5 -0
- package/generated/help.txt +11 -0
- package/generated/host-inventory.json +792 -0
- package/generated/routes.json +2025 -0
- package/package.json +33 -4
- package/schemas/responses.json +63 -0
- package/schemas/vendor.json +7130 -0
package/README.md
CHANGED
|
@@ -1,3 +1,109 @@
|
|
|
1
|
-
#
|
|
1
|
+
# gitea-axi
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An agent CLI for Gitea repository, issue and pull request reads with private credential profiles.
|
|
4
|
+
It includes a bounded repository dashboard, list and detail views, reviews, checks, diffs and classified raw GET requests.
|
|
5
|
+
Verification uses offline fakes only; live Gitea access remains unverified.
|
|
6
|
+
|
|
7
|
+
Use Node 24.14.0 (minimum 24.11.0) and npm 11.9.0.
|
|
8
|
+
Dependencies are exact in `package.json` and transitive versions are locked in `package-lock.json`.
|
|
9
|
+
Install the public npm package:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install -g @knowttl/gitea-axi
|
|
13
|
+
gitea-axi --version
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The executable is `gitea-axi`.
|
|
17
|
+
To build from source:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm ci --ignore-scripts
|
|
21
|
+
npm run build
|
|
22
|
+
node dist/cli.js repo view --help
|
|
23
|
+
node scripts/verify.mjs --task S1.1
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The verification driver builds and packs the CLI, installs that tarball into an empty temporary prefix and invokes its installed bin from outside the source tree.
|
|
27
|
+
Dependency installation can access npm; Gitea requests use only local fakes.
|
|
28
|
+
Only the local HTTP fake replaces the vendor boundary.
|
|
29
|
+
The S1.1 driver checks AC-001, AC-002 and AC-004, package contents, registry freshness, type checking, linting and refusal cases.
|
|
30
|
+
Its report contains exact process invocations, assertions, runtime pins and the package SHA-256.
|
|
31
|
+
Each driver prints its report path.
|
|
32
|
+
Set `GITEA_AXI_REPORT` to retain the report at a chosen path.
|
|
33
|
+
The default report goes to a unique file in the OS temporary directory; temporary installation files are removed.
|
|
34
|
+
|
|
35
|
+
Command syntax:
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
gitea-axi repo view --repo OWNER/NAME --origin https://gitea.example.test
|
|
39
|
+
gitea-axi --repo OWNER/NAME
|
|
40
|
+
gitea-axi issue list --repo OWNER/NAME --state open --limit 2
|
|
41
|
+
gitea-axi pr view 7 --repo OWNER/NAME --reviews --checks
|
|
42
|
+
gitea-axi pr view 7 --repo OWNER/NAME --fields number,title,headRefOid
|
|
43
|
+
gitea-axi pr checks 7 --repo OWNER/NAME --watch --timeout 30
|
|
44
|
+
gitea-axi api GET repos/OWNER/NAME
|
|
45
|
+
gitea-axi auth status
|
|
46
|
+
gitea-axi profile list
|
|
47
|
+
gitea-axi config path
|
|
48
|
+
gitea-axi doctor
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Anonymous reads require an explicit origin; repository selection follows the rules below.
|
|
52
|
+
Anonymous HTTP reads require `--allow-insecure-http` for that selected origin.
|
|
53
|
+
The tool follows no redirects and never imports tea or Git credentials.
|
|
54
|
+
Output uses TOON; structured errors use stdout, with exit 1 for operational failures and exit 2 for usage errors.
|
|
55
|
+
Business reads share a 30-second deadline, except check watches use their explicit timeout; each request has a 2,000,000-byte response limit.
|
|
56
|
+
`repo view` instead gives each of its server version, specification and repository requests a separate 30-second timeout.
|
|
57
|
+
An explicit repository wins over a matching local origin remote; a remote never changes the selected profile.
|
|
58
|
+
Hosted repository reads require an explicit repository.
|
|
59
|
+
Lists default to 30 returned records and bound collection scans to 20 pages or 1000 unique records.
|
|
60
|
+
Issue and PR lists filter before globally sorting the collected records and applying the return limit.
|
|
61
|
+
Milestone selectors use exact names, including numeric-looking names.
|
|
62
|
+
List output distinguishes unknown vendor totals from known totals and reports continuation and truncation.
|
|
63
|
+
Dashboard sections report permission failures as partial results.
|
|
64
|
+
Bodies default to 1000 characters, diffs to 10000 characters, and rendered output to 100000 characters.
|
|
65
|
+
`--full` expands bodies and diffs within the response and output ceilings.
|
|
66
|
+
`pr checks` exits 8 for pending checks and 1 for failed or absent checks; watches require an explicit deadline and accept cancellation.
|
|
67
|
+
`--fields` refuses unknown names and fields without an evidenced Gitea equivalent, and cannot combine with detail expansion flags.
|
|
68
|
+
Raw requests accept `api [GET] <relative-path>` for JSON metadata and refuse absolute URLs, traversal, secret query fields, credential-bearing routes, administrative routes, text or binary downloads and unclassified routes.
|
|
69
|
+
HTTP 401, 403 and ambiguous 404 responses remain distinct failures, and rate-limit retry hints never cause automatic retries.
|
|
70
|
+
|
|
71
|
+
`src/registry.ts` owns command metadata, help and the host inventory.
|
|
72
|
+
`npm run generate` generates those artifacts plus the consumed response schemas, route types and classified raw-route registry from the retained Swagger.
|
|
73
|
+
`npm run generate:check` rejects stale artifacts.
|
|
74
|
+
`evidence/README.md` records retained source paths, provenance and hashes.
|
|
75
|
+
Credentialed reads use the shipped `axi-credential.schema.json` contract.
|
|
76
|
+
The config file is `$XDG_CONFIG_HOME/gitea-axi/config.json`, otherwise `~/.config/gitea-axi/config.json` on Linux, and `%APPDATA%\gitea-axi\config.json` on Windows.
|
|
77
|
+
`--config` takes precedence over `GITEA_AXI_CONFIG`, and `--profile` takes precedence over `GITEA_AXI_PROFILE`.
|
|
78
|
+
`--config` requires an absolute path.
|
|
79
|
+
The config object contains `version: 1`, a `profiles` object keyed by profile name, and an optional `defaultProfile` name.
|
|
80
|
+
Without `--profile` or `GITEA_AXI_PROFILE`, setting any environment-profile field selects the environment profile instead of the config file; incomplete profiles are refused.
|
|
81
|
+
File selection uses the explicit profile, otherwise the file's `defaultProfile` or its sole profile.
|
|
82
|
+
Multiple file profiles without a selection or default are refused.
|
|
83
|
+
On Linux, the config file must be owned by the invoking account and must not be group- or world-writable.
|
|
84
|
+
Config files and file SecretRefs must be regular files; symlinks are refused.
|
|
85
|
+
See [the credential schema](axi-credential.schema.json) for profile fields and env/file SecretRef shapes.
|
|
86
|
+
`origin` and `tokenOrigin` must identify the same canonical origin.
|
|
87
|
+
Inline tokens and secret arguments are refused.
|
|
88
|
+
Resolved credentials are redacted from response and error string values before TOON serialization, including their JSON-escaped and URL-encoded forms.
|
|
89
|
+
Redaction also masks recognized secret fields, secret query values, URL credentials, JWTs, private keys and account keys.
|
|
90
|
+
The complete environment profile uses `GITEA_AXI_ORIGIN`, `GITEA_AXI_TOKEN_ORIGIN` and a JSON SecretRef in `GITEA_AXI_TOKEN`.
|
|
91
|
+
Set `GITEA_AXI_ALLOW_INSECURE_HTTP=true` to permit HTTP for that environment profile; only the exact values `true` and `false` are accepted.
|
|
92
|
+
HTTP requires `allowInsecureHttp: true` on that exact profile; a runtime origin override cannot reuse the token for another origin.
|
|
93
|
+
Keep file references owned by the invoking account with mode 0600 on Linux.
|
|
94
|
+
Windows file protection is PENDING; file SecretRefs are refused until native ACL verification is available.
|
|
95
|
+
Use env SecretRefs on Windows.
|
|
96
|
+
`auth status` resolves the selected credentials locally; its `authenticated` field reports token availability without checking the server.
|
|
97
|
+
`doctor` checks the current identity through Gitea's `/api/v1/user` endpoint.
|
|
98
|
+
|
|
99
|
+
Stock Artium support is not claimed.
|
|
100
|
+
Full profile management and native Windows permission verification remain pending.
|
|
101
|
+
|
|
102
|
+
`npm test` runs the installed-package checks with local Gitea fakes for S1.1 and S2.1 through S2.4.
|
|
103
|
+
Each S2 task also has an independent entrypoint, for example `node scripts/verify.mjs --task S2.3`.
|
|
104
|
+
The S2 fixtures contain synthetic non-empty records authored against the retained schemas, with independent expected projections for supported selected PR fields and explicit capability refusals for the remaining fields.
|
|
105
|
+
Set `GITEA_AXI_ARTIUM_ROOT` to a built admission fixture containing this package's read inventory to repeat business reads through a real local Artium server, client, credential writer and runner.
|
|
106
|
+
Set `GITEA_AXI_ARTIUM_MANIFEST` to that fixture's frozen manifest; the S2 proof records its revision and manifest digest alongside the tested source head and package digest.
|
|
107
|
+
The S2 driver reports whether Artium was selected; a standalone pass does not establish hosted admission.
|
|
108
|
+
Hosted CLI behavior scenarios must execute and match the expected exit code and standalone result; expected admission refusals are checked separately for their exact error code and recorded in `hostedRefusals`.
|
|
109
|
+
The fixture must admit item positionals, raw GET paths and the declared read flags while continuing to block credential and endpoint overrides.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"title": "Gitea profile",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"additionalProperties": false,
|
|
6
|
+
"required": ["origin", "tokenOrigin", "token"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"origin": { "type": "string", "pattern": "^https?://[^/@?#\\s]+$" },
|
|
9
|
+
"tokenOrigin": { "type": "string", "pattern": "^https?://[^/@?#\\s]+$" },
|
|
10
|
+
"token": {
|
|
11
|
+
"x-secret": true,
|
|
12
|
+
"oneOf": [
|
|
13
|
+
{ "type": "object", "additionalProperties": false, "required": ["env"], "properties": { "env": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" } } },
|
|
14
|
+
{ "type": "object", "additionalProperties": false, "required": ["file"], "properties": { "file": { "type": "string", "pattern": "^(?:/|[A-Za-z]:[\\\\/])" } } }
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"allowInsecureHttp": { "type": "boolean", "default": false }
|
|
18
|
+
},
|
|
19
|
+
"if": { "properties": { "origin": { "pattern": "^http:" } } },
|
|
20
|
+
"then": { "required": ["allowInsecureHttp"], "properties": { "allowInsecureHttp": { "const": true } } }
|
|
21
|
+
}
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { Ajv } from 'ajv';
|
|
3
|
+
import { CliError } from './error.js';
|
|
4
|
+
const vendor = JSON.parse(readFileSync(new URL('../schemas/vendor.json', import.meta.url), 'utf8'));
|
|
5
|
+
const routes = JSON.parse(readFileSync(new URL('../generated/routes.json', import.meta.url), 'utf8'));
|
|
6
|
+
routes.sort((a, b) => b.path.replace(/\{[^}]+\}/g, '').length - a.path.replace(/\{[^}]+\}/g, '').length);
|
|
7
|
+
const required = {
|
|
8
|
+
Issue: ['id', 'number', 'title', 'state'], PullRequest: ['id', 'number', 'title', 'state', 'head', 'base'],
|
|
9
|
+
Label: ['id', 'name', 'color'], Repository: ['id', 'full_name', 'default_branch'], User: ['id', 'login'],
|
|
10
|
+
Comment: ['id', 'body', 'user'], PullReview: ['id', 'user', 'state', 'body', 'commit_id'],
|
|
11
|
+
CommitStatus: ['id', 'context', 'status'], Milestone: ['id', 'title'],
|
|
12
|
+
PRBranchInfo: ['sha', 'ref'], CombinedStatus: ['sha', 'statuses', 'total_count'],
|
|
13
|
+
};
|
|
14
|
+
for (const [name, fields] of Object.entries(required)) {
|
|
15
|
+
if (vendor.definitions[name])
|
|
16
|
+
vendor.definitions[name].required = fields;
|
|
17
|
+
}
|
|
18
|
+
// Gitea's Go pointer fields and nil slices serialize as null, which Swagger 2 cannot express.
|
|
19
|
+
for (const definition of Object.values(vendor.definitions)) {
|
|
20
|
+
for (const [key, property] of Object.entries(definition.properties ?? {})) {
|
|
21
|
+
if (property.$ref && !['state', 'status'].includes(key) || property.type === 'array' || property.format === 'date-time') {
|
|
22
|
+
definition.properties[key] = { anyOf: [property, { type: 'null' }] };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const ajv = new Ajv({ strict: false, validateFormats: false });
|
|
27
|
+
const validators = new Map();
|
|
28
|
+
export function object(value) {
|
|
29
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
30
|
+
throw new CliError('INVALID_RESPONSE', 'Expected an object from Gitea');
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
export function routeFor(path) {
|
|
34
|
+
const route = routes.find(route => new RegExp('^' + route.path.replace(/[.]/g, '\\.').replace(/\{[^}]+\}/g, '[^/]+') + '$').test(path));
|
|
35
|
+
if (route?.effect === 'admin')
|
|
36
|
+
throw new CliError('READ_ONLY', 'Administrative API routes are refused; only metadata reads are available');
|
|
37
|
+
if (route?.effect === 'credential')
|
|
38
|
+
throw new CliError('READ_ONLY', 'Credential-bearing API routes are refused; only metadata reads are available');
|
|
39
|
+
if (route?.effect === 'download')
|
|
40
|
+
throw new CliError('READ_ONLY', 'Text and binary download routes are refused; only JSON metadata reads are available');
|
|
41
|
+
if (!route || route.effect !== 'read')
|
|
42
|
+
throw new CliError('READ_ONLY', 'Only classified metadata reads are available');
|
|
43
|
+
return route;
|
|
44
|
+
}
|
|
45
|
+
export function validate(path, value) {
|
|
46
|
+
const route = routeFor(path);
|
|
47
|
+
if (route.schema) {
|
|
48
|
+
let validator = validators.get(route.path);
|
|
49
|
+
if (!validator) {
|
|
50
|
+
validator = ajv.compile({ ...route.schema, definitions: vendor.definitions });
|
|
51
|
+
validators.set(route.path, validator);
|
|
52
|
+
}
|
|
53
|
+
if (!validator(value))
|
|
54
|
+
throw new CliError('INVALID_RESPONSE', route.operation + ' returned invalid fields');
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
export function rawPath(input) {
|
|
59
|
+
let decoded;
|
|
60
|
+
try {
|
|
61
|
+
decoded = decodeURIComponent(input);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new CliError('USAGE', 'Malformed API path', 2);
|
|
65
|
+
}
|
|
66
|
+
if (/^[a-z]+:/i.test(input) || input.startsWith('//') || /[\\#\s]/.test(decoded) || decoded.includes('%') ||
|
|
67
|
+
decoded.split(/[/?]/).some(part => part === '.' || part === '..'))
|
|
68
|
+
throw new CliError('USAGE', 'Use a relative API path without traversal', 2);
|
|
69
|
+
const [pathname, query = ''] = input.split('?');
|
|
70
|
+
for (const key of new URLSearchParams(query).keys()) {
|
|
71
|
+
if (/token|secret|password|authorization|api.?key/i.test(key))
|
|
72
|
+
throw new CliError('CONFIG_INVALID', 'Secret query fields are refused');
|
|
73
|
+
}
|
|
74
|
+
const path = pathname === '/swagger.v1.json' ? pathname : pathname?.startsWith('/api/v1/') ? pathname : '/api/v1/' + pathname?.replace(/^\//, '');
|
|
75
|
+
if (path !== '/swagger.v1.json')
|
|
76
|
+
routeFor(path);
|
|
77
|
+
return path + (query ? '?' + query : '');
|
|
78
|
+
}
|
|
79
|
+
export async function read(client, path) {
|
|
80
|
+
const value = await client.get(path);
|
|
81
|
+
return validate(path.split('?')[0], value);
|
|
82
|
+
}
|
|
83
|
+
function nextPage(headers, path, origin) {
|
|
84
|
+
const target = headers.get('link')?.split(',').find(part => /rel="?next"?/.test(part))?.match(/<([^>]+)>/)?.[1];
|
|
85
|
+
if (!target)
|
|
86
|
+
return null;
|
|
87
|
+
const url = new URL(target, new URL(path, origin));
|
|
88
|
+
if (url.origin !== origin || url.pathname !== new URL(path, origin).pathname || url.username || url.password || url.hash) {
|
|
89
|
+
throw new CliError('UNSAFE_PAGINATION', 'Pagination must stay on the selected origin and collection');
|
|
90
|
+
}
|
|
91
|
+
return rawPath(url.pathname + url.search);
|
|
92
|
+
}
|
|
93
|
+
export async function rawRead(client, path) {
|
|
94
|
+
const response = await client.request(path);
|
|
95
|
+
const data = path === '/swagger.v1.json' ? response.data : validate(new URL(path, client.origin).pathname, response.data);
|
|
96
|
+
const count = response.headers.get('x-total-count');
|
|
97
|
+
return { data, total: count && /^\d+$/.test(count) ? Number(count) : null, next: nextPage(response.headers, path, client.origin), truncated: false };
|
|
98
|
+
}
|
|
99
|
+
export async function collect(client, path, envelope) {
|
|
100
|
+
let next = path + (path.includes('?') ? '&' : '?') + 'limit=50&page=1';
|
|
101
|
+
const seenPages = new Set();
|
|
102
|
+
const items = new Map();
|
|
103
|
+
let total = null;
|
|
104
|
+
while (next && seenPages.size < 20 && items.size < 1000 && Date.now() < client.deadline) {
|
|
105
|
+
if (seenPages.has(next))
|
|
106
|
+
throw new CliError('INVALID_RESPONSE', 'Gitea repeated a pagination URL');
|
|
107
|
+
seenPages.add(next);
|
|
108
|
+
const response = await client.request(next);
|
|
109
|
+
const data = validate(new URL(next, client.origin).pathname, response.data);
|
|
110
|
+
const rows = envelope ? object(data)[envelope] : data;
|
|
111
|
+
if (!Array.isArray(rows))
|
|
112
|
+
throw new CliError('INVALID_RESPONSE', 'Expected a Gitea list' + (envelope ? ' envelope ' + envelope : ''));
|
|
113
|
+
for (const value of rows) {
|
|
114
|
+
const row = object(value);
|
|
115
|
+
const id = row.id ?? row.number ?? row.sha ?? row.filename ?? (row.user && row.content ? String(object(row.user).id) + ':' + row.content : undefined);
|
|
116
|
+
if (typeof id !== 'number' && typeof id !== 'string')
|
|
117
|
+
throw new CliError('INVALID_RESPONSE', 'List record has no identity');
|
|
118
|
+
items.set(String(id), row);
|
|
119
|
+
}
|
|
120
|
+
const count = response.headers.get('x-total-count');
|
|
121
|
+
if (count && /^\d+$/.test(count))
|
|
122
|
+
total = Number(count);
|
|
123
|
+
if (envelope && typeof object(data).total_count === 'number')
|
|
124
|
+
total = object(data).total_count;
|
|
125
|
+
const link = response.headers.get('link');
|
|
126
|
+
next = nextPage(response.headers, path, client.origin);
|
|
127
|
+
if (!next && !link && rows.length >= 50) {
|
|
128
|
+
const url = new URL([...seenPages].at(-1), client.origin);
|
|
129
|
+
url.searchParams.set('page', String(Number(url.searchParams.get('page')) + 1));
|
|
130
|
+
next = url.pathname + url.search;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { items: [...items.values()], total, next, truncated: next !== null };
|
|
134
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
const args = process.argv.slice(2);
|
|
4
|
+
if (args.length === 1 && ['--version', '-v', '-V'].includes(args[0] ?? '')) {
|
|
5
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
6
|
+
console.log(pkg.version);
|
|
7
|
+
}
|
|
8
|
+
else {
|
|
9
|
+
const { main } = await import('./main.js');
|
|
10
|
+
await main(args);
|
|
11
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { isAbsolute, join } from 'node:path';
|
|
4
|
+
import { Ajv } from 'ajv';
|
|
5
|
+
import { CliError } from './error.js';
|
|
6
|
+
import { readPrivateFile } from './private-file.js';
|
|
7
|
+
const schema = JSON.parse(readFileSync(new URL('../axi-credential.schema.json', import.meta.url), 'utf8'));
|
|
8
|
+
const validate = new Ajv({ strict: false }).compile(schema);
|
|
9
|
+
const secrets = [];
|
|
10
|
+
export function redact(value) {
|
|
11
|
+
if (Array.isArray(value))
|
|
12
|
+
return value.map(redact);
|
|
13
|
+
if (value && typeof value === 'object')
|
|
14
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key,
|
|
15
|
+
/^(?:token|access_token|refresh_token|password|secret|authorization|private_key)$/i.test(key) ? '[REDACTED]' : redact(item)]));
|
|
16
|
+
if (typeof value !== 'string')
|
|
17
|
+
return value;
|
|
18
|
+
let output = value;
|
|
19
|
+
for (const secret of secrets) {
|
|
20
|
+
for (const form of [secret, JSON.stringify(secret).slice(1, -1), encodeURIComponent(secret)]) {
|
|
21
|
+
output = output.replaceAll(form, '[REDACTED]');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return output.replace(/([?&](?:token|access_token|api_key|sig)=)[^&#\s]*/gi, '$1[REDACTED]')
|
|
25
|
+
.replace(/(https?:\/\/)[^/\s@]+@/gi, '$1[REDACTED]@')
|
|
26
|
+
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[REDACTED]')
|
|
27
|
+
.replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g, '[REDACTED]')
|
|
28
|
+
.replace(/(AccountKey=)[^;\s]+/gi, '$1[REDACTED]');
|
|
29
|
+
}
|
|
30
|
+
export function configPath(explicit) {
|
|
31
|
+
const base = process.platform === 'win32' ? process.env.APPDATA : process.env.XDG_CONFIG_HOME;
|
|
32
|
+
return explicit ?? process.env.GITEA_AXI_CONFIG ?? join(base && isAbsolute(base) ? base : join(homedir(), '.config'), 'gitea-axi', 'config.json');
|
|
33
|
+
}
|
|
34
|
+
export async function profileList(explicit) {
|
|
35
|
+
const path = configPath(explicit);
|
|
36
|
+
if (!existsSync(path))
|
|
37
|
+
return { path, profiles: [] };
|
|
38
|
+
const config = await readConfig(path);
|
|
39
|
+
return { path, profiles: Object.keys(config.profiles).map(name => ({ name, default: name === config.defaultProfile })) };
|
|
40
|
+
}
|
|
41
|
+
async function readConfig(path) {
|
|
42
|
+
let config;
|
|
43
|
+
try {
|
|
44
|
+
config = JSON.parse(await readPrivateFile(path));
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error instanceof CliError)
|
|
48
|
+
throw error;
|
|
49
|
+
throw new CliError('CONFIG_INVALID', 'Invalid profile configuration');
|
|
50
|
+
}
|
|
51
|
+
if (!config || config.version !== 1 || !config.profiles || Array.isArray(config.profiles) || typeof config.profiles !== 'object' ||
|
|
52
|
+
Object.keys(config).some(key => !['version', 'profiles', 'defaultProfile'].includes(key)) ||
|
|
53
|
+
Object.keys(config.profiles).some(name => !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name))) {
|
|
54
|
+
throw new CliError('CONFIG_INVALID', 'Invalid profile configuration');
|
|
55
|
+
}
|
|
56
|
+
return config;
|
|
57
|
+
}
|
|
58
|
+
export function canonicalOrigin(value) {
|
|
59
|
+
try {
|
|
60
|
+
const url = new URL(value);
|
|
61
|
+
if (!/^https?:\/\/[^\s\\/?#@]+\/?$/i.test(value) || !url.hostname || url.username || url.password)
|
|
62
|
+
throw new Error();
|
|
63
|
+
return url.origin;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new CliError('CONFIG_INVALID', 'origin must contain only HTTP or HTTPS scheme, host and port');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export async function credentials(override, allowHttp, options = {}) {
|
|
70
|
+
const path = configPath(options.config);
|
|
71
|
+
const selected = options.profile ?? process.env.GITEA_AXI_PROFILE;
|
|
72
|
+
const fields = ['ORIGIN', 'TOKEN_ORIGIN', 'TOKEN', 'ALLOW_INSECURE_HTTP'];
|
|
73
|
+
const hasEnv = fields.some(field => process.env['GITEA_AXI_' + field] !== undefined);
|
|
74
|
+
let profile;
|
|
75
|
+
let profileName = 'environment';
|
|
76
|
+
if (!selected && hasEnv) {
|
|
77
|
+
let token;
|
|
78
|
+
try {
|
|
79
|
+
token = JSON.parse(process.env.GITEA_AXI_TOKEN ?? 'null');
|
|
80
|
+
}
|
|
81
|
+
catch { /* Schema reports only the field name. */ }
|
|
82
|
+
const http = process.env.GITEA_AXI_ALLOW_INSECURE_HTTP;
|
|
83
|
+
if (http !== undefined && http !== 'true' && http !== 'false')
|
|
84
|
+
throw new CliError('CONFIG_INVALID', 'allowInsecureHttp must be true or false');
|
|
85
|
+
profile = { origin: process.env.GITEA_AXI_ORIGIN, tokenOrigin: process.env.GITEA_AXI_TOKEN_ORIGIN, token,
|
|
86
|
+
allowInsecureHttp: http === 'true' };
|
|
87
|
+
}
|
|
88
|
+
else if (existsSync(path)) {
|
|
89
|
+
const config = await readConfig(path);
|
|
90
|
+
const names = Object.keys(config.profiles);
|
|
91
|
+
const name = selected ?? config.defaultProfile ?? (names.length === 1 ? names[0] : undefined);
|
|
92
|
+
if (!name)
|
|
93
|
+
throw new CliError(names.length ? 'PROFILE_AMBIGUOUS' : 'PROFILE_REQUIRED', 'Select a profile');
|
|
94
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name) || !Object.hasOwn(config.profiles, name))
|
|
95
|
+
throw new CliError('PROFILE_NOT_FOUND', 'Selected profile was not found');
|
|
96
|
+
profile = config.profiles[name];
|
|
97
|
+
profileName = name;
|
|
98
|
+
}
|
|
99
|
+
else if (selected || options.config || process.env.GITEA_AXI_CONFIG) {
|
|
100
|
+
throw new CliError('PROFILE_NOT_FOUND', 'Selected profile configuration was not found');
|
|
101
|
+
}
|
|
102
|
+
else if (override) {
|
|
103
|
+
const origin = canonicalOrigin(override);
|
|
104
|
+
if (origin.startsWith('http:') && !allowHttp)
|
|
105
|
+
throw new CliError('USAGE', 'HTTP requires --allow-insecure-http for the explicit --origin', 2);
|
|
106
|
+
return { origin };
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
throw new CliError('AUTH_REQUIRED', 'Select a configured profile or an explicit anonymous --origin');
|
|
110
|
+
}
|
|
111
|
+
if (!validate(profile))
|
|
112
|
+
throw new CliError('CONFIG_INVALID', 'Invalid profile field: ' + (validate.errors?.[0]?.instancePath || 'token/origin'));
|
|
113
|
+
const origin = canonicalOrigin(profile.origin);
|
|
114
|
+
if (origin !== canonicalOrigin(profile.tokenOrigin) || (override && canonicalOrigin(override) !== origin)) {
|
|
115
|
+
throw new CliError('AUTH_BINDING_MISMATCH', 'origin must match tokenOrigin before resolving token');
|
|
116
|
+
}
|
|
117
|
+
if (origin.startsWith('http:') && !profile.allowInsecureHttp)
|
|
118
|
+
throw new CliError('CONFIG_INVALID', 'HTTP requires allowInsecureHttp on the selected profile');
|
|
119
|
+
let token;
|
|
120
|
+
if ('env' in profile.token)
|
|
121
|
+
token = process.env[profile.token.env] ?? '';
|
|
122
|
+
else {
|
|
123
|
+
if (!isAbsolute(profile.token.file) || /^\\\\/.test(profile.token.file))
|
|
124
|
+
throw new CliError('CONFIG_INVALID', 'token file must be an absolute local path');
|
|
125
|
+
token = (await readPrivateFile(profile.token.file, true)).replace(/^\uFEFF/, '').trimEnd();
|
|
126
|
+
}
|
|
127
|
+
if (!token)
|
|
128
|
+
throw new CliError('AUTH_REQUIRED', 'token reference is empty');
|
|
129
|
+
secrets.push(token);
|
|
130
|
+
if (/[\r\n]/.test(token))
|
|
131
|
+
throw new CliError('CONFIG_INVALID', 'token must be a single line');
|
|
132
|
+
return { origin, token, profile: profileName, source: 'env' in profile.token ? 'env:' + profile.token.env : 'file:' + profile.token.file };
|
|
133
|
+
}
|
package/dist/error.js
ADDED
package/dist/main.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
import { encode } from '@toon-format/toon';
|
|
4
|
+
import { commands } from './registry.js';
|
|
5
|
+
import { CliError } from './error.js';
|
|
6
|
+
import { readRepository } from './repository.js';
|
|
7
|
+
import { configPath, credentials, profileList, redact } from './credentials.js';
|
|
8
|
+
import { Client } from './transport.js';
|
|
9
|
+
import { collect, object, rawPath, rawRead, read } from './api.js';
|
|
10
|
+
import { boundedList, issueFields, listIssues, listOptions, positive, prFields, repository, selectedFields, view, watchChecks } from './reads.js';
|
|
11
|
+
export async function main(args) {
|
|
12
|
+
let operation = 'dashboard';
|
|
13
|
+
try {
|
|
14
|
+
if (args.some(arg => /^--(?:token|api-key|password|authorization)(?:=|$)/i.test(arg))) {
|
|
15
|
+
throw new CliError('CONFIG_INVALID', 'token and other secret arguments are not accepted');
|
|
16
|
+
}
|
|
17
|
+
const path = args.length === 0 || args[0]?.startsWith('--') ? ['dashboard'] : args.slice(0, ['api', 'doctor', 'dashboard'].includes(args[0]) ? 1 : 2);
|
|
18
|
+
const command = commands.find(command => command.path.join(' ') === path.join(' '));
|
|
19
|
+
if (!command)
|
|
20
|
+
throw new CliError('USAGE', 'Unknown command; use gitea-axi --help', 2);
|
|
21
|
+
operation = command.path.join(' ');
|
|
22
|
+
const rest = args[0]?.startsWith('--') || !args.length ? args : args.slice(path.length);
|
|
23
|
+
let parsed;
|
|
24
|
+
try {
|
|
25
|
+
parsed = parseArgs({
|
|
26
|
+
args: rest, allowPositionals: true, strict: true, tokens: true,
|
|
27
|
+
options: Object.fromEntries(Object.entries(command.flags).map(([name, flag]) => [name, { type: flag.type }])),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new CliError('USAGE', 'Invalid arguments; valid flags: ' + Object.keys(command.flags).map(f => '--' + f).join(', '), 2);
|
|
32
|
+
}
|
|
33
|
+
const { values, positionals } = parsed;
|
|
34
|
+
const names = parsed.tokens.filter(token => token.kind === 'option').map(token => token.name);
|
|
35
|
+
if (new Set(names).size !== names.length) {
|
|
36
|
+
throw new CliError('USAGE', 'Choose each flag only once; --repo requires exactly one selector', 2);
|
|
37
|
+
}
|
|
38
|
+
if (values.help) {
|
|
39
|
+
console.log(encode({ bin: process.argv[1], description: command.description, command: 'gitea-axi ' + operation,
|
|
40
|
+
flags: Object.entries(command.flags).map(([name, flag]) => ({ flag: '--' + name, ...flag })),
|
|
41
|
+
...(operation === 'dashboard' ? { commands: commands.map(command => command.path.join(' ')) } : {}),
|
|
42
|
+
examples: operation === 'api' ? ['gitea-axi api GET repos/OWNER/NAME', 'gitea-axi api version'] :
|
|
43
|
+
[`gitea-axi ${operation} --help`, `gitea-axi ${operation} --profile <name>`] }));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const options = values;
|
|
47
|
+
const takesNumber = ['issue view', 'pr view', 'pr checks', 'pr diff'].includes(operation);
|
|
48
|
+
if (takesNumber && (positionals.length !== 1 || !/^[1-9]\d*$/.test(positionals[0])))
|
|
49
|
+
throw new CliError('USAGE', 'Select exactly one positive item number', 2);
|
|
50
|
+
if (!takesNumber && operation !== 'api' && operation !== 'org view' && positionals.length)
|
|
51
|
+
throw new CliError('USAGE', 'Unexpected positional argument', 2);
|
|
52
|
+
if (operation === 'org view' && (positionals.length !== 1 || !/^[A-Za-z0-9_.-]+$/.test(positionals[0])))
|
|
53
|
+
throw new CliError('USAGE', 'Select one organization name', 2);
|
|
54
|
+
if (operation.endsWith('list'))
|
|
55
|
+
listOptions(options);
|
|
56
|
+
if (operation === 'issue view' || operation === 'issue list')
|
|
57
|
+
selectedFields(options, issueFields);
|
|
58
|
+
if (operation === 'pr view')
|
|
59
|
+
selectedFields(options, prFields);
|
|
60
|
+
if (operation === 'pr list')
|
|
61
|
+
selectedFields(options, issueFields.concat(['isDraft', 'headRefName', 'baseRefName']));
|
|
62
|
+
if (operation === 'pr checks') {
|
|
63
|
+
if ((options.interval || options.timeout) && !options.watch)
|
|
64
|
+
throw new CliError('USAGE', '--timeout and --interval require --watch', 2);
|
|
65
|
+
if (options.watch && !options.timeout)
|
|
66
|
+
throw new CliError('USAGE', '--watch requires --timeout seconds', 2);
|
|
67
|
+
positive(options.timeout, 30, 3600);
|
|
68
|
+
positive(options.interval, 5, 60);
|
|
69
|
+
}
|
|
70
|
+
let apiPath;
|
|
71
|
+
if (operation === 'api') {
|
|
72
|
+
if (positionals.length === 2 && positionals[0] !== 'GET')
|
|
73
|
+
throw new CliError('READ_ONLY', 'Only GET metadata reads are available');
|
|
74
|
+
if (positionals.length < 1 || positionals.length > 2)
|
|
75
|
+
throw new CliError('USAGE', 'Use gitea-axi api [GET] <relative-path>', 2);
|
|
76
|
+
apiPath = rawPath(positionals.at(-1));
|
|
77
|
+
}
|
|
78
|
+
if (options.config && (typeof options.config !== 'string' || !isAbsolute(options.config)))
|
|
79
|
+
throw new CliError('USAGE', '--config must be absolute', 2);
|
|
80
|
+
if (operation === 'config path') {
|
|
81
|
+
console.log(encode({ path: configPath(options.config) }));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (operation === 'profile list') {
|
|
85
|
+
console.log(encode(await profileList(options.config)));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const identity = await credentials(typeof values.origin === 'string' ? values.origin : undefined, Boolean(values['allow-insecure-http']), {
|
|
89
|
+
...(typeof options.config === 'string' ? { config: options.config } : {}), ...(typeof options.profile === 'string' ? { profile: options.profile } : {}),
|
|
90
|
+
});
|
|
91
|
+
const client = new Client(identity.origin, identity.token, operation === 'pr checks' && options.watch ? positive(options.timeout, 30, 3600) * 1000 : 30_000);
|
|
92
|
+
let output;
|
|
93
|
+
if (operation === 'auth status')
|
|
94
|
+
output = { profile: identity.profile ?? 'anonymous', origin: identity.origin, source: identity.source ?? 'anonymous', authenticated: Boolean(identity.token) };
|
|
95
|
+
else if (operation === 'doctor')
|
|
96
|
+
output = { origin: identity.origin, identity: await read(client, '/api/v1/user') };
|
|
97
|
+
else if (operation === 'api')
|
|
98
|
+
output = await rawRead(client, apiPath);
|
|
99
|
+
else if (operation === 'org view')
|
|
100
|
+
output = { organization: await read(client, '/api/v1/orgs/' + encodeURIComponent(positionals[0])) };
|
|
101
|
+
else if (operation === 'repo list') {
|
|
102
|
+
if (options.fields)
|
|
103
|
+
throw new CliError('USAGE', 'repo list supports its compact identity fields only', 2);
|
|
104
|
+
const path = options.org ? '/api/v1/orgs/' + encodeURIComponent(String(options.org)) + '/repos' : '/api/v1/repos/search';
|
|
105
|
+
output = boundedList(await collect(client, path, options.org ? undefined : 'data'), positive(options.limit, 30, 1000), ['id', 'full_name', 'default_branch']);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const repo = repository(values.repo, identity.origin);
|
|
109
|
+
const base = '/api/v1/repos/' + repo.split('/').map(encodeURIComponent).join('/');
|
|
110
|
+
if (operation === 'repo view')
|
|
111
|
+
output = await readRepository(identity.origin, repo, identity.token);
|
|
112
|
+
else if (operation === 'dashboard') {
|
|
113
|
+
const sections = {};
|
|
114
|
+
for (const [name, path, envelope] of [['repository', base], ['issues', base + '/issues?state=open&type=issues'], ['pulls', base + '/pulls?state=open'], ['labels', base + '/labels'], ['releases', base + '/releases'], ['runs', base + '/actions/tasks', 'workflow_runs']]) {
|
|
115
|
+
try {
|
|
116
|
+
sections[name] = name === 'repository' ? { status: 'ok', data: await read(client, path) } :
|
|
117
|
+
{ status: 'ok', ...boundedList(await collect(client, path, envelope), 5, name === 'labels' ? ['id', 'name', 'color'] : ['id', 'number', 'title', 'state']) };
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (!(error instanceof CliError))
|
|
121
|
+
throw error;
|
|
122
|
+
sections[name] = { status: 'error', code: error.code, reason: error.message };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
output = { bin: process.argv[1], description: 'Gitea repository reads', repository: repo, partial: Object.values(sections).some(section => object(section).status === 'error'), sections,
|
|
126
|
+
help: [`gitea-axi issue list --repo ${repo}`, `gitea-axi pr list --repo ${repo}`, 'gitea-axi --help'] };
|
|
127
|
+
}
|
|
128
|
+
else if (operation === 'issue list' || operation === 'pr list')
|
|
129
|
+
output = await listIssues(client, base, operation === 'pr list' ? 'pr' : 'issue', options);
|
|
130
|
+
else if (operation === 'issue view' || operation === 'pr view')
|
|
131
|
+
output = await view(client, base, operation === 'pr view' ? 'pr' : 'issue', positionals[0], options);
|
|
132
|
+
else if (['label list', 'run list', 'release list'].includes(operation))
|
|
133
|
+
output = boundedList(await collect(client, base + (operation === 'label list' ? '/labels' : operation === 'release list' ? '/releases' : '/actions/tasks'), operation === 'run list' ? 'workflow_runs' : undefined), positive(options.limit, 30, 1000), operation === 'label list' ? ['id', 'name', 'color'] : ['id', 'name', 'status', 'conclusion']);
|
|
134
|
+
else if (operation === 'pr checks') {
|
|
135
|
+
const result = await watchChecks(client, base, positionals[0], options);
|
|
136
|
+
process.exitCode = result.exitCode;
|
|
137
|
+
output = result;
|
|
138
|
+
}
|
|
139
|
+
else if (operation === 'pr diff') {
|
|
140
|
+
const response = await client.request(base + '/pulls/' + positionals[0] + '.diff', true);
|
|
141
|
+
const text = String(response.data);
|
|
142
|
+
const ceiling = options.full ? 100_000 : 10_000;
|
|
143
|
+
output = { diff: text.slice(0, ceiling), chars: text.length, truncated: text.length > ceiling, ...(text.length > ceiling ? { help: 'Use --full within the 100000 character diff ceiling' } : {}) };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const rendered = encode(redact(output));
|
|
147
|
+
if (rendered.length > 100_000)
|
|
148
|
+
throw new CliError('OUTPUT_LIMIT', 'Rendered output exceeds 100000 characters; select fewer fields or a lower --limit');
|
|
149
|
+
console.log(rendered);
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
const failure = error instanceof CliError ? error : new CliError('INTERNAL_ERROR', 'Unable to read the repository');
|
|
153
|
+
console.log(encode(redact({ error: { code: failure.code, operation, reason: failure.message }, help: 'gitea-axi ' + operation + ' --help' })));
|
|
154
|
+
process.exitCode = failure.exitCode;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { open, mkdir, chmod, rename, rm, lstat } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { CliError } from './error.js';
|
|
6
|
+
export async function readPrivateFile(path, secret = false) {
|
|
7
|
+
if (secret && process.platform === 'win32')
|
|
8
|
+
throw new CliError('CONFIG_INVALID', 'File SecretRef protection cannot be verified on Windows; use env SecretRefs');
|
|
9
|
+
const info = await lstat(path).catch(() => { throw new CliError('CONFIG_INSECURE', 'Private file is unavailable'); });
|
|
10
|
+
if (!info.isFile() || info.isSymbolicLink())
|
|
11
|
+
throw new CliError('CONFIG_INSECURE', 'Private file must be a regular owned file; use chmod 600');
|
|
12
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK).catch(() => {
|
|
13
|
+
throw new CliError('CONFIG_INSECURE', 'Private file must be a regular owned file; use chmod 600');
|
|
14
|
+
});
|
|
15
|
+
try {
|
|
16
|
+
const stat = await file.stat();
|
|
17
|
+
if (!stat.isFile() || (process.platform !== 'win32' &&
|
|
18
|
+
(stat.uid !== process.getuid() || (stat.mode & (secret ? 0o077 : 0o022)) !== 0))) {
|
|
19
|
+
throw new CliError('CONFIG_INSECURE', 'Private file must be owned and protected; use chmod 600');
|
|
20
|
+
}
|
|
21
|
+
return await file.readFile('utf8');
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
await file.close();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function writePrivateFile(path, content) {
|
|
28
|
+
if (process.platform === 'win32')
|
|
29
|
+
throw new CliError('CONFIG_INSECURE', 'Native Windows ACL writes are unavailable');
|
|
30
|
+
const directory = dirname(path);
|
|
31
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
32
|
+
const info = await lstat(directory);
|
|
33
|
+
if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== process.getuid()) {
|
|
34
|
+
throw new CliError('CONFIG_INSECURE', 'Private directory must be owned and cannot be a symlink');
|
|
35
|
+
}
|
|
36
|
+
await chmod(directory, 0o700);
|
|
37
|
+
const temporary = join(directory, '.' + randomUUID() + '.tmp');
|
|
38
|
+
const file = await open(temporary, 'wx', 0o600);
|
|
39
|
+
try {
|
|
40
|
+
await file.writeFile(content, 'utf8');
|
|
41
|
+
await file.sync();
|
|
42
|
+
await file.close();
|
|
43
|
+
await rename(temporary, path);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
await file.close();
|
|
47
|
+
await rm(temporary, { force: true });
|
|
48
|
+
}
|
|
49
|
+
}
|