@knowttl/gitea-axi 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js ADDED
@@ -0,0 +1,376 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { isAbsolute } from 'node:path';
3
+ import { encode } from '@toon-format/toon';
4
+ import { commands } from './registry.js';
5
+ import { CliError } from './error.js';
6
+ import { readRepository } from './repository.js';
7
+ import { configPath, credentials, profileList, redact, registerSecret } from './credentials.js';
8
+ import { Client } from './transport.js';
9
+ import { collect, object, rawPath, rawRead, read } from './api.js';
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
+ }
34
+ export async function main(args) {
35
+ let operation = args[0] && !args[0].startsWith('-') ? args[0] : 'dashboard';
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
+ }
42
+ if (args.some(arg => /^--(?:token|api-key|password|authorization)(?:=|$)/i.test(arg))) {
43
+ throw new CliError('CONFIG_INVALID', 'token and other secret arguments are not accepted');
44
+ }
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;
56
+ operation = command.path.join(' ');
57
+ const rest = args[0]?.startsWith('--') || !args.length ? args : args.slice(path.length);
58
+ let parsed;
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
+ });
71
+ parsed = parseArgs({
72
+ args: normalized, allowPositionals: true, strict: true, tokens: true, options,
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
+ }
78
+ }
79
+ catch {
80
+ throw new CliError('USAGE', 'Invalid arguments; valid flags: ' + Object.keys(command.flags).map(f => '--' + f).join(', '), 2);
81
+ }
82
+ const { values, positionals } = parsed;
83
+ const names = parsed.tokens.flatMap(token => token.kind === 'option' && !command.flags[token.name]?.multiple ? [token.name] : []);
84
+ if (new Set(names).size !== names.length) {
85
+ throw new CliError('USAGE', 'Choose each flag only once; --repo requires exactly one selector', 2);
86
+ }
87
+ if (values.help) {
88
+ console.log(encode({ bin: process.argv[1], description: command.description, command: 'gitea-axi ' + operation,
89
+ flags: Object.entries(command.flags).map(([name, flag]) => ({ flag: '--' + name, ...flag })),
90
+ ...(operation === 'dashboard' ? { commands: commands.map(command => command.path.join(' ')) } : {}),
91
+ examples: operation === 'api' ? ['gitea-axi api GET repos/OWNER/NAME', 'gitea-axi api version'] :
92
+ [`gitea-axi ${operation} --help`, `gitea-axi ${operation} --profile <name>`] }));
93
+ return;
94
+ }
95
+ const options = values;
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'));
173
+ if (takesNumber && (positionals.length !== 1 || !/^[1-9]\d*$/.test(positionals[0])))
174
+ throw new CliError('USAGE', 'Select exactly one positive item number', 2);
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)
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);
190
+ if (operation === 'org view' && (positionals.length !== 1 || !/^[A-Za-z0-9_.-]+$/.test(positionals[0])))
191
+ throw new CliError('USAGE', 'Select one organization name', 2);
192
+ if (operation.endsWith('list'))
193
+ listOptions(options);
194
+ if (operation === 'run list')
195
+ validateTasks(options);
196
+ if (operation === 'issue view' || operation === 'issue list')
197
+ selectedFields(options, issueFields);
198
+ if (operation === 'pr view')
199
+ selectedFields(options, prFields);
200
+ if (operation === 'pr list')
201
+ selectedFields(options, issueFields.concat(['isDraft', 'headRefName', 'baseRefName']));
202
+ if (operation === 'pr checks') {
203
+ if ((options.interval || options.timeout) && !options.watch)
204
+ throw new CliError('USAGE', '--timeout and --interval require --watch', 2);
205
+ if (options.watch && !options.timeout)
206
+ throw new CliError('USAGE', '--watch requires --timeout seconds', 2);
207
+ positive(options.timeout, 30, 3600);
208
+ positive(options.interval, 5, 60);
209
+ }
210
+ let apiPath;
211
+ let method = 'GET';
212
+ let payload;
213
+ if (operation === 'api') {
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);
219
+ if (positionals.length < 1 || positionals.length > 2)
220
+ throw new CliError('USAGE', 'Use gitea-axi api [GET] <relative-path>', 2);
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
+ }
260
+ }
261
+ if (options.config && (typeof options.config !== 'string' || !isAbsolute(options.config)))
262
+ throw new CliError('USAGE', '--config must be absolute', 2);
263
+ if (operation === 'config path') {
264
+ console.log(encode({ path: configPath(options.config) }));
265
+ return;
266
+ }
267
+ if (operation === 'profile list') {
268
+ console.log(encode(await profileList(options.config)));
269
+ return;
270
+ }
271
+ const identity = await credentials(typeof values.origin === 'string' ? values.origin : undefined, Boolean(values['allow-insecure-http']), {
272
+ ...(typeof options.config === 'string' ? { config: options.config } : {}), ...(typeof options.profile === 'string' ? { profile: options.profile } : {}),
273
+ ...(typeof options.hostname === 'string' ? { hostname: options.hostname } : {}),
274
+ });
275
+ const client = new Client(identity.origin, identity.token, operation === 'pr checks' && options.watch ? positive(options.timeout, 30, 3600) * 1000 : 30_000);
276
+ let output;
277
+ if (operation === 'auth status')
278
+ output = { profile: identity.profile ?? 'anonymous', origin: identity.origin, source: identity.source ?? 'anonymous', authenticated: Boolean(identity.token) };
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]);
287
+ else if (operation === 'api')
288
+ output = method === 'GET' ? await rawRead(client, apiPath) :
289
+ await write(client, identity, await rawWritePlan(client, method, apiPath, payload), options);
290
+ else if (operation === 'org view')
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);
300
+ else if (operation === 'repo list') {
301
+ if (options.fields)
302
+ throw new CliError('USAGE', 'repo list supports its compact identity fields only', 2);
303
+ const path = options.org ? '/api/v1/orgs/' + encodeURIComponent(String(options.org)) + '/repos' : '/api/v1/repos/search';
304
+ output = boundedList(await collect(client, path, options.org ? undefined : 'data'), positive(options.limit, 30, 1000), ['id', 'full_name', 'default_branch']);
305
+ }
306
+ else {
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);
310
+ const base = '/api/v1/repos/' + repo.split('/').map(encodeURIComponent).join('/');
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')
320
+ output = await readRepository(identity.origin, repo, identity.token);
321
+ else if (operation === 'dashboard') {
322
+ const sections = {};
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']]) {
324
+ try {
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
+ }
336
+ }
337
+ catch (error) {
338
+ if (!(error instanceof CliError))
339
+ throw error;
340
+ sections[name] = { status: 'error', code: error.code, reason: error.message };
341
+ }
342
+ }
343
+ output = { bin: process.argv[1], description: 'Gitea repository reads', repository: repo, partial: Object.values(sections).some(section => object(section).status === 'error'), sections,
344
+ help: [`gitea-axi issue list --repo ${repo}`, `gitea-axi pr list --repo ${repo}`, 'gitea-axi --help'] };
345
+ }
346
+ else if (operation === 'issue list' || operation === 'pr list')
347
+ output = await listIssues(client, base, operation === 'pr list' ? 'pr' : 'issue', options);
348
+ else if (operation === 'issue view' || operation === 'pr view')
349
+ output = await view(client, base, operation === 'pr view' ? 'pr' : 'issue', positionals[0], options);
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']);
354
+ else if (operation === 'pr checks') {
355
+ const result = await watchChecks(client, base, positionals[0], options);
356
+ process.exitCode = result.exitCode;
357
+ output = result;
358
+ }
359
+ else if (operation === 'pr diff') {
360
+ const response = await client.request(base + '/pulls/' + positionals[0] + '.diff', true);
361
+ const text = String(response.data);
362
+ const ceiling = options.full ? 100_000 : 10_000;
363
+ output = { diff: text.slice(0, ceiling), chars: text.length, truncated: text.length > ceiling, ...(text.length > ceiling ? { help: 'Use --full within the 100000 character diff ceiling' } : {}) };
364
+ }
365
+ }
366
+ const rendered = encode(redact(output));
367
+ if (rendered.length > 100_000)
368
+ throw new CliError('OUTPUT_LIMIT', 'Rendered output exceeds 100000 characters; select fewer fields or a lower --limit');
369
+ console.log(rendered);
370
+ }
371
+ catch (error) {
372
+ const failure = error instanceof CliError ? error : new CliError('INTERNAL_ERROR', 'Unable to read the repository');
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' })));
374
+ process.exitCode = failure.exitCode;
375
+ }
376
+ }
@@ -0,0 +1,51 @@
1
+ import { open, mkdir, chmod, rename, rm, lstat } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { CliError } from './error.js';
6
+ export async function readPrivateFile(path, secret = false, maxBytes) {
7
+ if (secret && process.platform === 'win32')
8
+ throw new CliError('CONFIG_INVALID', 'File SecretRef protection cannot be verified on Windows; use env SecretRefs');
9
+ const info = await lstat(path).catch(() => { throw new CliError('CONFIG_INSECURE', 'Private file is unavailable'); });
10
+ if (!info.isFile() || info.isSymbolicLink())
11
+ throw new CliError('CONFIG_INSECURE', 'Private file must be a regular owned file; use chmod 600');
12
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK).catch(() => {
13
+ throw new CliError('CONFIG_INSECURE', 'Private file must be a regular owned file; use chmod 600');
14
+ });
15
+ try {
16
+ const stat = await file.stat();
17
+ if (maxBytes !== undefined && stat.size > maxBytes)
18
+ throw new CliError('USAGE', 'Input exceeds ' + maxBytes + ' bytes', 2);
19
+ if (!stat.isFile() || (process.platform !== 'win32' &&
20
+ (stat.uid !== process.getuid() || (stat.mode & (secret ? 0o077 : 0o022)) !== 0))) {
21
+ throw new CliError('CONFIG_INSECURE', 'Private file must be owned and protected; use chmod 600');
22
+ }
23
+ return await file.readFile('utf8');
24
+ }
25
+ finally {
26
+ await file.close();
27
+ }
28
+ }
29
+ export async function writePrivateFile(path, content) {
30
+ if (process.platform === 'win32')
31
+ throw new CliError('CONFIG_INSECURE', 'Native Windows ACL writes are unavailable');
32
+ const directory = dirname(path);
33
+ await mkdir(directory, { recursive: true, mode: 0o700 });
34
+ const info = await lstat(directory);
35
+ if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== process.getuid()) {
36
+ throw new CliError('CONFIG_INSECURE', 'Private directory must be owned and cannot be a symlink');
37
+ }
38
+ await chmod(directory, 0o700);
39
+ const temporary = join(directory, '.' + randomUUID() + '.tmp');
40
+ const file = await open(temporary, 'wx', 0o600);
41
+ try {
42
+ await file.writeFile(content, 'utf8');
43
+ await file.sync();
44
+ await file.close();
45
+ await rename(temporary, path);
46
+ }
47
+ finally {
48
+ await file.close();
49
+ await rm(temporary, { force: true });
50
+ }
51
+ }