@knowttl/gitea-axi 0.0.0-stage → 0.1.1
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 +414 -2
- package/axi-credential.schema.json +28 -0
- package/dist/action-values.js +37 -0
- package/dist/api.js +195 -0
- package/dist/attachments.js +171 -0
- package/dist/catalogue.js +65 -0
- package/dist/ci.js +24 -0
- package/dist/cli.js +11 -0
- package/dist/credentials.js +196 -0
- package/dist/error.js +9 -0
- package/dist/git.js +89 -0
- package/dist/local-workflows.js +222 -0
- package/dist/main.js +376 -0
- package/dist/private-file.js +51 -0
- package/dist/pulls.js +299 -0
- package/dist/reads.js +236 -0
- package/dist/registry.js +189 -0
- package/dist/releases.js +145 -0
- package/dist/repo-workflows.js +147 -0
- package/dist/repository.js +37 -0
- package/dist/search.js +111 -0
- package/dist/stacks.js +486 -0
- package/dist/transport.js +120 -0
- package/dist/vendor.generated.js +5 -0
- package/dist/writes.js +547 -0
- package/generated/SKILL.md +141 -0
- package/generated/capabilities.json +964 -0
- package/generated/help.txt +5387 -0
- package/generated/host-inventory.json +7641 -0
- package/generated/routes.json +5096 -0
- package/package.json +46 -4
- package/schemas/catalogue.json +2672 -0
- package/schemas/responses.json +63 -0
- package/schemas/vendor.json +7130 -0
package/dist/api.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
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
|
+
Branch: ['name', 'commit'], PayloadCommit: ['id'],
|
|
14
|
+
Release: ['id', 'tag_name', 'target_commitish', 'name', 'body', 'draft', 'prerelease'],
|
|
15
|
+
Tag: ['name', 'commit'], CommitMeta: ['sha'],
|
|
16
|
+
Secret: ['name'], ActionVariable: ['name', 'data'], Attachment: ['id', 'uuid', 'name', 'size'],
|
|
17
|
+
GitTreeResponse: ['tree', 'truncated'], GitEntry: ['path', 'mode', 'type', 'sha'],
|
|
18
|
+
ActionTask: ['id', 'name', 'status'], ActionTaskResponse: ['workflow_runs', 'total_count'],
|
|
19
|
+
};
|
|
20
|
+
for (const [name, fields] of Object.entries(required)) {
|
|
21
|
+
if (vendor.definitions[name])
|
|
22
|
+
vendor.definitions[name].required = fields;
|
|
23
|
+
}
|
|
24
|
+
// Gitea's Go pointer fields and nil slices serialize as null, which Swagger 2 cannot express.
|
|
25
|
+
for (const definition of Object.values(vendor.definitions)) {
|
|
26
|
+
for (const [key, property] of Object.entries(definition.properties ?? {})) {
|
|
27
|
+
if (property.$ref && !['state', 'status'].includes(key) || property.type === 'array' || property.format === 'date-time') {
|
|
28
|
+
definition.properties[key] = { anyOf: [property, { type: 'null' }] };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const requestDefinitions = structuredClone(vendor.definitions);
|
|
33
|
+
for (const [name, fields] of Object.entries({ PullRequest: ['merge_commit_sha'], ContentsResponse: ['submodule_git_url', 'target'] })) {
|
|
34
|
+
for (const field of fields) {
|
|
35
|
+
const properties = vendor.definitions[name].properties;
|
|
36
|
+
properties[field] = { anyOf: [properties[field], { type: 'null' }] };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const ajv = new Ajv({ strict: false, validateFormats: false });
|
|
40
|
+
const validators = new Map();
|
|
41
|
+
export function object(value) {
|
|
42
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
43
|
+
throw new CliError('INVALID_RESPONSE', 'Expected an object from Gitea');
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
export function effectFor(method, path) {
|
|
47
|
+
path = canonicalPath(path);
|
|
48
|
+
return routes.find(route => route.method === method && new RegExp('^' + route.path.split(/(\{[^}]+\})/).map(part => /^\{[^}]+\}$/.test(part) ? '[^/]+' : part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('') + '$').test(path.split('?')[0]));
|
|
49
|
+
}
|
|
50
|
+
export function canonicalPath(path) {
|
|
51
|
+
const pathname = path.split('?')[0];
|
|
52
|
+
if (/%(?:2f|5c)/i.test(pathname))
|
|
53
|
+
throw new CliError('READ_ONLY', 'Encoded route separators are refused');
|
|
54
|
+
let decoded;
|
|
55
|
+
try {
|
|
56
|
+
decoded = decodeURIComponent(pathname);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new CliError('USAGE', 'Malformed API path', 2);
|
|
60
|
+
}
|
|
61
|
+
return decoded.split('/').map(encodeURIComponent).join('/');
|
|
62
|
+
}
|
|
63
|
+
export function validatePayload(method, path, payload) {
|
|
64
|
+
const route = effectFor(method, path);
|
|
65
|
+
if (!route || route.effect !== 'write')
|
|
66
|
+
throw new CliError('READ_ONLY', 'Only classified business writes may be enabled');
|
|
67
|
+
if (/\/actions\/secrets\//.test(path) && method === 'PUT' &&
|
|
68
|
+
(typeof object(payload).data !== 'string' || Object.keys(object(payload)).some(key => key !== 'data'))) {
|
|
69
|
+
throw new CliError('USAGE', 'Secret input requires only a string data field', 2);
|
|
70
|
+
}
|
|
71
|
+
const segments = canonicalPath(path).split('/');
|
|
72
|
+
for (const [index, segment] of route.path.split('/').entries()) {
|
|
73
|
+
if (route.integerParameters.some(name => segment === '{' + name + '}') && !/^(?:0|-?[1-9]\d*)$/.test(segments[index])) {
|
|
74
|
+
throw new CliError('USAGE', 'Integer route parameters require canonical decimal spelling', 2);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (route.requestSchema && !ajv.compile({ ...route.requestSchema, definitions: requestDefinitions })(payload)) {
|
|
78
|
+
throw new CliError('USAGE', 'Invalid request fields for ' + route.operation, 2);
|
|
79
|
+
}
|
|
80
|
+
return route;
|
|
81
|
+
}
|
|
82
|
+
export function routeFor(path) {
|
|
83
|
+
const route = effectFor('GET', path);
|
|
84
|
+
if (route?.effect === 'admin')
|
|
85
|
+
throw new CliError('READ_ONLY', 'Administrative API routes are refused; only metadata reads are available');
|
|
86
|
+
if (route?.effect === 'credential')
|
|
87
|
+
throw new CliError('READ_ONLY', 'Credential-bearing API routes are refused; only metadata reads are available');
|
|
88
|
+
if (route?.effect === 'download')
|
|
89
|
+
throw new CliError('READ_ONLY', 'Text and binary download routes are refused; only JSON metadata reads are available');
|
|
90
|
+
if (!route || route.effect !== 'read')
|
|
91
|
+
throw new CliError('READ_ONLY', 'Only classified metadata reads are available');
|
|
92
|
+
return route;
|
|
93
|
+
}
|
|
94
|
+
export function validate(path, value) {
|
|
95
|
+
const route = routeFor(path);
|
|
96
|
+
if (route.schema) {
|
|
97
|
+
let validator = validators.get(route.path);
|
|
98
|
+
if (!validator) {
|
|
99
|
+
validator = ajv.compile({ ...route.schema, definitions: vendor.definitions });
|
|
100
|
+
validators.set(route.path, validator);
|
|
101
|
+
}
|
|
102
|
+
if (!validator(value))
|
|
103
|
+
throw new CliError('INVALID_RESPONSE', route.operation + ' returned invalid fields');
|
|
104
|
+
}
|
|
105
|
+
if (/\/actions\/secrets(?:\?|$)/.test(path) && Array.isArray(value)) {
|
|
106
|
+
return value.map(row => ({ name: object(row).name, ...(object(row).created_at ? { created_at: object(row).created_at } : {}) }));
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
export function rawPath(input, method = 'GET') {
|
|
111
|
+
if (/^[a-z]+:/i.test(input) || input.startsWith('//') || input.includes('#'))
|
|
112
|
+
throw new CliError('USAGE', 'Use a relative API path without traversal', 2);
|
|
113
|
+
const separator = input.indexOf('?');
|
|
114
|
+
const pathname = canonicalPath(separator < 0 ? input : input.slice(0, separator));
|
|
115
|
+
const query = separator < 0 ? '' : input.slice(separator + 1);
|
|
116
|
+
const decoded = decodeURIComponent(pathname);
|
|
117
|
+
if (/[\\#\s%]/.test(decoded) || decoded.split('/').some(part => part === '.' || part === '..'))
|
|
118
|
+
throw new CliError('USAGE', 'Use a relative API path without traversal', 2);
|
|
119
|
+
for (const key of new URLSearchParams(query).keys()) {
|
|
120
|
+
if (/token|secret|password|authorization|api.?key/i.test(key))
|
|
121
|
+
throw new CliError('USAGE', 'Secret query fields are refused', 2);
|
|
122
|
+
}
|
|
123
|
+
const path = pathname === '/swagger.v1.json' ? pathname : pathname?.startsWith('/api/v1/') ? pathname : '/api/v1/' + pathname?.replace(/^\//, '');
|
|
124
|
+
if (path !== '/swagger.v1.json' && method === 'GET')
|
|
125
|
+
routeFor(path);
|
|
126
|
+
else if (method !== 'GET' && !effectFor(method, path))
|
|
127
|
+
throw new CliError('READ_ONLY', 'Unclassified API route');
|
|
128
|
+
return path + (query ? '?' + query : '');
|
|
129
|
+
}
|
|
130
|
+
export async function read(client, path) {
|
|
131
|
+
routeFor(path.split('?')[0]);
|
|
132
|
+
const value = await client.get(path);
|
|
133
|
+
return validate(path.split('?')[0], value);
|
|
134
|
+
}
|
|
135
|
+
export async function resolveCommit(client, base, ref) {
|
|
136
|
+
const rows = await read(client, base + '/commits?sha=' + encodeURIComponent(ref) + '&limit=1');
|
|
137
|
+
if (!Array.isArray(rows) || rows.length !== 1)
|
|
138
|
+
throw new CliError('NOT_FOUND_OR_HIDDEN', 'The selected commit ref is missing or hidden');
|
|
139
|
+
const row = object(rows[0]);
|
|
140
|
+
if (typeof row.sha !== 'string' || !/^[a-f0-9]{40}$/.test(row.sha))
|
|
141
|
+
throw new CliError('INVALID_RESPONSE', 'Expected an immutable commit identity');
|
|
142
|
+
return row;
|
|
143
|
+
}
|
|
144
|
+
function nextPage(headers, path, origin) {
|
|
145
|
+
const target = headers.get('link')?.split(',').find(part => /rel="?next"?/.test(part))?.match(/<([^>]+)>/)?.[1];
|
|
146
|
+
if (!target)
|
|
147
|
+
return null;
|
|
148
|
+
const url = new URL(target, new URL(path, origin));
|
|
149
|
+
if (url.origin !== origin || url.pathname !== new URL(path, origin).pathname || url.username || url.password || url.hash) {
|
|
150
|
+
throw new CliError('UNSAFE_PAGINATION', 'Pagination must stay on the selected origin and collection');
|
|
151
|
+
}
|
|
152
|
+
return rawPath(url.pathname + url.search);
|
|
153
|
+
}
|
|
154
|
+
export async function rawRead(client, path) {
|
|
155
|
+
const response = await client.request(path);
|
|
156
|
+
const data = path === '/swagger.v1.json' ? response.data : validate(new URL(path, client.origin).pathname, response.data);
|
|
157
|
+
const count = response.headers.get('x-total-count');
|
|
158
|
+
return { data, total: count && /^\d+$/.test(count) ? Number(count) : null, next: nextPage(response.headers, path, client.origin), truncated: false };
|
|
159
|
+
}
|
|
160
|
+
export async function collect(client, path, envelope) {
|
|
161
|
+
let next = path + (path.includes('?') ? '&' : '?') + 'limit=50&page=1';
|
|
162
|
+
const seenPages = new Set();
|
|
163
|
+
const items = new Map();
|
|
164
|
+
let total = null;
|
|
165
|
+
while (next && seenPages.size < 20 && items.size < 1000 && Date.now() < client.deadline) {
|
|
166
|
+
if (seenPages.has(next))
|
|
167
|
+
throw new CliError('INVALID_RESPONSE', 'Gitea repeated a pagination URL');
|
|
168
|
+
seenPages.add(next);
|
|
169
|
+
const response = await client.request(next);
|
|
170
|
+
const data = validate(new URL(next, client.origin).pathname, response.data);
|
|
171
|
+
const rows = envelope ? object(data)[envelope] : data;
|
|
172
|
+
if (!Array.isArray(rows))
|
|
173
|
+
throw new CliError('INVALID_RESPONSE', 'Expected a Gitea list' + (envelope ? ' envelope ' + envelope : ''));
|
|
174
|
+
for (const value of rows) {
|
|
175
|
+
const row = object(value);
|
|
176
|
+
const id = /\/(?:tags|actions\/(?:secrets|variables))$/.test(path.split('?')[0]) ? row.name : row.id ?? row.number ?? row.sha ?? row.filename ?? (row.user && row.content ? String(object(row.user).id) + ':' + row.content : undefined);
|
|
177
|
+
if (typeof id !== 'number' && typeof id !== 'string')
|
|
178
|
+
throw new CliError('INVALID_RESPONSE', 'List record has no identity');
|
|
179
|
+
items.set(String(id), row);
|
|
180
|
+
}
|
|
181
|
+
const count = response.headers.get('x-total-count');
|
|
182
|
+
if (count && /^\d+$/.test(count))
|
|
183
|
+
total = Number(count);
|
|
184
|
+
if (envelope && typeof object(data).total_count === 'number')
|
|
185
|
+
total = object(data).total_count;
|
|
186
|
+
const link = response.headers.get('link');
|
|
187
|
+
next = nextPage(response.headers, path, client.origin);
|
|
188
|
+
if (!next && !link && rows.length >= 50) {
|
|
189
|
+
const url = new URL([...seenPages].at(-1), client.origin);
|
|
190
|
+
url.searchParams.set('page', String(Number(url.searchParams.get('page')) + 1));
|
|
191
|
+
next = url.pathname + url.search;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { items: [...items.values()], total, next, truncated: next !== null };
|
|
195
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { link, lstat, open, realpath, rename, rm } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { collect, object, read, validate } from './api.js';
|
|
6
|
+
import { CliError } from './error.js';
|
|
7
|
+
export const transferBytes = 10_000_000;
|
|
8
|
+
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
9
|
+
function safeName(name) {
|
|
10
|
+
if (!name || name === '.' || name === '..' || /[\\/:*?"<>|\x00-\x1f\x7f]/.test(name) || /[. ]$/.test(name) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name))
|
|
11
|
+
throw new CliError('UNSAFE_PATH', 'Attachment requires a safe portable basename');
|
|
12
|
+
return name;
|
|
13
|
+
}
|
|
14
|
+
function attachment(value) {
|
|
15
|
+
const row = object(value);
|
|
16
|
+
if (!Number.isSafeInteger(row.id) || Number(row.id) <= 0 || typeof row.uuid !== 'string' || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(row.uuid) || typeof row.name !== 'string' || !Number.isSafeInteger(row.size) || Number(row.size) < 0)
|
|
17
|
+
throw new CliError('INVALID_RESPONSE', 'Attachment metadata has invalid identity or size');
|
|
18
|
+
safeName(row.name);
|
|
19
|
+
return row;
|
|
20
|
+
}
|
|
21
|
+
export async function assetPath(client, base, kind, selected) {
|
|
22
|
+
if (kind !== 'release') {
|
|
23
|
+
await read(client, base + (kind === 'pr' ? '/pulls/' : '/issues/') + selected);
|
|
24
|
+
return base + '/issues/' + selected + '/assets';
|
|
25
|
+
}
|
|
26
|
+
const page = await collect(client, base + '/releases');
|
|
27
|
+
if (page.truncated)
|
|
28
|
+
throw new CliError('INCOMPLETE_READ', 'Release identity requires a complete collection');
|
|
29
|
+
const release = page.items.find(row => row.tag_name === selected);
|
|
30
|
+
if (!release || !Number.isSafeInteger(release.id) || Number(release.id) <= 0)
|
|
31
|
+
throw new CliError('NOT_FOUND_OR_HIDDEN', 'The selected release is missing or hidden');
|
|
32
|
+
return base + '/releases/' + release.id + '/assets';
|
|
33
|
+
}
|
|
34
|
+
async function uploadSteps(path, options) {
|
|
35
|
+
const paths = options.file ?? options.attach;
|
|
36
|
+
if (!paths)
|
|
37
|
+
throw new CliError('USAGE', 'Select at least one --file', 2);
|
|
38
|
+
const files = Array.isArray(paths) ? paths : [paths];
|
|
39
|
+
const names = new Set();
|
|
40
|
+
const steps = [];
|
|
41
|
+
for (const value of files) {
|
|
42
|
+
const filePath = String(value), name = safeName(basename(filePath));
|
|
43
|
+
if (names.has(name.toLowerCase()))
|
|
44
|
+
throw new CliError('UNSAFE_PATH', 'Duplicate attachment basenames are refused');
|
|
45
|
+
names.add(name.toLowerCase());
|
|
46
|
+
const info = await lstat(filePath);
|
|
47
|
+
if (!info.isFile() || info.isSymbolicLink() || await realpath(filePath) !== resolve(filePath))
|
|
48
|
+
throw new CliError('UNSAFE_PATH', 'Upload must be a regular file without symlinks');
|
|
49
|
+
const file = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
50
|
+
const chunks = [];
|
|
51
|
+
let size = 0;
|
|
52
|
+
try {
|
|
53
|
+
if (!(await file.stat()).isFile())
|
|
54
|
+
throw new CliError('UNSAFE_PATH', 'Upload must be a regular file');
|
|
55
|
+
for await (const chunk of file.createReadStream({ autoClose: false })) {
|
|
56
|
+
size += chunk.length;
|
|
57
|
+
if (size > transferBytes)
|
|
58
|
+
throw new CliError('TRANSFER_LIMIT', 'Upload exceeds 10000000 bytes');
|
|
59
|
+
chunks.push(chunk);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await file.close();
|
|
64
|
+
}
|
|
65
|
+
const bytes = Buffer.concat(chunks);
|
|
66
|
+
const type = String(options['content-type'] ?? 'application/octet-stream');
|
|
67
|
+
if (!/^[\w.+-]+\/[\w.+-]+$/.test(type))
|
|
68
|
+
throw new CliError('USAGE', 'Select a MIME content type', 2);
|
|
69
|
+
steps.push({ method: 'POST', path, upload: { name, type, size, base64: bytes.toString('base64'), sha256: hash(bytes) } });
|
|
70
|
+
}
|
|
71
|
+
return steps;
|
|
72
|
+
}
|
|
73
|
+
export async function uploadPlan(client, path, options) {
|
|
74
|
+
const page = await collect(client, path);
|
|
75
|
+
if (page.truncated)
|
|
76
|
+
throw new CliError('INCOMPLETE_READ', 'Attachment identity requires a complete collection');
|
|
77
|
+
const before = page.items.map(attachment);
|
|
78
|
+
const steps = await uploadSteps(path, options);
|
|
79
|
+
if (steps.some(step => before.some(row => String(row.name).toLowerCase() === step.upload.name.toLowerCase())))
|
|
80
|
+
throw new CliError('ALREADY_EXISTS', 'An attachment has the same basename; choose a distinct name');
|
|
81
|
+
return { target: path, steps, reads: [path], preconditions: before, assets: { path, before } };
|
|
82
|
+
}
|
|
83
|
+
export async function attachToPlan(client, plan, operation, base, selected, options) {
|
|
84
|
+
if (!options.attach)
|
|
85
|
+
return plan;
|
|
86
|
+
const comment = operation.endsWith(' comment');
|
|
87
|
+
const created = operation.endsWith(' create') || comment;
|
|
88
|
+
if (created) {
|
|
89
|
+
const parent = comment ? plan.steps.findIndex(step => step.path.endsWith('/comments')) : 0;
|
|
90
|
+
const path = base + (comment ? '/issues/comments/0/assets' : operation.startsWith('release ') ? '/releases/0/assets' : '/issues/0/assets');
|
|
91
|
+
return { ...plan, steps: [...plan.steps, ...(await uploadSteps(path, options)).map(step => ({ ...step, assetParent: parent }))], assets: { path, before: [] } };
|
|
92
|
+
}
|
|
93
|
+
const uploads = await uploadPlan(client, await assetPath(client, base, operation.split(' ')[0], selected), options);
|
|
94
|
+
return { ...plan, steps: [...plan.steps, ...uploads.steps], reads: [...plan.reads, ...uploads.reads], preconditions: { ...object(plan.preconditions), attachments: uploads.preconditions }, assets: uploads.assets };
|
|
95
|
+
}
|
|
96
|
+
export function verifyUploads(plan, results, observations) {
|
|
97
|
+
const { path, before } = plan.assets;
|
|
98
|
+
const after = observations.find(row => row.path === path).data.map(attachment);
|
|
99
|
+
const uploads = plan.steps.flatMap((step, index) => step.upload ? [{ upload: step.upload, result: attachment(validate(path + '/' + object(results[index]).id, results[index])) }] : []);
|
|
100
|
+
if (after.length !== before.length + uploads.length || before.some(row => !after.some(item => item.id === row.id && item.uuid === row.uuid && item.name === row.name && item.size === row.size)) || uploads.some(({ upload, result }) => before.some(row => row.id === result.id) || !after.some(row => row.id === result.id && row.uuid === result.uuid && row.name === upload.name && row.size === upload.size)))
|
|
101
|
+
throw new CliError('READBACK_MISMATCH', 'Attachment metadata did not confirm the intended uploads');
|
|
102
|
+
}
|
|
103
|
+
export async function download(client, path, options) {
|
|
104
|
+
if (process.platform === 'win32')
|
|
105
|
+
throw new CliError('CONFIG_INSECURE', 'Native Windows download ACL protection is unavailable');
|
|
106
|
+
if (options['content-type'] !== undefined && !/^[\w.+-]+\/[\w.+-]+$/.test(String(options['content-type'])))
|
|
107
|
+
throw new CliError('USAGE', 'Select a MIME content type', 2);
|
|
108
|
+
if (options.output && options.dir)
|
|
109
|
+
throw new CliError('USAGE', 'Choose --output or --dir', 2);
|
|
110
|
+
if (options['asset-id'] !== undefined && !/^[1-9]\d*$/.test(String(options['asset-id'])))
|
|
111
|
+
throw new CliError('USAGE', 'Select one positive --asset-id', 2);
|
|
112
|
+
const page = await collect(client, path);
|
|
113
|
+
if (page.truncated)
|
|
114
|
+
throw new CliError('INCOMPLETE_READ', 'Download selection requires a complete collection');
|
|
115
|
+
const patterns = options.pattern === undefined ? ['*'] : Array.isArray(options.pattern) ? options.pattern.map(String) : [String(options.pattern)];
|
|
116
|
+
const matches = patterns.map(pattern => new RegExp('^' + pattern.split(/([*?])/).map(part => part === '*' ? '.*' : part === '?' ? '.' : part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('') + '$'));
|
|
117
|
+
const selected = page.items.filter(row => (options['asset-id'] === undefined || String(row.id) === options['asset-id']) && matches.some(pattern => pattern.test(String(row.name)))).map(attachment);
|
|
118
|
+
if (!selected.length)
|
|
119
|
+
throw new CliError('NO_MATCHES', 'No attachments match the selected patterns');
|
|
120
|
+
if (new Set(selected.map(row => String(row.name).toLowerCase())).size !== selected.length)
|
|
121
|
+
throw new CliError('UNSAFE_PATH', 'Duplicate download basenames are refused');
|
|
122
|
+
if (options.sha256 !== undefined && (selected.length !== 1 || !/^[a-f0-9]{64}$/.test(String(options.sha256))))
|
|
123
|
+
throw new CliError('USAGE', '--sha256 requires exactly one selected file and a lowercase SHA-256 digest', 2);
|
|
124
|
+
if (options.output && selected.length !== 1)
|
|
125
|
+
throw new CliError('USAGE', '--output requires exactly one selected attachment', 2);
|
|
126
|
+
const directory = options.output ? dirname(resolve(String(options.output))) : resolve(String(options.dir ?? '.'));
|
|
127
|
+
if (!(await lstat(directory)).isDirectory() || await realpath(directory) !== directory)
|
|
128
|
+
throw new CliError('UNSAFE_PATH', 'Download directory must exist without symlinks');
|
|
129
|
+
const completed = [];
|
|
130
|
+
for (const selectedRow of selected) {
|
|
131
|
+
const temporary = join(directory, '.gitea-attachment-' + randomUUID() + '.tmp');
|
|
132
|
+
try {
|
|
133
|
+
const row = attachment(await read(client, path + '/' + selectedRow.id));
|
|
134
|
+
if (['id', 'uuid', 'name', 'size'].some(key => row[key] !== selectedRow[key]))
|
|
135
|
+
throw new CliError('INVALID_RESPONSE', 'Attachment identity changed before download');
|
|
136
|
+
if (Number(row.size) > transferBytes)
|
|
137
|
+
throw new CliError('TRANSFER_LIMIT', 'Download exceeds 10000000 bytes');
|
|
138
|
+
const destination = join(directory, options.output ? safeName(basename(String(options.output))) : String(row.name));
|
|
139
|
+
const previous = await lstat(destination).catch((error) => { if (error.code !== 'ENOENT')
|
|
140
|
+
throw error; return null; });
|
|
141
|
+
if (previous && (!options.clobber || previous.isSymbolicLink() || !previous.isFile()))
|
|
142
|
+
throw new CliError('UNSAFE_PATH', 'Destination exists; choose --clobber for a regular file');
|
|
143
|
+
const { bytes, type } = await client.attachment(String(row.uuid), options['content-type'] === undefined ? undefined : String(options['content-type']));
|
|
144
|
+
const sha256 = hash(bytes);
|
|
145
|
+
if (bytes.length !== row.size || options.sha256 && sha256 !== options.sha256)
|
|
146
|
+
throw new CliError('INTEGRITY_MISMATCH', 'Downloaded bytes differ from the selected size or digest');
|
|
147
|
+
const file = await open(temporary, 'wx', 0o600);
|
|
148
|
+
try {
|
|
149
|
+
await file.writeFile(bytes);
|
|
150
|
+
await file.sync();
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
await file.close();
|
|
154
|
+
}
|
|
155
|
+
if (options.clobber)
|
|
156
|
+
await rename(temporary, destination);
|
|
157
|
+
else
|
|
158
|
+
await link(temporary, destination);
|
|
159
|
+
completed.push({ id: row.id, name: row.name, bytes: bytes.length, sha256, content_type: type, path: destination });
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (!completed.length)
|
|
163
|
+
throw error;
|
|
164
|
+
throw new CliError('PARTIAL_TRANSFER', 'Completed attachment IDs ' + completed.map(row => row.id).join(',') + '; remaining transfer failed: ' + (error instanceof CliError ? error.code : 'FILE_ERROR'));
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
await rm(temporary, { force: true });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { downloads: completed };
|
|
171
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { CliError } from './error.js';
|
|
3
|
+
export const baseline = JSON.parse(readFileSync(new URL('../schemas/catalogue.json', import.meta.url), 'utf8')).commands;
|
|
4
|
+
export function commandGap(path) {
|
|
5
|
+
if (/^(discussion|gist|project) /.test(path))
|
|
6
|
+
return 'No corresponding discussion, gist or Projects v2 API is documented in the retained Gitea 1.23.4 or 1.24.0 specifications';
|
|
7
|
+
if (/^issue (lock|unlock)$/.test(path))
|
|
8
|
+
return 'Gitea 1.23.4 has no documented issue lock contract; the retained 1.24.0 lock candidate is not commissioned';
|
|
9
|
+
if (/^issue (transfer|subissue )/.test(path))
|
|
10
|
+
return 'Gitea 1.23.4 and 1.24.0 provide no evidenced issue transfer or parent/child subissue contract; repository transfer and dependencies are different operations';
|
|
11
|
+
if (path === 'pr ready')
|
|
12
|
+
return 'Gitea 1.23.4 and 1.24.0 do not document a draft transition field in EditPullRequestOption';
|
|
13
|
+
if (path === 'setup hooks')
|
|
14
|
+
return 'C096: retained gh-axi 0.1.46 help does not specify supported agent paths, configuration schemas or repair semantics; hook installation remains pending';
|
|
15
|
+
if (path === 'update' || path === 'update --check')
|
|
16
|
+
return 'C122/C123: retained gh-axi 0.1.46 help does not establish the distribution, integrity or rollback contract; update and check-only remain pending';
|
|
17
|
+
if (path === 'api HEAD')
|
|
18
|
+
return 'Gitea 1.23.4 has no documented HEAD operation/effect contract; no GET substitution is performed';
|
|
19
|
+
if (path === 'pr review')
|
|
20
|
+
return 'Gitea 1.23.4 and 1.24.0 do not establish review event values or atomic stale-head rejection (CX-007); review posting remains pending';
|
|
21
|
+
if (path === 'dashboard')
|
|
22
|
+
return 'C121: exact gh-axi 0.1.46 sections and defaults are not captured; the owned bounded repository dashboard is not baseline parity';
|
|
23
|
+
if (path.startsWith('workflow ') || path.startsWith('run ') && path !== 'run list')
|
|
24
|
+
return 'Gitea 1.23.4 has task summaries only; retained 1.24.0 workflow/run candidates remain uncommissioned';
|
|
25
|
+
if (path === 'search code')
|
|
26
|
+
return 'Gitea 1.23.4 has no evidenced code search API/query contract';
|
|
27
|
+
if (path === 'stack submit')
|
|
28
|
+
return 'The required automatic merge scheduling contract remains pending';
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
export function flagGap(path, name) {
|
|
32
|
+
if (name === 'jq' || name === 'template')
|
|
33
|
+
return '--' + name + ' compatibility is not implemented: no jq evaluator or template engine/grammar has been selected; Gitea 1.23.4 responses do not supply these local semantics';
|
|
34
|
+
return '--' + name + ' has no implemented compatibility contract for ' + path + ' on Gitea 1.23.4; see capabilities for the retained baseline outcome';
|
|
35
|
+
}
|
|
36
|
+
export function capabilityOutput(commands, fields, id) {
|
|
37
|
+
if (id && !baseline.some(row => row.id === id))
|
|
38
|
+
throw new CliError('USAGE', 'Select a catalogue ID C001 through C124', 2);
|
|
39
|
+
const rows = baseline.filter(row => !id || row.id === id).map(row => {
|
|
40
|
+
const path = row.path === 'update --check' ? 'update' : row.path.startsWith('api ') ? 'api' : row.path;
|
|
41
|
+
const command = commands.find(command => command.path.join(' ') === path);
|
|
42
|
+
const gap = commandGap(row.path) ?? command?.gap;
|
|
43
|
+
const flags = row.flags.map(flag => {
|
|
44
|
+
const name = flag.name === '-X' ? 'method' : flag.name.replace(/^--/, '');
|
|
45
|
+
const meta = command?.flags[name];
|
|
46
|
+
return { ...flag, status: !meta || meta.gap || gap ? 'gap' : 'available with owned semantics',
|
|
47
|
+
contract: gap ?? meta?.gap ?? 'Use command help; baseline semantic parity is not claimed by flag acceptance' };
|
|
48
|
+
});
|
|
49
|
+
return id ? { ...row, status: gap ? 'gap' : 'partial', gap: gap ?? null, flags } :
|
|
50
|
+
{ id: row.id, command: 'gitea-axi ' + row.path, status: gap ? 'gap' : 'partial', flags: flags.length };
|
|
51
|
+
});
|
|
52
|
+
return { baseline: 'gh-axi 0.1.46', target: 'Gitea 1.23.4',
|
|
53
|
+
counts: { commands: baseline.length, commandFlags: baseline.reduce((count, row) => count + row.flags.length, 0), globals: 4, prFields: fields.length, fullParity: 0 },
|
|
54
|
+
commands: rows,
|
|
55
|
+
...(id ? {} : { globals: [
|
|
56
|
+
{ selector: '-R/--repo', contract: 'Explicit repository before matching local remote; stack rejects it and GH_REPO' },
|
|
57
|
+
{ selector: '--hostname/GH_HOST', contract: 'Select exactly one matching configured profile by host and port; exact tokenOrigin binding applies before secret resolution' },
|
|
58
|
+
{ selector: '--help', contract: 'Offline registry-derived help' },
|
|
59
|
+
{ selector: '-v/-V/--version', contract: 'Offline packaged version' },
|
|
60
|
+
], prFields: fields.map(field => ({ field, contract: field === 'id' ? 'gap: GraphQL node ID unavailable; fullDatabaseId is the numeric vendor ID' :
|
|
61
|
+
field === 'statusCheckRollup' ? 'partial commit statuses only; excludes full check suites' :
|
|
62
|
+
['autoMergeRequest', 'closingIssuesReferences', 'mergeStateStatus', 'potentialMergeCommit', 'projectCards', 'projectItems', 'reviewDecision'].includes(field) ? 'gap: no evidenced Gitea 1.23.4 field contract' : 'owned normalized field; see pr view --fields' })) }),
|
|
63
|
+
help: id ? ['gitea-axi capabilities'] : ['gitea-axi capabilities C038'],
|
|
64
|
+
};
|
|
65
|
+
}
|
package/dist/ci.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { collect } from './api.js';
|
|
2
|
+
import { CliError } from './error.js';
|
|
3
|
+
import { continuationHelp, positive, selectedFields } from './reads.js';
|
|
4
|
+
export const taskFields = ['id', 'name', 'display_title', 'workflow_id', 'head_branch', 'head_sha', 'event', 'status', 'run_number', 'url', 'created_at', 'updated_at', 'run_started_at'];
|
|
5
|
+
export function validateTasks(options) {
|
|
6
|
+
positive(options.limit, 10, 1000);
|
|
7
|
+
selectedFields(options, taskFields, []);
|
|
8
|
+
}
|
|
9
|
+
export async function tasks(client, base, options) {
|
|
10
|
+
const page = await collect(client, base + '/actions/tasks', 'workflow_runs').catch((error) => {
|
|
11
|
+
if (error instanceof CliError && error.code === 'NOT_FOUND_OR_HIDDEN')
|
|
12
|
+
throw new CliError('CAPABILITY_GAP', 'Actions task reads are unavailable or hidden on this server; full workflow run APIs require Gitea 1.24 or later');
|
|
13
|
+
throw error;
|
|
14
|
+
});
|
|
15
|
+
const mapping = { workflow: 'workflow_id', branch: 'head_branch', status: 'status', event: 'event', commit: 'head_sha' };
|
|
16
|
+
const rows = page.items.filter(row => Object.entries(mapping).every(([flag, field]) => options[flag] === undefined || row[field] === options[flag]));
|
|
17
|
+
const limit = positive(options.limit, 10, 1000);
|
|
18
|
+
const fields = selectedFields(options, taskFields, []) ?? ['id', 'name', 'status'];
|
|
19
|
+
return { items: rows.slice(0, limit).map(row => Object.fromEntries(fields.map(field => [field, row[field] ?? null]))), count: Math.min(rows.length, limit),
|
|
20
|
+
total: page.truncated ? null : rows.length, unfiltered_total: page.total, next: page.next, truncated: page.truncated || rows.length > limit, continuation: page.truncated || rows.length > limit,
|
|
21
|
+
help: [...continuationHelp(page, rows.length, limit), ...(page.items.length === 0 ? ['No task summaries returned. Gitea older than 1.24 may return an empty task list even when workflow runs exist; this does not establish that there are no runs.'] : [])],
|
|
22
|
+
scope: 'Gitea 1.23.4 ActionTask summaries; task/run identity pending commissioning. Status does not establish job conclusions or terminal run results.',
|
|
23
|
+
pending: ['run details, watch, logs, artifacts and controls', '1.24.0 workflow routes require representative commissioning', 'mutations require the shared write gate'] };
|
|
24
|
+
}
|
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
|
+
}
|