@knowttl/gitea-axi 0.1.0 → 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.
@@ -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,40 @@
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); }
10
38
  export function redact(value) {
11
39
  if (Array.isArray(value))
12
40
  return value.map(redact);
@@ -69,11 +97,31 @@ export function canonicalOrigin(value) {
69
97
  export async function credentials(override, allowHttp, options = {}) {
70
98
  const path = configPath(options.config);
71
99
  const selected = options.profile ?? process.env.GITEA_AXI_PROFILE;
100
+ const hostname = options.hostname ?? (selected === 'artium' ? undefined : process.env.GH_HOST);
101
+ if (hostname === '')
102
+ throw new CliError('USAGE', '--hostname/GH_HOST requires a nonempty host', 2);
103
+ const matchesHost = (origin) => {
104
+ const url = new URL(canonicalOrigin(origin));
105
+ if (!hostname || /[\s\\/?#@]/.test(hostname))
106
+ throw new CliError('USAGE', '--hostname/GH_HOST requires a host with optional port', 2);
107
+ let host;
108
+ try {
109
+ host = new URL(url.protocol + '//' + hostname);
110
+ }
111
+ catch {
112
+ throw new CliError('USAGE', '--hostname/GH_HOST requires a host with optional port', 2);
113
+ }
114
+ return url.host === host.host;
115
+ };
72
116
  const fields = ['ORIGIN', 'TOKEN_ORIGIN', 'TOKEN', 'ALLOW_INSECURE_HTTP'];
73
117
  const hasEnv = fields.some(field => process.env['GITEA_AXI_' + field] !== undefined);
74
118
  let profile;
75
119
  let profileName = 'environment';
120
+ let revision = 'environment';
76
121
  if (!selected && hasEnv) {
122
+ const missing = ['ORIGIN', 'TOKEN_ORIGIN', 'TOKEN'].filter(field => !process.env['GITEA_AXI_' + field]);
123
+ if (missing.length)
124
+ throw new CliError('CONFIG_INVALID', 'Set missing environment variables: ' + missing.map(field => 'GITEA_AXI_' + field).join(', '));
77
125
  let token;
78
126
  try {
79
127
  token = JSON.parse(process.env.GITEA_AXI_TOKEN ?? 'null');
@@ -87,18 +135,30 @@ export async function credentials(override, allowHttp, options = {}) {
87
135
  }
88
136
  else if (existsSync(path)) {
89
137
  const config = await readConfig(path);
90
- const names = Object.keys(config.profiles);
91
- const name = selected ?? config.defaultProfile ?? (names.length === 1 ? names[0] : undefined);
138
+ const names = Object.keys(config.profiles).filter(name => {
139
+ if (!hostname)
140
+ return true;
141
+ const profile = config.profiles[name];
142
+ validateProfile(profile);
143
+ return matchesHost(profile.origin);
144
+ });
145
+ if (hostname && !selected && !names.length)
146
+ throw new CliError('PROFILE_NOT_FOUND', 'No configured profile matches --hostname/GH_HOST');
147
+ const name = selected ?? (hostname ? names.length === 1 ? names[0] : undefined : config.defaultProfile ?? (names.length === 1 ? names[0] : undefined));
92
148
  if (!name)
93
149
  throw new CliError(names.length ? 'PROFILE_AMBIGUOUS' : 'PROFILE_REQUIRED', 'Select a profile');
94
150
  if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name) || !Object.hasOwn(config.profiles, name))
95
151
  throw new CliError('PROFILE_NOT_FOUND', 'Selected profile was not found');
96
152
  profile = config.profiles[name];
97
153
  profileName = name;
154
+ revision = process.env.GITEA_AXI_CREDENTIAL_REVISION ?? String((await stat(path, { bigint: true })).mtimeNs);
98
155
  }
99
156
  else if (selected || options.config || process.env.GITEA_AXI_CONFIG) {
100
157
  throw new CliError('PROFILE_NOT_FOUND', 'Selected profile configuration was not found');
101
158
  }
159
+ else if (hostname) {
160
+ throw new CliError('PROFILE_NOT_FOUND', '--hostname/GH_HOST requires a matching configured profile');
161
+ }
102
162
  else if (override) {
103
163
  const origin = canonicalOrigin(override);
104
164
  if (origin.startsWith('http:') && !allowHttp)
@@ -108,9 +168,10 @@ export async function credentials(override, allowHttp, options = {}) {
108
168
  else {
109
169
  throw new CliError('AUTH_REQUIRED', 'Select a configured profile or an explicit anonymous --origin');
110
170
  }
111
- if (!validate(profile))
112
- throw new CliError('CONFIG_INVALID', 'Invalid profile field: ' + (validate.errors?.[0]?.instancePath || 'token/origin'));
171
+ validateProfile(profile);
113
172
  const origin = canonicalOrigin(profile.origin);
173
+ if (hostname && !matchesHost(origin))
174
+ throw new CliError('AUTH_BINDING_MISMATCH', 'Selected profile does not match --hostname/GH_HOST');
114
175
  if (origin !== canonicalOrigin(profile.tokenOrigin) || (override && canonicalOrigin(override) !== origin)) {
115
176
  throw new CliError('AUTH_BINDING_MISMATCH', 'origin must match tokenOrigin before resolving token');
116
177
  }
@@ -123,11 +184,13 @@ export async function credentials(override, allowHttp, options = {}) {
123
184
  if (!isAbsolute(profile.token.file) || /^\\\\/.test(profile.token.file))
124
185
  throw new CliError('CONFIG_INVALID', 'token file must be an absolute local path');
125
186
  token = (await readPrivateFile(profile.token.file, true)).replace(/^\uFEFF/, '').trimEnd();
187
+ revision += ':' + String((await stat(profile.token.file, { bigint: true })).mtimeNs);
126
188
  }
127
189
  if (!token)
128
190
  throw new CliError('AUTH_REQUIRED', 'token reference is empty');
129
191
  secrets.push(token);
130
192
  if (/[\r\n]/.test(token))
131
193
  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 };
194
+ return { origin, token, profile: profileName, revision, source: 'env' in profile.token ? 'env:' + profile.token.env : 'file:' + profile.token.file,
195
+ ...(profile.writes ? { writes: profile.writes } : {}) };
133
196
  }
package/dist/git.js ADDED
@@ -0,0 +1,89 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { realpath } from 'node:fs/promises';
3
+ import { isAbsolute } from 'node:path';
4
+ import { CliError } from './error.js';
5
+ import { createHash } from 'node:crypto';
6
+ export class Git {
7
+ cwd;
8
+ origin;
9
+ token;
10
+ constructor(cwd, origin, token) {
11
+ this.cwd = cwd;
12
+ this.origin = origin;
13
+ this.token = token;
14
+ }
15
+ async run(args, allowed = [0]) {
16
+ const env = {
17
+ PATH: process.env.PATH, SystemRoot: process.env.SystemRoot,
18
+ GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
19
+ GIT_OPTIONAL_LOCKS: '0',
20
+ GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', GIT_EDITOR: 'true', GIT_SEQUENCE_EDITOR: 'true',
21
+ GIT_CONFIG_COUNT: this.token ? '1' : '0',
22
+ ...(this.token ? { GIT_CONFIG_KEY_0: 'http.' + this.origin + '/.extraHeader', GIT_CONFIG_VALUE_0: 'Authorization: token ' + this.token } : {}),
23
+ };
24
+ return new Promise((resolve, reject) => {
25
+ const child = spawn('git', ['-c', 'core.hooksPath=' + (process.platform === 'win32' ? 'NUL' : '/dev/null'),
26
+ '-c', 'core.fsmonitor=false', '-c', 'credential.helper=', '-c', 'http.followRedirects=false', '-c', 'protocol.allow=never',
27
+ '-c', 'protocol.http.allow=always', '-c', 'protocol.https.allow=always',
28
+ ...(args[0] === 'push' ? ['push', '--no-follow-tags', '--recurse-submodules=no', ...args.slice(1)] : args)], {
29
+ cwd: this.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000,
30
+ });
31
+ let output = '', size = 0;
32
+ child.stdout.on('data', (bytes) => { size += bytes.length; if (size > 2_000_000)
33
+ child.kill();
34
+ else
35
+ output += bytes.toString(); });
36
+ child.stderr.on('data', () => { });
37
+ child.on('error', () => reject(new CliError('GIT_FAILED', 'Unable to start native Git')));
38
+ child.on('close', code => allowed.includes(code ?? -1) && size <= 2_000_000 ? resolve(output.trimEnd()) :
39
+ reject(new CliError('GIT_FAILED', 'Native Git ' + args[0] + ' stopped; inspect the workspace for conflicts before continuing')));
40
+ });
41
+ }
42
+ async workspace() {
43
+ if (!isAbsolute(this.cwd) || await realpath(this.cwd) !== this.cwd || await this.run(['rev-parse', '--show-toplevel']) !== this.cwd) {
44
+ throw new CliError('USAGE', '--workspace must be the absolute native repository root', 2);
45
+ }
46
+ const config = await this.run(['config', '--local', '--name-only', '--list']) + '\n' +
47
+ await this.run(['config', '--worktree', '--name-only', '--list']);
48
+ if (config.split('\n').some(key => /^(?:url\.|http\.|credential\.|include\.|includeif\.|filter\.|diff\.|core\.sshcommand$|remote\..*\.(?:proxy|uploadpack|receivepack|pushurl|vcs)$)/i.test(key))) {
49
+ throw new CliError('UNSAFE_GIT_CONFIG', 'Workspace transport overrides must be removed before using origin-bound Git');
50
+ }
51
+ return this;
52
+ }
53
+ async clean() {
54
+ if (await this.run(['status', '--porcelain=v1', '--untracked-files=all']))
55
+ throw new CliError('DIRTY_WORKSPACE', 'Commit or stash workspace changes before this operation');
56
+ }
57
+ async branch(name) {
58
+ if (!name || name.startsWith('-'))
59
+ throw new CliError('USAGE', 'Select a branch name', 2);
60
+ await this.run(['check-ref-format', '--branch', name]);
61
+ return name;
62
+ }
63
+ async tip(branch = 'HEAD') { return this.run(['rev-parse', '--verify', branch + '^{commit}']); }
64
+ async snapshot() {
65
+ const status = await this.run(['status', '--porcelain=v1', '--untracked-files=all']);
66
+ return { head: await this.tip(), branch: await this.run(['symbolic-ref', '--quiet', '--short', 'HEAD'], [0, 1]),
67
+ refs: await this.run(['for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads']), status,
68
+ diff: createHash('sha256').update(await this.run(['diff', '--no-ext-diff', '--no-textconv', '--binary', 'HEAD'])).digest('hex'),
69
+ index: createHash('sha256').update(await this.run(['diff', '--no-ext-diff', '--no-textconv', '--cached', '--binary', 'HEAD'])).digest('hex'),
70
+ worktrees: await this.run(['worktree', 'list', '--porcelain']) };
71
+ }
72
+ url(repository) {
73
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || repository.split('/').some(part => part === '.' || part === '..')) {
74
+ throw new CliError('USAGE', 'Select OWNER/NAME', 2);
75
+ }
76
+ return this.origin + '/' + repository + '.git';
77
+ }
78
+ async remote(name) {
79
+ if (!/^[A-Za-z0-9_-]+$/.test(name))
80
+ throw new CliError('USAGE', 'Select a simple remote name', 2);
81
+ const urls = (await this.run(['remote', 'get-url', '--all', name])).split('\n');
82
+ const url = new URL(urls[0]);
83
+ if (urls.length !== 1 || url.origin !== this.origin || url.username || url.password || url.search || url.hash ||
84
+ !/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\.git$/.test(url.pathname)) {
85
+ throw new CliError('ORIGIN_MISMATCH', 'Selected remote must be one credential-free OWNER/NAME.git URL on the selected origin');
86
+ }
87
+ return { name, url: url.href, repository: url.pathname.slice(1, -4) };
88
+ }
89
+ }