@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/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');