@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,222 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, open, readdir, realpath, rm } from 'node:fs/promises';
3
+ import { isAbsolute, join } from 'node:path';
4
+ import { Git } from './git.js';
5
+ import { CliError } from './error.js';
6
+ import { collect, object, read, resolveCommit } from './api.js';
7
+ import { repoPath, repositoryPlan, validateRepositoryWorkflow } from './repo-workflows.js';
8
+ import { verifyPull } from './pulls.js';
9
+ import { readPrivateFile, writePrivateFile } from './private-file.js';
10
+ import { write } from './writes.js';
11
+ import { stackWorkflow } from './stacks.js';
12
+ export const usage = (message) => { throw new CliError('USAGE', message, 2); };
13
+ export const gap = (message) => { throw new CliError('CAPABILITY_GAP', message); };
14
+ export const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
15
+ export function isLocal(operation, options) {
16
+ return operation.startsWith('stack ') || ['repo clone', 'pr checkout', 'pr revert'].includes(operation) ||
17
+ ['repo create', 'repo fork'].includes(operation) && ['source', 'push', 'remote', 'clone', 'workspace'].some(key => options[key] !== undefined);
18
+ }
19
+ export function validateLocal(operation, options) {
20
+ if (process.env.GITEA_AXI_HOST_WRITE_OPERATIONS !== undefined || process.env.GITEA_AXI_CREDENTIAL_REVISION !== undefined) {
21
+ throw new CliError('READ_ONLY', 'Workspace workflows are standalone only');
22
+ }
23
+ if (operation.startsWith('stack ') && (options.repo || process.env.GH_REPO))
24
+ usage('Stack commands reject -R/--repo and GH_REPO; use the explicit local workspace');
25
+ if (typeof options.workspace !== 'string' || !isAbsolute(options.workspace))
26
+ usage('--workspace requires an explicit absolute path');
27
+ if (options.config !== undefined && !isAbsolute(String(options.config)))
28
+ usage('--config must be absolute');
29
+ }
30
+ export async function localEffect(client, identity, operation, options, git, preconditions, payload, execute, api) {
31
+ const step = { method: 'LOCAL', path: git.cwd, local: 'gitea.local.' + operation, payload };
32
+ const plan = api ? { ...api, steps: [...api.steps, step], preconditions: { api: api.preconditions, workspace: preconditions } } :
33
+ { target: git.cwd, steps: [step], reads: [], preconditions };
34
+ // Keep API verification preconditions in their original shape for the existing verifier.
35
+ if (api)
36
+ plan.preconditions = { ...object(api.preconditions), localWorkspace: preconditions };
37
+ return write(client, identity, plan, options, execute);
38
+ }
39
+ export async function localWorkflow(client, identity, operation, args, options) {
40
+ if (operation.startsWith('stack '))
41
+ return stackWorkflow(client, identity, operation.slice(6), args, options);
42
+ const workspace = await realpath(String(options.workspace));
43
+ const git = new Git(workspace, identity.origin, identity.token);
44
+ if (operation === 'repo clone') {
45
+ if (args.length !== 1)
46
+ usage('Select one OWNER/NAME to clone');
47
+ const name = args[0];
48
+ const url = git.url(name);
49
+ const destination = join(workspace, name.split('/')[1]);
50
+ if (existsSync(destination))
51
+ usage('Clone destination already exists');
52
+ const repo = object(await read(client, repoPath(name)));
53
+ if (repo.full_name !== name)
54
+ throw new CliError('INVALID_RESPONSE', 'Repository identity differs from the selected clone target');
55
+ const expected = await resolveCommit(client, repoPath(name), String(repo.default_branch));
56
+ const preconditions = { parent: workspace, contents: await readdir(workspace), repository: repo, commit: expected.sha };
57
+ return localEffect(client, identity, 'clone', options, git, preconditions, { url, destination }, async () => {
58
+ if (existsSync(destination) || !same(await readdir(workspace), preconditions.contents))
59
+ throw new CliError('CONFIRMATION_MISMATCH', 'Clone parent changed');
60
+ await git.run(['clone', '--no-checkout', '--', url, destination]);
61
+ const clone = await new Git(destination, identity.origin, identity.token).workspace();
62
+ await clone.run(['checkout', '--detach', String(expected.sha)]);
63
+ if (await clone.tip() !== expected.sha)
64
+ throw new CliError('READBACK_MISMATCH', 'Clone head differs from the previewed default commit');
65
+ return { workspace: destination, head: expected.sha, remote: await clone.remote('origin') };
66
+ });
67
+ }
68
+ if (operation === 'repo create' || operation === 'repo fork') {
69
+ if (args.length > 1)
70
+ usage('Select at most one repository');
71
+ if (operation === 'repo create' && (options.push || options.remote) && !options.source || options.push && !options.source)
72
+ usage('--push and repository creation --remote require --source');
73
+ if (options.source && options.clone)
74
+ usage('Choose --source or --clone');
75
+ const apiOptions = { ...options };
76
+ for (const key of ['source', 'push', 'remote', 'clone', 'workspace'])
77
+ delete apiOptions[key];
78
+ validateRepositoryWorkflow(operation, apiOptions);
79
+ const api = await repositoryPlan(client, operation, args[0], apiOptions);
80
+ const destinationName = String(api.workflow.expected.full_name);
81
+ const url = git.url(destinationName);
82
+ let source;
83
+ let preconditions;
84
+ const remote = String(options.remote ?? 'origin');
85
+ if (!/^[A-Za-z0-9_-]+$/.test(remote))
86
+ usage('Select a simple remote name');
87
+ const destination = join(workspace, destinationName.split('/')[1]);
88
+ if (options.source || operation === 'repo fork' && options.remote && !options.clone) {
89
+ const sourcePath = String(options.source ?? workspace);
90
+ if (!isAbsolute(sourcePath))
91
+ usage('--source requires an absolute native repository root');
92
+ source = await new Git(await realpath(sourcePath), identity.origin, identity.token).workspace();
93
+ if (source.cwd !== workspace)
94
+ usage('--source must equal the explicit --workspace');
95
+ await source.clean();
96
+ if (options.push && !await source.run(['symbolic-ref', '--quiet', '--short', 'HEAD'], [0, 1]))
97
+ usage('Publishing requires a named source branch');
98
+ if ((await source.run(['remote'])).split('\n').includes(remote))
99
+ usage('Selected remote already exists; choose another --remote');
100
+ preconditions = await source.snapshot();
101
+ }
102
+ else if (options.clone) {
103
+ if (existsSync(destination))
104
+ usage('Clone destination already exists');
105
+ preconditions = { parent: workspace, contents: await readdir(workspace) };
106
+ }
107
+ else
108
+ usage('Workspace composite requires --source or --clone');
109
+ return localEffect(client, identity, 'composite', options, git, preconditions, { source: source?.cwd, remote, url, clone: Boolean(options.clone), push: Boolean(options.push) }, async () => {
110
+ if (source) {
111
+ if (!same(await source.snapshot(), preconditions))
112
+ throw new CliError('CONFIRMATION_MISMATCH', 'Source changed after API creation; repository created, local publication stopped');
113
+ await source.run(['remote', 'add', remote, url]);
114
+ if (options.push) {
115
+ const branch = await source.run(['symbolic-ref', '--short', 'HEAD']);
116
+ await source.run(['push', '--', url, 'refs/heads/' + branch + ':refs/heads/' + branch]);
117
+ if (!(await source.run(['ls-remote', '--', url, 'refs/heads/' + branch])).startsWith(await source.tip()))
118
+ throw new CliError('READBACK_MISMATCH', 'Published head was not confirmed');
119
+ }
120
+ return { workspace, remote, published: Boolean(options.push), head: await source.tip() };
121
+ }
122
+ if (existsSync(destination))
123
+ throw new CliError('CONFIRMATION_MISMATCH', 'Clone destination appeared; API repository was created');
124
+ await git.run(['clone', '--origin', remote, '--', url, destination]);
125
+ return { workspace: destination, remote };
126
+ }, api);
127
+ }
128
+ await git.workspace();
129
+ await git.clean();
130
+ if (args.length !== 1 || !/^[1-9]\d*$/.test(args[0]))
131
+ usage('Select one positive PR number');
132
+ const remote = await git.remote('origin');
133
+ if (options.repo && options.repo !== remote.repository)
134
+ usage('--repo must match the workspace origin remote');
135
+ const base = repoPath(remote.repository);
136
+ const pull = object(await read(client, base + '/pulls/' + args[0]));
137
+ const snapshot = await git.snapshot();
138
+ if (operation === 'pr checkout') {
139
+ const head = object(pull.head);
140
+ const headRepository = object(head.repo);
141
+ const url = git.url(String(headRepository.full_name));
142
+ if (!/^[a-f0-9]{40}$/.test(String(head.sha)))
143
+ throw new CliError('INVALID_RESPONSE', 'PR head requires an exact commit SHA');
144
+ await git.branch(String(head.ref));
145
+ return localEffect(client, identity, 'checkout', options, git, { snapshot, pull, remote }, { url, ref: head.ref, sha: head.sha }, async () => {
146
+ if (!same(snapshot, await git.snapshot()))
147
+ throw new CliError('CONFIRMATION_MISMATCH', 'Workspace changed');
148
+ await git.run(['fetch', '--no-tags', '--', url, 'refs/heads/' + head.ref]);
149
+ if (await git.tip('FETCH_HEAD') !== head.sha)
150
+ throw new CliError('STALE_HEAD', 'Fetched PR branch differs from the previewed head; checkout stopped');
151
+ await git.run(['checkout', '--detach', String(head.sha)]);
152
+ return { workspace, head: await git.tip() };
153
+ });
154
+ }
155
+ if (pull.merged !== true || !/^[a-f0-9]{40}$/.test(String(pull.merge_commit_sha)))
156
+ usage('Revert requires a merged PR with an exact merge commit');
157
+ const branch = 'revert-' + args[0] + '-' + String(pull.merge_commit_sha).slice(0, 12);
158
+ const baseBranch = await git.branch(String(object(pull.base).ref));
159
+ const recordPath = join(await git.run(['rev-parse', '--path-format=absolute', '--git-common-dir']), 'gitea-axi', branch + '.json');
160
+ if ((await git.run(['for-each-ref', '--format=%(refname:short)', 'refs/heads/' + branch])) === branch) {
161
+ if (snapshot.branch !== branch)
162
+ usage('Check out the generated revert branch before previewing its publication');
163
+ if (!existsSync(recordPath))
164
+ usage('The branch has no recorded revert provenance; inspect the existing branch before publication');
165
+ const record = object(await load(recordPath));
166
+ if (record.merge !== pull.merge_commit_sha || record.head !== snapshot.head || record.repository !== remote.repository || record.origin !== identity.origin) {
167
+ throw new CliError('CONFIRMATION_MISMATCH', 'The generated revert branch or its origin-bound provenance changed');
168
+ }
169
+ const pulls = await collect(client, base + '/pulls?state=all');
170
+ if (pulls.truncated)
171
+ throw new CliError('INCOMPLETE_READ', 'Revert publication requires a complete PR collection');
172
+ if (pulls.items.some(item => object(item.head).ref === branch))
173
+ usage('The revert branch already has a PR; inspect it before repeating');
174
+ const payload = { title: 'Revert "' + String(pull.title) + '"', body: 'Reverts #' + args[0], head: branch, base: baseBranch };
175
+ const local = { method: 'LOCAL', path: workspace, local: 'gitea.local.revert' };
176
+ const plan = { target: base + '/pulls', reads: [],
177
+ preconditions: { snapshot, pull, remote, pulls: pulls.items },
178
+ steps: [{ ...local, payload: { phase: 'push', branch, sha: snapshot.head } },
179
+ { method: 'POST', path: base + '/pulls', payload }, { ...local, payload: { phase: 'verify', payload } }] };
180
+ return write(client, identity, plan, options, async (step, results) => {
181
+ if (object(step.payload).phase === 'push') {
182
+ if (!same(snapshot, await git.snapshot()))
183
+ throw new CliError('CONFIRMATION_MISMATCH', 'Revert branch changed');
184
+ await git.run(['push', '--', remote.url, 'refs/heads/' + branch + ':refs/heads/' + branch]);
185
+ return { branch, head: snapshot.head };
186
+ }
187
+ const created = object(results[1]);
188
+ const actual = object(await read(client, base + '/pulls/' + created.number));
189
+ if (actual.id !== created.id || object(actual.head).sha !== snapshot.head || object(actual.head).ref !== branch ||
190
+ object(actual.base).ref !== baseBranch || actual.title !== payload.title || actual.body !== payload.body) {
191
+ throw new CliError('READBACK_MISMATCH', 'Revert PR did not confirm its exact branch and generated commit');
192
+ }
193
+ return { pull: actual, branch, head: snapshot.head };
194
+ });
195
+ }
196
+ const preconditions = { snapshot, pull, remote };
197
+ return localEffect(client, identity, 'revert', options, git, preconditions, { branch, baseBranch, merge: pull.merge_commit_sha }, async () => {
198
+ await git.run(['fetch', '--no-tags', '--', remote.url, 'refs/heads/' + baseBranch]);
199
+ await git.run(['checkout', '-b', branch, 'FETCH_HEAD']);
200
+ const parents = (await git.run(['rev-list', '--parents', '-n', '1', String(pull.merge_commit_sha)])).split(' ');
201
+ if (parents.length !== 3)
202
+ gap('Revert currently requires a two-parent merge commit; branch retained without a revert');
203
+ await git.run(['revert', '--no-edit', '-m', '1', String(pull.merge_commit_sha)]);
204
+ await save(recordPath, { merge: pull.merge_commit_sha, head: await git.tip(), repository: remote.repository, origin: identity.origin });
205
+ return { branch, head: await git.tip(), state: 'local revert prepared',
206
+ next: 'Repeat pr revert ' + args[0] + ' with --preview to bind publication of the generated commit and creation of its revert PR' };
207
+ });
208
+ }
209
+ export async function lock(directory) {
210
+ await mkdir(directory, { recursive: true, mode: 0o700 });
211
+ const path = join(directory, 'lock');
212
+ const file = await open(path, 'wx', 0o600).catch(() => { throw new CliError('STACK_LOCKED', 'Stack mutation is locked; inspect the owning process before manual recovery'); });
213
+ return async () => { await file.close(); await rm(path); };
214
+ }
215
+ export async function verifyPlannedPull(client, plan, result) {
216
+ const observations = [];
217
+ for (const path of plan.reads)
218
+ observations.push({ path, data: await read(client, path) });
219
+ await verifyPull(client, plan, [result], observations);
220
+ }
221
+ export async function save(path, value) { await writePrivateFile(path, JSON.stringify(value)); }
222
+ export async function load(path) { return JSON.parse(await readPrivateFile(path, true, 1_000_000)); }
package/dist/main.js CHANGED
@@ -4,34 +4,83 @@ import { encode } from '@toon-format/toon';
4
4
  import { commands } from './registry.js';
