@knowttl/gitea-axi 0.1.0 → 0.1.2

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/dist/api.js CHANGED
@@ -10,6 +10,12 @@ const required = {
10
10
  Comment: ['id', 'body', 'user'], PullReview: ['id', 'user', 'state', 'body', 'commit_id'],
11
11
  CommitStatus: ['id', 'context', 'status'], Milestone: ['id', 'title'],
12
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'],
13
19
  };
14
20
  for (const [name, fields] of Object.entries(required)) {
15
21
  if (vendor.definitions[name])
@@ -23,6 +29,17 @@ for (const definition of Object.values(vendor.definitions)) {
23
29
  }
24
30
  }
25
31
  }
32
+ const requestDefinitions = structuredClone(vendor.definitions);
33
+ for (const [name, fields] of Object.entries({
34
+ PullRequest: ['merge_commit_sha'],
35
+ ContentsResponse: ['submodule_git_url', 'target', 'content', 'encoding', 'download_url', 'html_url', 'git_url'],
36
+ FileLinksResponse: ['git', 'html'],
37
+ })) {
38
+ for (const field of fields) {
39
+ const properties = vendor.definitions[name].properties;
40
+ properties[field] = { anyOf: [properties[field], { type: 'null' }] };
41
+ }
42
+ }
26
43
  const ajv = new Ajv({ strict: false, validateFormats: false });
27
44
  const validators = new Map();
28
45
  export function object(value) {
@@ -30,8 +47,48 @@ export function object(value) {
30
47
  throw new CliError('INVALID_RESPONSE', 'Expected an object from Gitea');
31
48
  return value;
32
49
  }
50
+ export function effectFor(method, path) {
51
+ path = canonicalPath(path);
52
+ return routes.find(route => route.method === method && new RegExp('^' + route.path.split(/(\{[^}]+\})/).map(part => {
53
+ if (/^\{[^}]+\}$/.test(part))
54
+ return route.multiSegmentParameters?.includes(part.slice(1, -1)) ? '[^/]+(?:/[^/]+)*' : '[^/]+';
55
+ return part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
56
+ }).join('') + '$').test(path.split('?')[0]));
57
+ }
58
+ export function canonicalPath(path) {
59
+ const pathname = path.split('?')[0];
60
+ if (/%(?:2f|5c)/i.test(pathname))
61
+ throw new CliError('READ_ONLY', 'Encoded route separators are refused');
62
+ let decoded;
63
+ try {
64
+ decoded = decodeURIComponent(pathname);
65
+ }
66
+ catch {
67
+ throw new CliError('USAGE', 'Malformed API path', 2);
68
+ }
69
+ return decoded.split('/').map(encodeURIComponent).join('/');
70
+ }
71
+ export function validatePayload(method, path, payload) {
72
+ const route = effectFor(method, path);
73
+ if (!route || route.effect !== 'write')
74
+ throw new CliError('READ_ONLY', 'Only classified business writes may be enabled');
75
+ if (/\/actions\/secrets\//.test(path) && method === 'PUT' &&
76
+ (typeof object(payload).data !== 'string' || Object.keys(object(payload)).some(key => key !== 'data'))) {
77
+ throw new CliError('USAGE', 'Secret input requires only a string data field', 2);
78
+ }
79
+ const segments = canonicalPath(path).split('/');
80
+ for (const [index, segment] of route.path.split('/').entries()) {
81
+ if (route.integerParameters.some(name => segment === '{' + name + '}') && !/^(?:0|-?[1-9]\d*)$/.test(segments[index])) {
82
+ throw new CliError('USAGE', 'Integer route parameters require canonical decimal spelling', 2);
83
+ }
84
+ }
85
+ if (route.requestSchema && !ajv.compile({ ...route.requestSchema, definitions: requestDefinitions })(payload)) {
86
+ throw new CliError('USAGE', 'Invalid request fields for ' + route.operation, 2);
87
+ }
88
+ return route;
89
+ }
33
90
  export function routeFor(path) {
34
- const route = routes.find(route => new RegExp('^' + route.path.replace(/[.]/g, '\\.').replace(/\{[^}]+\}/g, '[^/]+') + '$').test(path));
91
+ const route = effectFor('GET', path);
35
92
  if (route?.effect === 'admin')
36
93
  throw new CliError('READ_ONLY', 'Administrative API routes are refused; only metadata reads are available');
37
94
  if (route?.effect === 'credential')
@@ -47,39 +104,52 @@ export function validate(path, value) {
47
104
  if (route.schema) {
48
105
  let validator = validators.get(route.path);
49
106
  if (!validator) {
50
- validator = ajv.compile({ ...route.schema, definitions: vendor.definitions });
107
+ const schema = route.operation === 'repoGetContents' ? { anyOf: [route.schema, { type: 'array', items: route.schema }] } : route.schema;
108
+ validator = ajv.compile({ ...schema, definitions: vendor.definitions });
51
109
  validators.set(route.path, validator);
52
110
  }
53
111
  if (!validator(value))
54
112
  throw new CliError('INVALID_RESPONSE', route.operation + ' returned invalid fields');
55
113
  }
114
+ if (/\/actions\/secrets(?:\?|$)/.test(path) && Array.isArray(value)) {
115
+ return value.map(row => ({ name: object(row).name, ...(object(row).created_at ? { created_at: object(row).created_at } : {}) }));
116
+ }
56
117
  return value;
57
118
  }
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 === '..'))
119
+ export function rawPath(input, method = 'GET') {
120
+ if (/^[a-z]+:/i.test(input) || input.startsWith('//') || input.includes('#'))
121
+ throw new CliError('USAGE', 'Use a relative API path without traversal', 2);
122
+ const separator = input.indexOf('?');
123
+ const pathname = canonicalPath(separator < 0 ? input : input.slice(0, separator));
124
+ const query = separator < 0 ? '' : input.slice(separator + 1);
125
+ const decoded = decodeURIComponent(pathname);
126
+ if (/[\\#\s%]/.test(decoded) || decoded.split('/').some(part => part === '.' || part === '..'))
68
127
  throw new CliError('USAGE', 'Use a relative API path without traversal', 2);
69
- const [pathname, query = ''] = input.split('?');
70
128
  for (const key of new URLSearchParams(query).keys()) {
71
129
  if (/token|secret|password|authorization|api.?key/i.test(key))
72
- throw new CliError('CONFIG_INVALID', 'Secret query fields are refused');
130
+ throw new CliError('USAGE', 'Secret query fields are refused', 2);
73
131
  }
74
132
  const path = pathname === '/swagger.v1.json' ? pathname : pathname?.startsWith('/api/v1/') ? pathname : '/api/v1/' + pathname?.replace(/^\//, '');
75
- if (path !== '/swagger.v1.json')
133
+ if (path !== '/swagger.v1.json' && method === 'GET')
76
134
  routeFor(path);
135
+ else if (method !== 'GET' && !effectFor(method, path))
136
+ throw new CliError('READ_ONLY', 'Unclassified API route');
77
137
  return path + (query ? '?' + query : '');
78
138
  }
79
139
  export async function read(client, path) {
140
+ routeFor(path.split('?')[0]);
80
141
  const value = await client.get(path);
81
142
  return validate(path.split('?')[0], value);
82
143
  }
144
+ export async function resolveCommit(client, base, ref) {
145
+ const rows = await read(client, base + '/commits?sha=' + encodeURIComponent(ref) + '&limit=1');
146
+ if (!Array.isArray(rows) || rows.length !== 1)
147
+ throw new CliError('NOT_FOUND_OR_HIDDEN', 'The selected commit ref is missing or hidden');
148
+ const row = object(rows[0]);
149
+ if (typeof row.sha !== 'string' || !/^[a-f0-9]{40}$/.test(row.sha))
150
+ throw new CliError('INVALID_RESPONSE', 'Expected an immutable commit identity');
151
+ return row;
152
+ }
83
153
  function nextPage(headers, path, origin) {
84
154
  const target = headers.get('link')?.split(',').find(part => /rel="?next"?/.test(part))?.match(/<([^>]+)>/)?.[1];
85
155
  if (!target)
@@ -97,7 +167,8 @@ export async function rawRead(client, path) {
97
167
  return { data, total: count && /^\d+$/.test(count) ? Number(count) : null, next: nextPage(response.headers, path, client.origin), truncated: false };
98
168
  }
99
169
  export async function collect(client, path, envelope) {
100
- let next = path + (path.includes('?') ? '&' : '?') + 'limit=50&page=1';
170
+ const paginated = routeFor(path).paginated;
171
+ let next = paginated ? path + (path.includes('?') ? '&' : '?') + 'limit=50&page=1' : path;
101
172
  const seenPages = new Set();
102
173
  const items = new Map();
103
174
  let total = null;
@@ -112,7 +183,7 @@ export async function collect(client, path, envelope) {
112
183
  throw new CliError('INVALID_RESPONSE', 'Expected a Gitea list' + (envelope ? ' envelope ' + envelope : ''));
113
184
  for (const value of rows) {
114
185
  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);
186
+ 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);
116
187
  if (typeof id !== 'number' && typeof id !== 'string')
117
188
  throw new CliError('INVALID_RESPONSE', 'List record has no identity');
118
189
  items.set(String(id), row);
@@ -124,11 +195,11 @@ export async function collect(client, path, envelope) {
124
195
  total = object(data).total_count;
125
196
  const link = response.headers.get('link');
126
197
  next = nextPage(response.headers, path, client.origin);
127
- if (!next && !link && rows.length >= 50) {
198
+ if (paginated && !next && !link && rows.length >= 50) {
128
199
  const url = new URL([...seenPages].at(-1), client.origin);
129
200
  url.searchParams.set('page', String(Number(url.searchParams.get('page')) + 1));
130
201
  next = url.pathname + url.search;
131
202
  }
132
203
  }
133
- return { items: [...items.values()], total, next, truncated: next !== null };
204
+ return { items: [...items.values()].slice(0, 1000), total: total ?? (!paginated && !next ? items.size : null), next, truncated: next !== null || items.size > 1000 };
134
205
  }
@@ -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
+ }
@@ -1,12 +1,45 @@
1
1
  import { readFileSync, existsSync } from 'node:fs';
2
+ import { stat } from 'node:fs/promises';
2
3
  import { homedir } from 'node:os';
3
4
  import { isAbsolute, join } from 'node:path';
4
5
  import { Ajv } from 'ajv';
5
6
  import { CliError } from './error.js';
6
7
  import { readPrivateFile } from './private-file.js';
7
8
  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 validate = new Ajv({ strict: false, allErrors: true }).compile(schema);
10
+ function validateProfile(profile) {
11
+ if (validate(profile))
12
+ return;
13
+ const value = profile && typeof profile === 'object' ? profile : undefined;
14
+ const token = value && 'token' in value ? value.token : undefined;
15
+ const branch = token && typeof token === 'object' ? 'env' in token ? '0' : 'file' in token ? '1' : undefined : undefined;
16
+ const http = value && 'origin' in value && typeof value.origin === 'string' && value.origin.startsWith('http:');
17
+ const errors = (validate.errors ?? []).filter(error => {
18
+ const alternative = error.schemaPath.match(/^#\/properties\/token\/oneOf\/(\d+)\//)?.[1];
19
+ return (!alternative || alternative === branch) && (!error.schemaPath.startsWith('#/then/') || http) && error.keyword !== 'if' && error.keyword !== 'oneOf';
20
+ });
21
+ const priority = { required: 0, additionalProperties: 1 };
22
+ const error = errors.find(error => error.schemaPath === '#/required') ?? errors.sort((a, b) => (priority[a.keyword] ?? 2) - (priority[b.keyword] ?? 2) || b.instancePath.split('/').length - a.instancePath.split('/').length)[0] ?? validate.errors?.find(error => error.keyword === 'oneOf');
23
+ const property = error?.keyword === 'required' ? error.params.missingProperty : error?.keyword === 'additionalProperties' ? error.params.additionalProperty : undefined;
24
+ const field = [...(error?.instancePath.split('/').slice(1) ?? []), ...(property ? [String(property)] : [])].join('.');
25
+ if (field === 'allowInsecureHttp' && error?.schemaPath.startsWith('#/then/'))
26
+ throw new CliError('CONFIG_INVALID', 'HTTP requires allowInsecureHttp: true on the selected profile');
27
+ const correction = error?.keyword === 'additionalProperties' ? 'remove this unknown field' :
28
+ field === 'token.file' ? 'set an absolute local file path' :
29
+ field === 'token.env' ? 'set a valid environment variable name' :
30
+ field === 'token' ? 'set exactly one of env (an environment variable name) or file (an absolute local path)' :
31
+ field === 'allowInsecureHttp' ? 'set true or false' :
32
+ error?.keyword === 'required' ? 'set this required field' : 'use a value matching the credential profile schema';
33
+ throw new CliError('CONFIG_INVALID', 'Invalid profile field: ' + (field || 'profile') + '; ' + correction);
34
+ }
9
35
  const secrets = [];
36
+ export function registerSecret(value) { if (value)
37
+ secrets.push(value); }
38
+ const controls = new Set();
39
+ export function controlText(value) {
40
+ controls.add(value);
41
+ return value;
42
+ }
10
43
  export function redact(value) {
11
44
  if (Array.isArray(value))
12
45
  return value.map(redact);
@@ -15,6 +48,10 @@ export function redact(value) {
15
48
  /^(?:token|access_token|refresh_token|password|secret|authorization|private_key)$/i.test(key) ? '[REDACTED]' : redact(item)]));
16
49
  if (typeof value !== 'string')
17
50
  return value;
51
+ // Callers register only generated control text, never payloads or upstream response text.
52
+ const preserved = [...controls].filter(text => value.includes(text)).sort((a, b) => b.length - a.length);
53
+ if (preserved.length)
54
+ return value.split(new RegExp('(' + preserved.map(text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')')).map(part => controls.has(part) ? part : redact(part)).join('');
18
55
  let output = value;
19
56
  for (const secret of secrets) {
20
57
  for (const form of [secret, JSON.stringify(secret).slice(1, -1), encodeURIComponent(secret)]) {
@@ -69,11 +106,31 @@ export function canonicalOrigin(value) {
69
106
  export async function credentials(override, allowHttp, options = {}) {
70
107
  const path = configPath(options.config);
71
108
  const selected = options.profile ?? process.env.GITEA_AXI_PROFILE;
109
+ const hostname = options.hostname ?? (selected === 'artium' ? undefined : process.env.GH_HOST);
110
+ if (hostname === '')
111
+ throw new CliError('USAGE', '--hostname/GH_HOST requires a nonempty host', 2);
112
+ const matchesHost = (origin) => {
113
+ const url = new URL(canonicalOrigin(origin));
114
+ if (!hostname || /[\s\\/?#@]/.test(hostname))
115
+ throw new CliError('USAGE', '--hostname/GH_HOST requires a host with optional port', 2);
116
+ let host;
117
+ try {
118
+ host = new URL(url.protocol + '//' + hostname);
119
+ }
120
+ catch {
121
+ throw new CliError('USAGE', '--hostname/GH_HOST requires a host with optional port', 2);
122
+ }
123
+ return url.host === host.host;
124
+ };
72
125
  const fields = ['ORIGIN', 'TOKEN_ORIGIN', 'TOKEN', 'ALLOW_INSECURE_HTTP'];
73
126
  const hasEnv = fields.some(field => process.env['GITEA_AXI_' + field] !== undefined);
74
127
  let profile;
75
128
  let profileName = 'environment';
129
+ let revision = 'environment';
76
130
  if (!selected && hasEnv) {
131
+ const missing = ['ORIGIN', 'TOKEN_ORIGIN', 'TOKEN'].filter(field => !process.env['GITEA_AXI_' + field]);
132
+ if (missing.length)
133
+ throw new CliError('CONFIG_INVALID', 'Set missing environment variables: ' + missing.map(field => 'GITEA_AXI_' + field).join(', '));
77
134
  let token;
78
135
  try {
79
136
  token = JSON.parse(process.env.GITEA_AXI_TOKEN ?? 'null');
@@ -87,18 +144,30 @@ export async function credentials(override, allowHttp, options = {}) {
87
144
  }
88
145
  else if (existsSync(path)) {
89
146
  const config = await readConfig(path);
90
- const names = Object.keys(config.profiles);
91
- const name = selected ?? config.defaultProfile ?? (names.length === 1 ? names[0] : undefined);
147
+ const names = Object.keys(config.profiles).filter(name => {
148
+ if (!hostname)
149
+ return true;
150
+ const profile = config.profiles[name];
151
+ validateProfile(profile);
152
+ return matchesHost(profile.origin);
153
+ });
154
+ if (hostname && !selected && !names.length)
155
+ throw new CliError('PROFILE_NOT_FOUND', 'No configured profile matches --hostname/GH_HOST');
156
+ const name = selected ?? (hostname ? names.length === 1 ? names[0] : undefined : config.defaultProfile ?? (names.length === 1 ? names[0] : undefined));
92
157
  if (!name)
93
158
  throw new CliError(names.length ? 'PROFILE_AMBIGUOUS' : 'PROFILE_REQUIRED', 'Select a profile');
94
159
  if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name) || !Object.hasOwn(config.profiles, name))
95
160
  throw new CliError('PROFILE_NOT_FOUND', 'Selected profile was not found');
96
161
  profile = config.profiles[name];
97
162
  profileName = name;
163
+ revision = process.env.GITEA_AXI_CREDENTIAL_REVISION ?? String((await stat(path, { bigint: true })).mtimeNs);
98
164
  }
99
165
  else if (selected || options.config || process.env.GITEA_AXI_CONFIG) {
100
166
  throw new CliError('PROFILE_NOT_FOUND', 'Selected profile configuration was not found');
101
167
  }
168
+ else if (hostname) {
169
+ throw new CliError('PROFILE_NOT_FOUND', '--hostname/GH_HOST requires a matching configured profile');
170
+ }
102
171
  else if (override) {
103
172
  const origin = canonicalOrigin(override);
104
173
  if (origin.startsWith('http:') && !allowHttp)
@@ -108,9 +177,10 @@ export async function credentials(override, allowHttp, options = {}) {
108
177
  else {
109
178
  throw new CliError('AUTH_REQUIRED', 'Select a configured profile or an explicit anonymous --origin');
110
179
  }
111
- if (!validate(profile))
112
- throw new CliError('CONFIG_INVALID', 'Invalid profile field: ' + (validate.errors?.[0]?.instancePath || 'token/origin'));
180
+ validateProfile(profile);
113
181
  const origin = canonicalOrigin(profile.origin);
182
+ if (hostname && !matchesHost(origin))
183
+ throw new CliError('AUTH_BINDING_MISMATCH', 'Selected profile does not match --hostname/GH_HOST');
114
184
  if (origin !== canonicalOrigin(profile.tokenOrigin) || (override && canonicalOrigin(override) !== origin)) {
115
185
  throw new CliError('AUTH_BINDING_MISMATCH', 'origin must match tokenOrigin before resolving token');
116
186
  }
@@ -123,11 +193,13 @@ export async function credentials(override, allowHttp, options = {}) {
123
193
  if (!isAbsolute(profile.token.file) || /^\\\\/.test(profile.token.file))
124
194
  throw new CliError('CONFIG_INVALID', 'token file must be an absolute local path');
125
195
  token = (await readPrivateFile(profile.token.file, true)).replace(/^\uFEFF/, '').trimEnd();
196
+ revision += ':' + String((await stat(profile.token.file, { bigint: true })).mtimeNs);
126
197
  }
127
198
  if (!token)
128
199
  throw new CliError('AUTH_REQUIRED', 'token reference is empty');
129
200
  secrets.push(token);
130
201
  if (/[\r\n]/.test(token))
131
202
  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 };
203
+ return { origin, token, profile: profileName, revision, source: 'env' in profile.token ? 'env:' + profile.token.env : 'file:' + profile.token.file,
204
+ ...(profile.writes ? { writes: profile.writes } : {}) };
133
205
  }