5
5
  import { CliError } from './error.js';
6
6
  import { readRepository } from './repository.js';
7
- import { configPath, credentials, profileList, redact } from './credentials.js';
7
+ import { configPath, credentials, profileList, redact, registerSecret } from './credentials.js';
8
8
  import { Client } from './transport.js';
9
9
  import { collect, object, rawPath, rawRead, read } from './api.js';
10
10
  import { boundedList, issueFields, listIssues, listOptions, positive, prFields, repository, selectedFields, view, watchChecks } from './reads.js';
11
+ import { tasks, validateTasks } from './ci.js';
12
+ import { search, validateSearch } from './search.js';
13
+ import { issueLabelPlan, rawWritePlan, reconcile, write } from './writes.js';
14
+ import { readPrivateFile } from './private-file.js';
15
+ import { pullPlan, validatePull } from './pulls.js';
16
+ import { repositoryPlan, validateRepositoryWorkflow } from './repo-workflows.js';
17
+ import { releaseListFields, releasePlan, releaseRead, validateRelease } from './releases.js';
18
+ import { actionValues, valuePlan } from './action-values.js';
19
+ import { assetPath, attachToPlan, download, uploadPlan } from './attachments.js';
20
+ import { isLocal, localWorkflow, validateLocal } from './local-workflows.js';
21
+ import { capabilityOutput, commandGap } from './catalogue.js';
22
+ async function input(path) {
23
+ if (path !== '-')
24
+ return readPrivateFile(path, true, 1_000_000);
25
+ process.stdin.setEncoding('utf8');
26
+ let body = '';
27
+ for await (const chunk of process.stdin) {
28
+ body += String(chunk);
29
+ if (Buffer.byteLength(body) > 1_000_000)
30
+ throw new CliError('USAGE', 'Input exceeds 1000000 bytes', 2);
31
+ }
32
+ return body;
33
+ }
11
34
  export async function main(args) {
12
- let operation = 'dashboard';
35
+ let operation = args[0] && !args[0].startsWith('-') ? args[0] : 'dashboard';
13
36
  try {
37
+ for (const arg of args) {
38
+ const match = /^(?:--(?:raw-)?field=)?data=([\s\S]*)$/.exec(arg);
39
+ if (match)
40
+ registerSecret(match[1]);
41
+ }
14
42
  if (args.some(arg => /^--(?:token|api-key|password|authorization)(?:=|$)/i.test(arg))) {
15
43
  throw new CliError('CONFIG_INVALID', 'token and other secret arguments are not accepted');
16
44
  }
17
- const path = args.length === 0 || args[0]?.startsWith('--') ? ['dashboard'] : args.slice(0, ['api', 'doctor', 'dashboard'].includes(args[0]) ? 1 : 2);
18
- const command = commands.find(command => command.path.join(' ') === path.join(' '));
19
- if (!command)
20
- throw new CliError('USAGE', 'Unknown command; use gitea-axi --help', 2);
45
+ const command = args.length === 0 || args[0]?.startsWith('--') ? commands.find(command => command.path[0] === 'dashboard') :
46
+ [...commands].sort((a, b) => b.path.length - a.path.length).find(command => command.path.every((part, index) => args[index] === part));
47
+ if (!command) {
48
+ const children = commands.filter(command => command.path[0] === args[0]);
49
+ if (children.length && args.length === 2 && args[1] === '--help') {
50
+ console.log(encode({ command: 'gitea-axi ' + operation, commands: children.map(command => 'gitea-axi ' + command.path.join(' ')) }));
51
+ return;
52
+ }
53
+ throw new CliError('USAGE', children.length ? 'Choose a command: ' + children.map(command => 'gitea-axi ' + command.path.join(' ')).join(', ') : 'Unknown command; use gitea-axi --help', 2);
54
+ }
55
+ const path = command.path;
21
56
  operation = command.path.join(' ');
22
57
  const rest = args[0]?.startsWith('--') || !args.length ? args : args.slice(path.length);
23
58
  let parsed;
24
59
  try {
60
+ const options = Object.fromEntries(Object.entries(command.flags).map(([name, flag]) => [name, { type: flag.type,
61
+ ...(flag.multiple ? { multiple: true } : {}), ...(flag.short ? { short: flag.short } : {}) }]));
62
+ const tokens = parseArgs({ args: rest, allowPositionals: true, strict: false, tokens: true, options }).tokens;
63
+ const normalized = tokens.map(token => {
64
+ if (token.kind === 'positional')
65
+ return token.value;
66
+ if (token.kind === 'option-terminator')
67
+ return '--';
68
+ const boolean = command.flags[token.name]?.type === 'boolean' && /^(true|false)$/.test(token.value ?? '');
69
+ return '--' + token.name + (token.value !== undefined && !boolean ? '=' + token.value : '');
70
+ });
25
71
  parsed = parseArgs({
26
- args: rest, allowPositionals: true, strict: true, tokens: true,
27
- options: Object.fromEntries(Object.entries(command.flags).map(([name, flag]) => [name, { type: flag.type }])),
72
+ args: normalized, allowPositionals: true, strict: true, tokens: true, options,
28
73
  });
74
+ for (const token of tokens) {
75
+ if (token.kind === 'option' && command.flags[token.name]?.type === 'boolean' && /^(true|false)$/.test(token.value ?? ''))
76
+ parsed.values[token.name] = token.value === 'true';
77
+ }
29
78
  }
30
79
  catch {
31
80
  throw new CliError('USAGE', 'Invalid arguments; valid flags: ' + Object.keys(command.flags).map(f => '--' + f).join(', '), 2);
32
81
  }
33
82
  const { values, positionals } = parsed;
34
- const names = parsed.tokens.filter(token => token.kind === 'option').map(token => token.name);
83
+ const names = parsed.tokens.flatMap(token => token.kind === 'option' && !command.flags[token.name]?.multiple ? [token.name] : []);
35
84
  if (new Set(names).size !== names.length) {
36
85
  throw new CliError('USAGE', 'Choose each flag only once; --repo requires exactly one selector', 2);
37
86
  }
@@ -44,15 +93,106 @@ export async function main(args) {
44
93
  return;
45
94
  }
46
95
  const options = values;
47
- const takesNumber = ['issue view', 'pr view', 'pr checks', 'pr diff'].includes(operation);
96
+ if (operation === 'capabilities') {
97
+ if (positionals.length > 1)
98
+ throw new CliError('USAGE', 'Use capabilities [C001-C124]', 2);
99
+ console.log(encode(capabilityOutput(commands, prFields, positionals[0])));
100
+ return;
101
+ }
102
+ if (command.gap)
103
+ throw new CliError('CAPABILITY_GAP', command.gap);
104
+ for (const name of Object.keys(values)) {
105
+ if (command.flags[name]?.gap)
106
+ throw new CliError('CAPABILITY_GAP', command.flags[name].gap);
107
+ }
108
+ if (operation === 'api' && (options.method === 'HEAD' || positionals[0] === 'HEAD'))
109
+ throw new CliError('CAPABILITY_GAP', commandGap('api HEAD'));
110
+ if ((process.env.GITEA_AXI_PROFILE === 'artium' || process.env.GITEA_AXI_CREDENTIAL_REVISION !== undefined) &&
111
+ ['origin', 'profile', 'config', 'hostname', 'allow-insecure-http'].some(name => options[name] !== undefined)) {
112
+ throw new CliError('READ_ONLY', 'Hosted calls refuse credential and configuration overrides');
113
+ }
114
+ if (isLocal(operation, options)) {
115
+ validateLocal(operation, options);
116
+ const identity = await credentials(typeof options.origin === 'string' ? options.origin : undefined, Boolean(options['allow-insecure-http']), {
117
+ ...(typeof options.config === 'string' ? { config: options.config } : {}), ...(typeof options.profile === 'string' ? { profile: options.profile } : {}),
118
+ ...(typeof options.hostname === 'string' ? { hostname: options.hostname } : {}),
119
+ });
120
+ const output = await localWorkflow(new Client(identity.origin, identity.token, 300_000), identity, operation, positionals, options);
121
+ const rendered = encode(redact(output));
122
+ if (rendered.length > 100_000)
123
+ throw new CliError('OUTPUT_LIMIT', 'Rendered output exceeds 100000 characters');
124
+ console.log(rendered);
125
+ return;
126
+ }
127
+ const valuesCommand = operation.startsWith('secret ') || operation.startsWith('variable ');
128
+ const secretCommand = operation.startsWith('secret ');
129
+ let protectedValue;
130
+ if (valuesCommand) {
131
+ if (options.org && options.repo)
132
+ throw new CliError('USAGE', 'Choose --org or --repo', 2);
133
+ if (options.org && (!/^[A-Za-z0-9_.-]+$/.test(String(options.org)) || ['.', '..'].includes(String(options.org))))
134
+ throw new CliError('USAGE', 'Select an organization name', 2);
135
+ if (operation.endsWith(' set')) {
136
+ if (options.input !== undefined && options.body !== undefined || options.input === undefined && options.body === undefined)
137
+ throw new CliError('USAGE', 'Choose exactly one value input', 2);
138
+ protectedValue = options.input !== undefined ? await input(String(options.input)) : String(options.body);
139
+ if (secretCommand)
140
+ registerSecret(protectedValue);
141
+ }
142
+ }
143
+ const repositoryMutation = operation.startsWith('repo ') && command.effect === 'write';
144
+ const releasing = operation.startsWith('release ');
145
+ if (repositoryMutation)
146
+ validateRepositoryWorkflow(operation, options);
147
+ if (releasing) {
148
+ validateRelease(options);
149
+ if (options.notes !== undefined)
150
+ options.body = options.notes;
151
+ if (options['notes-file'] !== undefined)
152
+ options['body-file'] = options['notes-file'];
153
+ }
154
+ const transferring = /^(?:issue|pr|release) (?:upload|download)$/.test(operation);
155
+ if (operation.startsWith('pr ') && command.effect === 'write' && !transferring)
156
+ validatePull(operation, options);
157
+ if (operation.startsWith('workflow ') || operation.startsWith('run ') && operation !== 'run list') {
158
+ throw new CliError('CAPABILITY_GAP', operation + ' is pending: the 1.23.4 contract has task summaries only; 1.24.0 candidates require commissioning. Mutating controls also await the shared write gate');
159
+ }
160
+ const searching = operation.startsWith('search ');
161
+ if (searching) {
162
+ if (positionals.length !== 1)
163
+ throw new CliError('USAGE', 'Select exactly one quoted search query', 2);
164
+ validateSearch(path[1], positionals[0], options);
165
+ }
166
+ if (options['body-file'] !== undefined) {
167
+ if (options.body !== undefined)
168
+ throw new CliError('USAGE', 'Choose --body or --body-file', 2);
169
+ options.body = await input(String(options['body-file']));
170
+ }
171
+ const takesNumber = ['issue view', 'pr view', 'pr checks', 'pr diff', 'issue download', 'pr download'].includes(operation) ||
172
+ (command.effect === 'write' && (operation.startsWith('issue ') || operation.startsWith('pr ')) && !operation.endsWith(' create'));
48
173
  if (takesNumber && (positionals.length !== 1 || !/^[1-9]\d*$/.test(positionals[0])))
49
174
  throw new CliError('USAGE', 'Select exactly one positive item number', 2);
50
- if (!takesNumber && operation !== 'api' && operation !== 'org view' && positionals.length)
175
+ const takesLabel = operation === 'label edit' || operation === 'label delete';
176
+ const takesValue = valuesCommand && !operation.endsWith(' list');
177
+ if (takesValue && (positionals.length !== 1 || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(positionals[0])))
178
+ throw new CliError('USAGE', 'Select one secret or variable name using letters, digits and underscores', 2);
179
+ if (takesLabel && positionals.length !== 1)
180
+ throw new CliError('USAGE', 'Select one exact label name or ID', 2);
181
+ const takesTag = releasing && operation !== 'release list';
182
+ if (takesTag && (positionals.length !== 1 || !positionals[0]))
183
+ throw new CliError('USAGE', 'Select exactly one release tag', 2);
184
+ if (repositoryMutation && positionals.length > 1)
185
+ throw new CliError('USAGE', 'Select at most one repository', 2);
186
+ if (!takesNumber && !takesLabel && !takesTag && !takesValue && !repositoryMutation && !searching && operation !== 'write reconcile' && operation !== 'api' && operation !== 'org view' && positionals.length)
51
187
  throw new CliError('USAGE', 'Unexpected positional argument', 2);
188
+ if (operation === 'write reconcile' && positionals.length !== 1)
189
+ throw new CliError('USAGE', 'Select one confirmation ID', 2);
52
190
  if (operation === 'org view' && (positionals.length !== 1 || !/^[A-Za-z0-9_.-]+$/.test(positionals[0])))
53
191
  throw new CliError('USAGE', 'Select one organization name', 2);
54
192
  if (operation.endsWith('list'))
55
193
  listOptions(options);
194
+ if (operation === 'run list')
195
+ validateTasks(options);
56
196
  if (operation === 'issue view' || operation === 'issue list')
57
197
  selectedFields(options, issueFields);
58
198
  if (operation === 'pr view')
@@ -68,12 +208,55 @@ export async function main(args) {
68
208
  positive(options.interval, 5, 60);
69
209
  }
70
210
  let apiPath;
211
+ let method = 'GET';
212
+ let payload;
71
213
  if (operation === 'api') {
72
- if (positionals.length === 2 && positionals[0] !== 'GET')
73
- throw new CliError('READ_ONLY', 'Only GET metadata reads are available');
214
+ if (options.method && positionals.length === 2)
215
+ throw new CliError('USAGE', 'Choose -X or a positional method once', 2);
216
+ method = positionals.length === 2 ? positionals[0] : String(options.method ?? 'GET');
217
+ if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method))
218
+ throw new CliError('USAGE', 'Use GET, POST, PUT, PATCH or DELETE', 2);
74
219
  if (positionals.length < 1 || positionals.length > 2)
75
220
  throw new CliError('USAGE', 'Use gitea-axi api [GET] <relative-path>', 2);
76
- apiPath = rawPath(positionals.at(-1));
221
+ apiPath = rawPath(positionals.at(-1), method);
222
+ const fields = parsed.tokens.filter(token => token.kind === 'option' && ['field', 'raw-field'].includes(token.name));
223
+ if (fields.length) {
224
+ if (options.input || method === 'GET')
225
+ throw new CliError('USAGE', 'Request fields require a write method and cannot combine with --input', 2);
226
+ const body = {};
227
+ for (const token of fields) {
228
+ if (token.kind !== 'option')
229
+ continue;
230
+ const match = /^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/.exec(token.value ?? '');
231
+ if (!match || ['__proto__', 'prototype', 'constructor'].includes(match[1]) || Object.hasOwn(body, match[1]))
232
+ throw new CliError('USAGE', 'Choose unique key=value request fields', 2);
233
+ if (/secret|token|password|authorization|api.?key/i.test(match[1]) || /\/secrets\//.test(apiPath))
234
+ throw new CliError('CONFIG_INVALID', 'Secret fields require protected --input');
235
+ const value = match[2];
236
+ body[match[1]] = token.name === 'field' && /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)$/.test(value) ? JSON.parse(value) : value;
237
+ }
238
+ if (Buffer.byteLength(JSON.stringify(body)) > 1_000_000)
239
+ throw new CliError('USAGE', 'Request exceeds 1000000 bytes', 2);
240
+ payload = body;
241
+ }
242
+ if (method !== 'GET' && (!options['allow-write'] || process.env.GITEA_AXI_READ_ONLY && process.env.GITEA_AXI_READ_ONLY !== 'false'))
243
+ throw new CliError('READ_ONLY', 'Writes require an exact operation grant and a writable policy');
244
+ if (options.input) {
245
+ if (method === 'GET')
246
+ throw new CliError('USAGE', '--input requires a write method', 2);
247
+ const body = await input(String(options.input));
248
+ registerSecret(body);
249
+ if (Buffer.byteLength(body) > 1_000_000)
250
+ throw new CliError('USAGE', 'Request exceeds 1000000 bytes', 2);
251
+ try {
252
+ payload = JSON.parse(body);
253
+ }
254
+ catch {
255
+ throw new CliError('USAGE', 'Request must be valid JSON', 2);
256
+ }
257
+ if (/\/actions\/secrets\//.test(apiPath) && typeof object(payload).data === 'string')
258
+ registerSecret(String(object(payload).data));
259
+ }
77
260
  }
78
261
  if (options.config && (typeof options.config !== 'string' || !isAbsolute(options.config)))
79
262
  throw new CliError('USAGE', '--config must be absolute', 2);
@@ -87,17 +270,33 @@ export async function main(args) {
87
270
  }
88
271
  const identity = await credentials(typeof values.origin === 'string' ? values.origin : undefined, Boolean(values['allow-insecure-http']), {
89
272
  ...(typeof options.config === 'string' ? { config: options.config } : {}), ...(typeof options.profile === 'string' ? { profile: options.profile } : {}),
273
+ ...(typeof options.hostname === 'string' ? { hostname: options.hostname } : {}),
90
274
  });
91
275
  const client = new Client(identity.origin, identity.token, operation === 'pr checks' && options.watch ? positive(options.timeout, 30, 3600) * 1000 : 30_000);
92
276
  let output;
93
277
  if (operation === 'auth status')
94
278
  output = { profile: identity.profile ?? 'anonymous', origin: identity.origin, source: identity.source ?? 'anonymous', authenticated: Boolean(identity.token) };
95
- else if (operation === 'doctor')
96
- output = { origin: identity.origin, identity: await read(client, '/api/v1/user') };
279
+ else if (operation === 'doctor') {
280
+ const account = object(await read(client, '/api/v1/user'));
281
+ output = { origin: identity.origin, identity: { id: account.id, login: account.login } };
282
+ }
283
+ else if (searching)
284
+ output = await search(client, path[1], positionals[0], options);
285
+ else if (operation === 'write reconcile')
286
+ output = await reconcile(client, positionals[0]);
97
287
  else if (operation === 'api')
98
- output = await rawRead(client, apiPath);
288
+ output = method === 'GET' ? await rawRead(client, apiPath) :
289
+ await write(client, identity, await rawWritePlan(client, method, apiPath, payload), options);
99
290
  else if (operation === 'org view')
100
291
  output = { organization: await read(client, '/api/v1/orgs/' + encodeURIComponent(positionals[0])) };
292
+ else if (valuesCommand) {
293
+ const scope = options.org ? '/api/v1/orgs/' + encodeURIComponent(String(options.org)) : '/api/v1/repos/' + repository(typeof options.repo === 'string' ? options.repo : undefined, identity.origin).split('/').map(encodeURIComponent).join('/');
294
+ const path = scope + '/actions/' + (secretCommand ? 'secrets' : 'variables');
295
+ output = operation.endsWith(' list') ? boundedList({ items: await actionValues(client, path), total: null, next: null, truncated: false }, positive(options.limit, 30, 1000), secretCommand ? ['name'] : ['name', 'data']) :
296
+ await write(client, identity, await valuePlan(client, path, positionals[0], operation.split(' ')[1], protectedValue), options);
297
+ }
298
+ else if (repositoryMutation)
299
+ output = await write(client, identity, await repositoryPlan(client, operation, positionals[0], options), options);
101
300
  else if (operation === 'repo list') {
102
301
  if (options.fields)
103
302
  throw new CliError('USAGE', 'repo list supports its compact identity fields only', 2);
@@ -105,16 +304,35 @@ export async function main(args) {
105
304
  output = boundedList(await collect(client, path, options.org ? undefined : 'data'), positive(options.limit, 30, 1000), ['id', 'full_name', 'default_branch']);
106
305
  }
107
306
  else {
108
- const repo = repository(values.repo, identity.origin);
307
+ if (command.effect === 'write' && (!options['allow-write'] || process.env.GITEA_AXI_READ_ONLY && process.env.GITEA_AXI_READ_ONLY !== 'false'))
308
+ throw new CliError('READ_ONLY', 'Writes require an exact operation grant and a writable policy');
309
+ const repo = repository(typeof values.repo === 'string' ? values.repo : undefined, identity.origin);
109
310
  const base = '/api/v1/repos/' + repo.split('/').map(encodeURIComponent).join('/');
110
- if (operation === 'repo view')
311
+ if (transferring) {
312
+ const path = await assetPath(client, base, operation.split(' ')[0], positionals[0]);
313
+ output = operation.endsWith(' upload') ? await write(client, identity, await uploadPlan(client, path, options), options) : await download(client, path, options);
314
+ }
315
+ else if (releasing)
316
+ output = command.effect === 'write' ? await write(client, identity, await attachToPlan(client, await releasePlan(client, operation, base, positionals[0], options), operation, base, positionals[0], options), options) : await releaseRead(client, operation, base, positionals[0], options);
317
+ else if (command.effect === 'write')
318
+ output = await write(client, identity, await attachToPlan(client, await (operation.startsWith('pr ') ? pullPlan : issueLabelPlan)(client, operation, base, positionals[0], options), operation, base, positionals[0], options), options);
319
+ else if (operation === 'repo view')
111
320
  output = await readRepository(identity.origin, repo, identity.token);
112
321
  else if (operation === 'dashboard') {
113
322
  const sections = {};
114
- for (const [name, path, envelope] of [['repository', base], ['issues', base + '/issues?state=open&type=issues'], ['pulls', base + '/pulls?state=open'], ['labels', base + '/labels'], ['releases', base + '/releases'], ['runs', base + '/actions/tasks', 'workflow_runs']]) {
323
+ for (const [name, path, listCommand] of [['repository', base], ['issues', base + '/issues?state=open&type=issues', 'issue'], ['pulls', base + '/pulls?state=open', 'pr'], ['labels', base + '/labels', 'label'], ['releases', base + '/releases', 'release'], ['runs', base + '/actions/tasks', 'run']]) {
115
324
  try {
116
- sections[name] = name === 'repository' ? { status: 'ok', data: await read(client, path) } :
117
- { status: 'ok', ...boundedList(await collect(client, path, envelope), 5, name === 'labels' ? ['id', 'name', 'color'] : ['id', 'number', 'title', 'state']) };
325
+ if (name === 'repository') {
326
+ const row = object(await read(client, path));
327
+ sections[name] = { status: 'ok', data: Object.fromEntries(['id', 'full_name', 'private', 'default_branch', 'open_issues_count', 'open_pr_counter', 'updated_at'].map(key => [key, row[key] ?? null])) };
328
+ }
329
+ else {
330
+ const list = name === 'runs' ? await tasks(client, base, { limit: '5' }) : boundedList(await collect(client, path), 5, name === 'labels' ? ['id', 'name', 'color'] : name === 'releases' ? releaseListFields : ['id', 'number', 'title', 'state']);
331
+ sections[name] = { status: 'ok', ...list, ...(list.truncated ? { help: [
332
+ `Run gitea-axi ${listCommand} list --repo ${repo} --limit 1000 to see more items.`,
333
+ ...(list.help ?? []).filter(hint => !hint.startsWith('Raise --limit')),
334
+ ] } : {}) };
335
+ }
118
336
  }
119
337
  catch (error) {
120
338
  if (!(error instanceof CliError))
@@ -129,8 +347,10 @@ export async function main(args) {
129
347
  output = await listIssues(client, base, operation === 'pr list' ? 'pr' : 'issue', options);
130
348
  else if (operation === 'issue view' || operation === 'pr view')
131
349
  output = await view(client, base, operation === 'pr view' ? 'pr' : 'issue', positionals[0], options);
132
- else if (['label list', 'run list', 'release list'].includes(operation))
133
- output = boundedList(await collect(client, base + (operation === 'label list' ? '/labels' : operation === 'release list' ? '/releases' : '/actions/tasks'), operation === 'run list' ? 'workflow_runs' : undefined), positive(options.limit, 30, 1000), operation === 'label list' ? ['id', 'name', 'color'] : ['id', 'name', 'status', 'conclusion']);
350
+ else if (operation === 'run list')
351
+ output = await tasks(client, base, options);
352
+ else if (operation === 'label list')
353
+ output = boundedList(await collect(client, base + '/labels'), positive(options.limit, 30, 1000), ['id', 'name', 'color']);
134
354
  else if (operation === 'pr checks') {
135
355
  const result = await watchChecks(client, base, positionals[0], options);
136
356
  process.exitCode = result.exitCode;
@@ -150,7 +370,7 @@ export async function main(args) {
150
370
  }
151
371
  catch (error) {
152
372
  const failure = error instanceof CliError ? error : new CliError('INTERNAL_ERROR', 'Unable to read the repository');
153
- console.log(encode(redact({ error: { code: failure.code, operation, reason: failure.message }, help: 'gitea-axi ' + operation + ' --help' })));
373
+ console.log(encode(redact({ error: { code: failure.code, operation, reason: failure.message }, help: commands.some(command => command.path.join(' ') === operation || command.path[0] === operation) ? 'gitea-axi ' + operation + ' --help' : 'gitea-axi --help' })));
154
374
  process.exitCode = failure.exitCode;
155
375
  }
156
376
  }
@@ -3,7 +3,7 @@ import { constants } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { CliError } from './error.js';
6
- export async function readPrivateFile(path, secret = false) {
6
+ export async function readPrivateFile(path, secret = false, maxBytes) {
7
7
  if (secret && process.platform === 'win32')
8
8
  throw new CliError('CONFIG_INVALID', 'File SecretRef protection cannot be verified on Windows; use env SecretRefs');
9
9
  const info = await lstat(path).catch(() => { throw new CliError('CONFIG_INSECURE', 'Private file is unavailable'); });
@@ -14,6 +14,8 @@ export async function readPrivateFile(path, secret = false) {
14
14
  });
15
15
  try {
16
16
  const stat = await file.stat();
17
+ if (maxBytes !== undefined && stat.size > maxBytes)
18
+ throw new CliError('USAGE', 'Input exceeds ' + maxBytes + ' bytes', 2);
17
19
  if (!stat.isFile() || (process.platform !== 'win32' &&
18
20
  (stat.uid !== process.getuid() || (stat.mode & (secret ? 0o077 : 0o022)) !== 0))) {
19
21
  throw new CliError('CONFIG_INSECURE', 'Private file must be owned and protected; use chmod 600');