@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/LICENSE +21 -0
- package/README.md +350 -32
- package/axi-credential.schema.json +8 -1
- package/dist/action-values.js +37 -0
- package/dist/api.js +90 -19
- package/dist/attachments.js +171 -0
- package/dist/catalogue.js +65 -0
- package/dist/ci.js +24 -0
- package/dist/credentials.js +78 -6
- package/dist/git.js +89 -0
- package/dist/local-workflows.js +222 -0
- package/dist/main.js +245 -25
- package/dist/private-file.js +3 -1
- package/dist/pulls.js +299 -0
- package/dist/reads.js +15 -9
- package/dist/registry.js +139 -5
- package/dist/releases.js +145 -0
- package/dist/repo-workflows.js +147 -0
- package/dist/search.js +111 -0
- package/dist/stacks.js +486 -0
- package/dist/transport.js +62 -7
- package/dist/writes.js +568 -0
- package/generated/SKILL.md +141 -0
- package/generated/capabilities.json +964 -0
- package/generated/help.txt +5376 -0
- package/generated/host-inventory.json +7000 -151
- package/generated/routes.json +3501 -5
- package/package.json +24 -10
- package/schemas/catalogue.json +2672 -0
package/dist/api.js
CHANGED
|
@@ -10,6 +10,12 @@ const required = {
|
|
|
10
10
|
Comment: ['id', 'body', 'user'], PullReview: ['id', 'user', 'state', 'body', 'commit_id'],
|
|
11
11
|
CommitStatus: ['id', 'context', 'status'], Milestone: ['id', 'title'],
|
|
12
12
|
PRBranchInfo: ['sha', 'ref'], CombinedStatus: ['sha', 'statuses', 'total_count'],
|
|
13
|
+
Branch: ['name', 'commit'], PayloadCommit: ['id'],
|
|
14
|
+
Release: ['id', 'tag_name', 'target_commitish', 'name', 'body', 'draft', 'prerelease'],
|
|
15
|
+
Tag: ['name', 'commit'], CommitMeta: ['sha'],
|
|
16
|
+
Secret: ['name'], ActionVariable: ['name', 'data'], Attachment: ['id', 'uuid', 'name', 'size'],
|
|
17
|
+
GitTreeResponse: ['tree', 'truncated'], GitEntry: ['path', 'mode', 'type', 'sha'],
|
|
18
|
+
ActionTask: ['id', 'name', 'status'], ActionTaskResponse: ['workflow_runs', 'total_count'],
|
|
13
19
|
};
|
|
14
20
|
for (const [name, fields] of Object.entries(required)) {
|
|
15
21
|
if (vendor.definitions[name])
|
|
@@ -23,6 +29,17 @@ for (const definition of Object.values(vendor.definitions)) {
|
|
|
23
29
|
}
|
|
24
30
|
}
|
|
25
31
|
}
|
|
32
|
+
const requestDefinitions = structuredClone(vendor.definitions);
|
|
33
|
+
for (const [name, fields] of Object.entries({
|
|
34
|
+
PullRequest: ['merge_commit_sha'],
|
|
35
|
+
ContentsResponse: ['submodule_git_url', 'target', 'content', 'encoding', 'download_url', 'html_url', 'git_url'],
|
|
36
|
+
FileLinksResponse: ['git', 'html'],
|
|
37
|
+
})) {
|
|
38
|
+
for (const field of fields) {
|
|
39
|
+
const properties = vendor.definitions[name].properties;
|
|
40
|
+
properties[field] = { anyOf: [properties[field], { type: 'null' }] };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
26
43
|
const ajv = new Ajv({ strict: false, validateFormats: false });
|
|
27
44
|
const validators = new Map();
|
|
28
45
|
export function object(value) {
|
|
@@ -30,8 +47,48 @@ export function object(value) {
|
|
|
30
47
|
throw new CliError('INVALID_RESPONSE', 'Expected an object from Gitea');
|
|
31
48
|
return value;
|
|
32
49
|
}
|
|
50
|
+
export function effectFor(method, path) {
|
|
51
|
+
path = canonicalPath(path);
|
|
52
|
+
return routes.find(route => route.method === method && new RegExp('^' + route.path.split(/(\{[^}]+\})/).map(part => {
|
|
53
|
+
if (/^\{[^}]+\}$/.test(part))
|
|
54
|
+
return route.multiSegmentParameters?.includes(part.slice(1, -1)) ? '[^/]+(?:/[^/]+)*' : '[^/]+';
|
|
55
|
+
return part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
56
|
+
}).join('') + '$').test(path.split('?')[0]));
|
|
57
|
+
}
|
|
58
|
+
export function canonicalPath(path) {
|
|
59
|
+
const pathname = path.split('?')[0];
|
|
60
|
+
if (/%(?:2f|5c)/i.test(pathname))
|
|
61
|
+
throw new CliError('READ_ONLY', 'Encoded route separators are refused');
|
|
62
|
+
let decoded;
|
|
63
|
+
try {
|
|
64
|
+
decoded = decodeURIComponent(pathname);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
throw new CliError('USAGE', 'Malformed API path', 2);
|
|
68
|
+
}
|
|
69
|
+
return decoded.split('/').map(encodeURIComponent).join('/');
|
|
70
|
+
}
|
|
71
|
+
export function validatePayload(method, path, payload) {
|
|
72
|
+
const route = effectFor(method, path);
|
|
73
|
+
if (!route || route.effect !== 'write')
|
|
74
|
+
throw new CliError('READ_ONLY', 'Only classified business writes may be enabled');
|
|
75
|
+
if (/\/actions\/secrets\//.test(path) && method === 'PUT' &&
|
|
76
|
+
(typeof object(payload).data !== 'string' || Object.keys(object(payload)).some(key => key !== 'data'))) {
|
|
77
|
+
throw new CliError('USAGE', 'Secret input requires only a string data field', 2);
|
|
78
|
+
}
|
|
79
|
+
const segments = canonicalPath(path).split('/');
|
|
80
|
+
for (const [index, segment] of route.path.split('/').entries()) {
|
|
81
|
+
if (route.integerParameters.some(name => segment === '{' + name + '}') && !/^(?:0|-?[1-9]\d*)$/.test(segments[index])) {
|
|
82
|
+
throw new CliError('USAGE', 'Integer route parameters require canonical decimal spelling', 2);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (route.requestSchema && !ajv.compile({ ...route.requestSchema, definitions: requestDefinitions })(payload)) {
|
|
86
|
+
throw new CliError('USAGE', 'Invalid request fields for ' + route.operation, 2);
|
|
87
|
+
}
|
|
88
|
+
return route;
|
|
89
|
+
}
|
|
33
90
|
export function routeFor(path) {
|
|
34
|
-
const route =
|
|
91
|
+
const route = effectFor('GET', path);
|
|
35
92
|
if (route?.effect === 'admin')
|
|
36
93
|
throw new CliError('READ_ONLY', 'Administrative API routes are refused; only metadata reads are available');
|
|
37
94
|
if (route?.effect === 'credential')
|
|
@@ -47,39 +104,52 @@ export function validate(path, value) {
|
|
|
47
104
|
if (route.schema) {
|
|
48
105
|
let validator = validators.get(route.path);
|
|
49
106
|
if (!validator) {
|
|
50
|
-
|
|
107
|
+
const schema = route.operation === 'repoGetContents' ? { anyOf: [route.schema, { type: 'array', items: route.schema }] } : route.schema;
|
|
108
|
+
validator = ajv.compile({ ...schema, definitions: vendor.definitions });
|
|
51
109
|
validators.set(route.path, validator);
|
|
52
110
|
}
|
|
53
111
|
if (!validator(value))
|
|
54
112
|
throw new CliError('INVALID_RESPONSE', route.operation + ' returned invalid fields');
|
|
55
113
|
}
|
|
114
|
+
if (/\/actions\/secrets(?:\?|$)/.test(path) && Array.isArray(value)) {
|
|
115
|
+
return value.map(row => ({ name: object(row).name, ...(object(row).created_at ? { created_at: object(row).created_at } : {}) }));
|
|
116
|
+
}
|
|
56
117
|
return value;
|
|
57
118
|
}
|
|
58
|
-
export function rawPath(input) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (/^[a-z]+:/i.test(input) || input.startsWith('//') || /[\\#\s]/.test(decoded) || decoded.includes('%') ||
|
|
67
|
-
decoded.split(/[/?]/).some(part => part === '.' || part === '..'))
|
|
119
|
+
export function rawPath(input, method = 'GET') {
|
|
120
|
+
if (/^[a-z]+:/i.test(input) || input.startsWith('//') || input.includes('#'))
|
|
121
|
+
throw new CliError('USAGE', 'Use a relative API path without traversal', 2);
|
|
122
|
+
const separator = input.indexOf('?');
|
|
123
|
+
const pathname = canonicalPath(separator < 0 ? input : input.slice(0, separator));
|
|
124
|
+
const query = separator < 0 ? '' : input.slice(separator + 1);
|
|
125
|
+
const decoded = decodeURIComponent(pathname);
|
|
126
|
+
if (/[\\#\s%]/.test(decoded) || decoded.split('/').some(part => part === '.' || part === '..'))
|
|
68
127
|
throw new CliError('USAGE', 'Use a relative API path without traversal', 2);
|
|
69
|
-
const [pathname, query = ''] = input.split('?');
|
|
70
128
|
for (const key of new URLSearchParams(query).keys()) {
|
|
71
129
|
if (/token|secret|password|authorization|api.?key/i.test(key))
|
|
72
|
-
throw new CliError('
|
|
130
|
+
throw new CliError('USAGE', 'Secret query fields are refused', 2);
|
|
73
131
|
}
|
|
74
132
|
const path = pathname === '/swagger.v1.json' ? pathname : pathname?.startsWith('/api/v1/') ? pathname : '/api/v1/' + pathname?.replace(/^\//, '');
|
|
75
|
-
if (path !== '/swagger.v1.json')
|
|
133
|
+
if (path !== '/swagger.v1.json' && method === 'GET')
|
|
76
134
|
routeFor(path);
|
|
135
|
+
else if (method !== 'GET' && !effectFor(method, path))
|
|
136
|
+
throw new CliError('READ_ONLY', 'Unclassified API route');
|
|
77
137
|
return path + (query ? '?' + query : '');
|
|
78
138
|
}
|
|
79
139
|
export async function read(client, path) {
|
|
140
|
+
routeFor(path.split('?')[0]);
|
|
80
141
|
const value = await client.get(path);
|
|
81
142
|
return validate(path.split('?')[0], value);
|
|
82
143
|
}
|
|
144
|
+
export async function resolveCommit(client, base, ref) {
|
|
145
|
+
const rows = await read(client, base + '/commits?sha=' + encodeURIComponent(ref) + '&limit=1');
|
|
146
|
+
if (!Array.isArray(rows) || rows.length !== 1)
|
|
147
|
+
throw new CliError('NOT_FOUND_OR_HIDDEN', 'The selected commit ref is missing or hidden');
|
|
148
|
+
const row = object(rows[0]);
|
|
149
|
+
if (typeof row.sha !== 'string' || !/^[a-f0-9]{40}$/.test(row.sha))
|
|
150
|
+
throw new CliError('INVALID_RESPONSE', 'Expected an immutable commit identity');
|
|
151
|
+
return row;
|
|
152
|
+
}
|
|
83
153
|
function nextPage(headers, path, origin) {
|
|
84
154
|
const target = headers.get('link')?.split(',').find(part => /rel="?next"?/.test(part))?.match(/<([^>]+)>/)?.[1];
|
|
85
155
|
if (!target)
|
|
@@ -97,7 +167,8 @@ export async function rawRead(client, path) {
|
|
|
97
167
|
return { data, total: count && /^\d+$/.test(count) ? Number(count) : null, next: nextPage(response.headers, path, client.origin), truncated: false };
|
|
98
168
|
}
|
|
99
169
|
export async function collect(client, path, envelope) {
|
|
100
|
-
|
|
170
|
+
const paginated = routeFor(path).paginated;
|
|
171
|
+
let next = paginated ? path + (path.includes('?') ? '&' : '?') + 'limit=50&page=1' : path;
|
|
101
172
|
const seenPages = new Set();
|
|
102
173
|
const items = new Map();
|
|
103
174
|
let total = null;
|
|
@@ -112,7 +183,7 @@ export async function collect(client, path, envelope) {
|
|
|
112
183
|
throw new CliError('INVALID_RESPONSE', 'Expected a Gitea list' + (envelope ? ' envelope ' + envelope : ''));
|
|
113
184
|
for (const value of rows) {
|
|
114
185
|
const row = object(value);
|
|
115
|
-
const id = row.id ?? row.number ?? row.sha ?? row.filename ?? (row.user && row.content ? String(object(row.user).id) + ':' + row.content : undefined);
|
|
186
|
+
const id = /\/(?:tags|actions\/(?:secrets|variables))$/.test(path.split('?')[0]) ? row.name : row.id ?? row.number ?? row.sha ?? row.filename ?? (row.user && row.content ? String(object(row.user).id) + ':' + row.content : undefined);
|
|
116
187
|
if (typeof id !== 'number' && typeof id !== 'string')
|
|
117
188
|
throw new CliError('INVALID_RESPONSE', 'List record has no identity');
|
|
118
189
|
items.set(String(id), row);
|
|
@@ -124,11 +195,11 @@ export async function collect(client, path, envelope) {
|
|
|
124
195
|
total = object(data).total_count;
|
|
125
196
|
const link = response.headers.get('link');
|
|
126
197
|
next = nextPage(response.headers, path, client.origin);
|
|
127
|
-
if (!next && !link && rows.length >= 50) {
|
|
198
|
+
if (paginated && !next && !link && rows.length >= 50) {
|
|
128
199
|
const url = new URL([...seenPages].at(-1), client.origin);
|
|
129
200
|
url.searchParams.set('page', String(Number(url.searchParams.get('page')) + 1));
|
|
130
201
|
next = url.pathname + url.search;
|
|
131
202
|
}
|
|
132
203
|
}
|
|
133
|
-
return { items: [...items.values()], total, next, truncated: next !== null };
|
|
204
|
+
return { items: [...items.values()].slice(0, 1000), total: total ?? (!paginated && !next ? items.size : null), next, truncated: next !== null || items.size > 1000 };
|
|
134
205
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { link, lstat, open, realpath, rename, rm } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { collect, object, read, validate } from './api.js';
|
|
6
|
+
import { CliError } from './error.js';
|
|
7
|
+
export const transferBytes = 10_000_000;
|
|
8
|
+
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
9
|
+
function safeName(name) {
|
|
10
|
+
if (!name || name === '.' || name === '..' || /[\\/:*?"<>|\x00-\x1f\x7f]/.test(name) || /[. ]$/.test(name) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name))
|
|
11
|
+
throw new CliError('UNSAFE_PATH', 'Attachment requires a safe portable basename');
|
|
12
|
+
return name;
|
|
13
|
+
}
|
|
14
|
+
function attachment(value) {
|
|
15
|
+
const row = object(value);
|
|
16
|
+
if (!Number.isSafeInteger(row.id) || Number(row.id) <= 0 || typeof row.uuid !== 'string' || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(row.uuid) || typeof row.name !== 'string' || !Number.isSafeInteger(row.size) || Number(row.size) < 0)
|
|
17
|
+
throw new CliError('INVALID_RESPONSE', 'Attachment metadata has invalid identity or size');
|
|
18
|
+
safeName(row.name);
|
|
19
|
+
return row;
|
|
20
|
+
}
|
|
21
|
+
export async function assetPath(client, base, kind, selected) {
|
|
22
|
+
if (kind !== 'release') {
|
|
23
|
+
await read(client, base + (kind === 'pr' ? '/pulls/' : '/issues/') + selected);
|
|
24
|
+
return base + '/issues/' + selected + '/assets';
|
|
25
|
+
}
|
|
26
|
+
const page = await collect(client, base + '/releases');
|
|
27
|
+
if (page.truncated)
|
|
28
|
+
throw new CliError('INCOMPLETE_READ', 'Release identity requires a complete collection');
|
|
29
|
+
const release = page.items.find(row => row.tag_name === selected);
|
|
30
|
+
if (!release || !Number.isSafeInteger(release.id) || Number(release.id) <= 0)
|
|
31
|
+
throw new CliError('NOT_FOUND_OR_HIDDEN', 'The selected release is missing or hidden');
|
|
32
|
+
return base + '/releases/' + release.id + '/assets';
|
|
33
|
+
}
|
|
34
|
+
async function uploadSteps(path, options) {
|
|
35
|
+
const paths = options.file ?? options.attach;
|
|
36
|
+
if (!paths)
|
|
37
|
+
throw new CliError('USAGE', 'Select at least one --file', 2);
|
|
38
|
+
const files = Array.isArray(paths) ? paths : [paths];
|
|
39
|
+
const names = new Set();
|
|
40
|
+
const steps = [];
|
|
41
|
+
for (const value of files) {
|
|
42
|
+
const filePath = String(value), name = safeName(basename(filePath));
|
|
43
|
+
if (names.has(name.toLowerCase()))
|
|
44
|
+
throw new CliError('UNSAFE_PATH', 'Duplicate attachment basenames are refused');
|
|
45
|
+
names.add(name.toLowerCase());
|
|
46
|
+
const info = await lstat(filePath);
|
|
47
|
+
if (!info.isFile() || info.isSymbolicLink() || await realpath(filePath) !== resolve(filePath))
|
|
48
|
+
throw new CliError('UNSAFE_PATH', 'Upload must be a regular file without symlinks');
|
|
49
|
+
const file = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
50
|
+
const chunks = [];
|
|
51
|
+
let size = 0;
|
|
52
|
+
try {
|
|
53
|
+
if (!(await file.stat()).isFile())
|
|
54
|
+
throw new CliError('UNSAFE_PATH', 'Upload must be a regular file');
|
|
55
|
+
for await (const chunk of file.createReadStream({ autoClose: false })) {
|
|
56
|
+
size += chunk.length;
|
|
57
|
+
if (size > transferBytes)
|
|
58
|
+
throw new CliError('TRANSFER_LIMIT', 'Upload exceeds 10000000 bytes');
|
|
59
|
+
chunks.push(chunk);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await file.close();
|
|
64
|
+
}
|
|
65
|
+
const bytes = Buffer.concat(chunks);
|
|
66
|
+
const type = String(options['content-type'] ?? 'application/octet-stream');
|
|
67
|
+
if (!/^[\w.+-]+\/[\w.+-]+$/.test(type))
|
|
68
|
+
throw new CliError('USAGE', 'Select a MIME content type', 2);
|
|
69
|
+
steps.push({ method: 'POST', path, upload: { name, type, size, base64: bytes.toString('base64'), sha256: hash(bytes) } });
|
|
70
|
+
}
|
|
71
|
+
return steps;
|
|
72
|
+
}
|
|
73
|
+
export async function uploadPlan(client, path, options) {
|
|
74
|
+
const page = await collect(client, path);
|
|
75
|
+
if (page.truncated)
|
|
76
|
+
throw new CliError('INCOMPLETE_READ', 'Attachment identity requires a complete collection');
|
|
77
|
+
const before = page.items.map(attachment);
|
|
78
|
+
const steps = await uploadSteps(path, options);
|
|
79
|
+
if (steps.some(step => before.some(row => String(row.name).toLowerCase() === step.upload.name.toLowerCase())))
|
|
80
|
+
throw new CliError('ALREADY_EXISTS', 'An attachment has the same basename; choose a distinct name');
|
|
81
|
+
return { target: path, steps, reads: [path], preconditions: before, assets: { path, before } };
|
|
82
|
+
}
|
|
83
|
+
export async function attachToPlan(client, plan, operation, base, selected, options) {
|
|
84
|
+
if (!options.attach)
|
|
85
|
+
return plan;
|
|
86
|
+
const comment = operation.endsWith(' comment');
|
|
87
|
+
const created = operation.endsWith(' create') || comment;
|
|
88
|
+
if (created) {
|
|
89
|
+
const parent = comment ? plan.steps.findIndex(step => step.path.endsWith('/comments')) : 0;
|
|
90
|
+
const path = base + (comment ? '/issues/comments/0/assets' : operation.startsWith('release ') ? '/releases/0/assets' : '/issues/0/assets');
|
|
91
|
+
return { ...plan, steps: [...plan.steps, ...(await uploadSteps(path, options)).map(step => ({ ...step, assetParent: parent }))], assets: { path, before: [] } };
|
|
92
|
+
}
|
|
93
|
+
const uploads = await uploadPlan(client, await assetPath(client, base, operation.split(' ')[0], selected), options);
|
|
94
|
+
return { ...plan, steps: [...plan.steps, ...uploads.steps], reads: [...plan.reads, ...uploads.reads], preconditions: { ...object(plan.preconditions), attachments: uploads.preconditions }, assets: uploads.assets };
|
|
95
|
+
}
|
|
96
|
+
export function verifyUploads(plan, results, observations) {
|
|
97
|
+
const { path, before } = plan.assets;
|
|
98
|
+
const after = observations.find(row => row.path === path).data.map(attachment);
|
|
99
|
+
const uploads = plan.steps.flatMap((step, index) => step.upload ? [{ upload: step.upload, result: attachment(validate(path + '/' + object(results[index]).id, results[index])) }] : []);
|
|
100
|
+
if (after.length !== before.length + uploads.length || before.some(row => !after.some(item => item.id === row.id && item.uuid === row.uuid && item.name === row.name && item.size === row.size)) || uploads.some(({ upload, result }) => before.some(row => row.id === result.id) || !after.some(row => row.id === result.id && row.uuid === result.uuid && row.name === upload.name && row.size === upload.size)))
|
|
101
|
+
throw new CliError('READBACK_MISMATCH', 'Attachment metadata did not confirm the intended uploads');
|
|
102
|
+
}
|
|
103
|
+
export async function download(client, path, options) {
|
|
104
|
+
if (process.platform === 'win32')
|
|
105
|
+
throw new CliError('CONFIG_INSECURE', 'Native Windows download ACL protection is unavailable');
|
|
106
|
+
if (options['content-type'] !== undefined && !/^[\w.+-]+\/[\w.+-]+$/.test(String(options['content-type'])))
|
|
107
|
+
throw new CliError('USAGE', 'Select a MIME content type', 2);
|
|
108
|
+
if (options.output && options.dir)
|
|
109
|
+
throw new CliError('USAGE', 'Choose --output or --dir', 2);
|
|
110
|
+
if (options['asset-id'] !== undefined && !/^[1-9]\d*$/.test(String(options['asset-id'])))
|
|
111
|
+
throw new CliError('USAGE', 'Select one positive --asset-id', 2);
|
|
112
|
+
const page = await collect(client, path);
|
|
113
|
+
if (page.truncated)
|
|
114
|
+
throw new CliError('INCOMPLETE_READ', 'Download selection requires a complete collection');
|
|
115
|
+
const patterns = options.pattern === undefined ? ['*'] : Array.isArray(options.pattern) ? options.pattern.map(String) : [String(options.pattern)];
|
|
116
|
+
const matches = patterns.map(pattern => new RegExp('^' + pattern.split(/([*?])/).map(part => part === '*' ? '.*' : part === '?' ? '.' : part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('') + '$'));
|
|
117
|
+
const selected = page.items.filter(row => (options['asset-id'] === undefined || String(row.id) === options['asset-id']) && matches.some(pattern => pattern.test(String(row.name)))).map(attachment);
|
|
118
|
+
if (!selected.length)
|
|
119
|
+
throw new CliError('NO_MATCHES', 'No attachments match the selected patterns');
|
|
120
|
+
if (new Set(selected.map(row => String(row.name).toLowerCase())).size !== selected.length)
|
|
121
|
+
throw new CliError('UNSAFE_PATH', 'Duplicate download basenames are refused');
|
|
122
|
+
if (options.sha256 !== undefined && (selected.length !== 1 || !/^[a-f0-9]{64}$/.test(String(options.sha256))))
|
|
123
|
+
throw new CliError('USAGE', '--sha256 requires exactly one selected file and a lowercase SHA-256 digest', 2);
|
|
124
|
+
if (options.output && selected.length !== 1)
|
|
125
|
+
throw new CliError('USAGE', '--output requires exactly one selected attachment', 2);
|
|
126
|
+
const directory = options.output ? dirname(resolve(String(options.output))) : resolve(String(options.dir ?? '.'));
|
|
127
|
+
if (!(await lstat(directory)).isDirectory() || await realpath(directory) !== directory)
|
|
128
|
+
throw new CliError('UNSAFE_PATH', 'Download directory must exist without symlinks');
|
|
129
|
+
const completed = [];
|
|
130
|
+
for (const selectedRow of selected) {
|
|
131
|
+
const temporary = join(directory, '.gitea-attachment-' + randomUUID() + '.tmp');
|
|
132
|
+
try {
|
|
133
|
+
const row = attachment(await read(client, path + '/' + selectedRow.id));
|
|
134
|
+
if (['id', 'uuid', 'name', 'size'].some(key => row[key] !== selectedRow[key]))
|
|
135
|
+
throw new CliError('INVALID_RESPONSE', 'Attachment identity changed before download');
|
|
136
|
+
if (Number(row.size) > transferBytes)
|
|
137
|
+
throw new CliError('TRANSFER_LIMIT', 'Download exceeds 10000000 bytes');
|
|
138
|
+
const destination = join(directory, options.output ? safeName(basename(String(options.output))) : String(row.name));
|
|
139
|
+
const previous = await lstat(destination).catch((error) => { if (error.code !== 'ENOENT')
|
|
140
|
+
throw error; return null; });
|
|
141
|
+
if (previous && (!options.clobber || previous.isSymbolicLink() || !previous.isFile()))
|
|
142
|
+
throw new CliError('UNSAFE_PATH', 'Destination exists; choose --clobber for a regular file');
|
|
143
|
+
const { bytes, type } = await client.attachment(String(row.uuid), options['content-type'] === undefined ? undefined : String(options['content-type']));
|
|
144
|
+
const sha256 = hash(bytes);
|
|
145
|
+
if (bytes.length !== row.size || options.sha256 && sha256 !== options.sha256)
|
|
146
|
+
throw new CliError('INTEGRITY_MISMATCH', 'Downloaded bytes differ from the selected size or digest');
|
|
147
|
+
const file = await open(temporary, 'wx', 0o600);
|
|
148
|
+
try {
|
|
149
|
+
await file.writeFile(bytes);
|
|
150
|
+
await file.sync();
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
await file.close();
|
|
154
|
+
}
|
|
155
|
+
if (options.clobber)
|
|
156
|
+
await rename(temporary, destination);
|
|
157
|
+
else
|
|
158
|
+
await link(temporary, destination);
|
|
159
|
+
completed.push({ id: row.id, name: row.name, bytes: bytes.length, sha256, content_type: type, path: destination });
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (!completed.length)
|
|
163
|
+
throw error;
|
|
164
|
+
throw new CliError('PARTIAL_TRANSFER', 'Completed attachment IDs ' + completed.map(row => row.id).join(',') + '; remaining transfer failed: ' + (error instanceof CliError ? error.code : 'FILE_ERROR'));
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
await rm(temporary, { force: true });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { downloads: completed };
|
|
171
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { CliError } from './error.js';
|
|
3
|
+
export const baseline = JSON.parse(readFileSync(new URL('../schemas/catalogue.json', import.meta.url), 'utf8')).commands;
|
|
4
|
+
export function commandGap(path) {
|
|
5
|
+
if (/^(discussion|gist|project) /.test(path))
|
|
6
|
+
return 'No corresponding discussion, gist or Projects v2 API is documented in the retained Gitea 1.23.4 or 1.24.0 specifications';
|
|
7
|
+
if (/^issue (lock|unlock)$/.test(path))
|
|
8
|
+
return 'Gitea 1.23.4 has no documented issue lock contract; the retained 1.24.0 lock candidate is not commissioned';
|
|
9
|
+
if (/^issue (transfer|subissue )/.test(path))
|
|
10
|
+
return 'Gitea 1.23.4 and 1.24.0 provide no evidenced issue transfer or parent/child subissue contract; repository transfer and dependencies are different operations';
|
|
11
|
+
if (path === 'pr ready')
|
|
12
|
+
return 'Gitea 1.23.4 and 1.24.0 do not document a draft transition field in EditPullRequestOption';
|
|
13
|
+
if (path === 'setup hooks')
|
|
14
|
+
return 'C096: retained gh-axi 0.1.46 help does not specify supported agent paths, configuration schemas or repair semantics; hook installation remains pending';
|
|
15
|
+
if (path === 'update' || path === 'update --check')
|
|
16
|
+
return 'C122/C123: retained gh-axi 0.1.46 help does not establish the distribution, integrity or rollback contract; update and check-only remain pending';
|
|
17
|
+
if (path === 'api HEAD')
|
|
18
|
+
return 'Gitea 1.23.4 has no documented HEAD operation/effect contract; no GET substitution is performed';
|
|
19
|
+
if (path === 'pr review')
|
|
20
|
+
return 'Gitea 1.23.4 and 1.24.0 do not establish review event values or atomic stale-head rejection (CX-007); review posting remains pending';
|
|
21
|
+
if (path === 'dashboard')
|
|
22
|
+
return 'C121: exact gh-axi 0.1.46 sections and defaults are not captured; the owned bounded repository dashboard is not baseline parity';
|
|
23
|
+
if (path.startsWith('workflow ') || path.startsWith('run ') && path !== 'run list')
|
|
24
|
+
return 'Gitea 1.23.4 has task summaries only; retained 1.24.0 workflow/run candidates remain uncommissioned';
|
|
25
|
+
if (path === 'search code')
|
|
26
|
+
return 'Gitea 1.23.4 has no evidenced code search API/query contract';
|
|
27
|
+
if (path === 'stack submit')
|
|
28
|
+
return 'The required automatic merge scheduling contract remains pending';
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
export function flagGap(path, name) {
|
|
32
|
+
if (name === 'jq' || name === 'template')
|
|
33
|
+
return '--' + name + ' compatibility is not implemented: no jq evaluator or template engine/grammar has been selected; Gitea 1.23.4 responses do not supply these local semantics';
|
|
34
|
+
return '--' + name + ' has no implemented compatibility contract for ' + path + ' on Gitea 1.23.4; see capabilities for the retained baseline outcome';
|
|
35
|
+
}
|
|
36
|
+
export function capabilityOutput(commands, fields, id) {
|
|
37
|
+
if (id && !baseline.some(row => row.id === id))
|
|
38
|
+
throw new CliError('USAGE', 'Select a catalogue ID C001 through C124', 2);
|
|
39
|
+
const rows = baseline.filter(row => !id || row.id === id).map(row => {
|
|
40
|
+
const path = row.path === 'update --check' ? 'update' : row.path.startsWith('api ') ? 'api' : row.path;
|
|
41
|
+
const command = commands.find(command => command.path.join(' ') === path);
|
|
42
|
+
const gap = commandGap(row.path) ?? command?.gap;
|
|
43
|
+
const flags = row.flags.map(flag => {
|
|
44
|
+
const name = flag.name === '-X' ? 'method' : flag.name.replace(/^--/, '');
|
|
45
|
+
const meta = command?.flags[name];
|
|
46
|
+
return { ...flag, status: !meta || meta.gap || gap ? 'gap' : 'available with owned semantics',
|
|
47
|
+
contract: gap ?? meta?.gap ?? 'Use command help; baseline semantic parity is not claimed by flag acceptance' };
|
|
48
|
+
});
|
|
49
|
+
return id ? { ...row, status: gap ? 'gap' : 'partial', gap: gap ?? null, flags } :
|
|
50
|
+
{ id: row.id, command: 'gitea-axi ' + row.path, status: gap ? 'gap' : 'partial', flags: flags.length };
|
|
51
|
+
});
|
|
52
|
+
return { baseline: 'gh-axi 0.1.46', target: 'Gitea 1.23.4',
|
|
53
|
+
counts: { commands: baseline.length, commandFlags: baseline.reduce((count, row) => count + row.flags.length, 0), globals: 4, prFields: fields.length, fullParity: 0 },
|
|
54
|
+
commands: rows,
|
|
55
|
+
...(id ? {} : { globals: [
|
|
56
|
+
{ selector: '-R/--repo', contract: 'Explicit repository before matching local remote; stack rejects it and GH_REPO' },
|
|
57
|
+
{ selector: '--hostname/GH_HOST', contract: 'Select exactly one matching configured profile by host and port; exact tokenOrigin binding applies before secret resolution' },
|
|
58
|
+
{ selector: '--help', contract: 'Offline registry-derived help' },
|
|
59
|
+
{ selector: '-v/-V/--version', contract: 'Offline packaged version' },
|
|
60
|
+
], prFields: fields.map(field => ({ field, contract: field === 'id' ? 'gap: GraphQL node ID unavailable; fullDatabaseId is the numeric vendor ID' :
|
|
61
|
+
field === 'statusCheckRollup' ? 'partial commit statuses only; excludes full check suites' :
|
|
62
|
+
['autoMergeRequest', 'closingIssuesReferences', 'mergeStateStatus', 'potentialMergeCommit', 'projectCards', 'projectItems', 'reviewDecision'].includes(field) ? 'gap: no evidenced Gitea 1.23.4 field contract' : 'owned normalized field; see pr view --fields' })) }),
|
|
63
|
+
help: id ? ['gitea-axi capabilities'] : ['gitea-axi capabilities C038'],
|
|
64
|
+
};
|
|
65
|
+
}
|
package/dist/ci.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { collect } from './api.js';
|
|
2
|
+
import { CliError } from './error.js';
|
|
3
|
+
import { continuationHelp, positive, selectedFields } from './reads.js';
|
|
4
|
+
export const taskFields = ['id', 'name', 'display_title', 'workflow_id', 'head_branch', 'head_sha', 'event', 'status', 'run_number', 'url', 'created_at', 'updated_at', 'run_started_at'];
|
|
5
|
+
export function validateTasks(options) {
|
|
6
|
+
positive(options.limit, 10, 1000);
|
|
7
|
+
selectedFields(options, taskFields, []);
|
|
8
|
+
}
|
|
9
|
+
export async function tasks(client, base, options) {
|
|
10
|
+
const page = await collect(client, base + '/actions/tasks', 'workflow_runs').catch((error) => {
|
|
11
|
+
if (error instanceof CliError && error.code === 'NOT_FOUND_OR_HIDDEN')
|
|
12
|
+
throw new CliError('CAPABILITY_GAP', 'Actions task reads are unavailable or hidden on this server; full workflow run APIs require Gitea 1.24 or later');
|
|
13
|
+
throw error;
|
|
14
|
+
});
|
|
15
|
+
const mapping = { workflow: 'workflow_id', branch: 'head_branch', status: 'status', event: 'event', commit: 'head_sha' };
|
|
16
|
+
const rows = page.items.filter(row => Object.entries(mapping).every(([flag, field]) => options[flag] === undefined || row[field] === options[flag]));
|
|
17
|
+
const limit = positive(options.limit, 10, 1000);
|
|
18
|
+
const fields = selectedFields(options, taskFields, []) ?? ['id', 'name', 'status'];
|
|
19
|
+
return { items: rows.slice(0, limit).map(row => Object.fromEntries(fields.map(field => [field, row[field] ?? null]))), count: Math.min(rows.length, limit),
|
|
20
|
+
total: page.truncated ? null : rows.length, unfiltered_total: page.total, next: page.next, truncated: page.truncated || rows.length > limit, continuation: page.truncated || rows.length > limit,
|
|
21
|
+
help: [...continuationHelp(page, rows.length, limit), ...(page.items.length === 0 ? ['No task summaries returned. Gitea older than 1.24 may return an empty task list even when workflow runs exist; this does not establish that there are no runs.'] : [])],
|
|
22
|
+
scope: 'Gitea 1.23.4 ActionTask summaries; task/run identity pending commissioning. Status does not establish job conclusions or terminal run results.',
|
|
23
|
+
pending: ['run details, watch, logs, artifacts and controls', '1.24.0 workflow routes require representative commissioning', 'mutations require the shared write gate'] };
|
|
24
|
+
}
|
package/dist/credentials.js
CHANGED
|
@@ -1,12 +1,45 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
4
|
import { isAbsolute, join } from 'node:path';
|
|
4
5
|
import { Ajv } from 'ajv';
|
|
5
6
|
import { CliError } from './error.js';
|
|
6
7
|
import { readPrivateFile } from './private-file.js';
|
|
7
8
|
const schema = JSON.parse(readFileSync(new URL('../axi-credential.schema.json', import.meta.url), 'utf8'));
|
|
8
|
-
const validate = new Ajv({ strict: false }).compile(schema);
|
|
9
|
+
const validate = new Ajv({ strict: false, allErrors: true }).compile(schema);
|
|
10
|
+
function validateProfile(profile) {
|
|
11
|
+
if (validate(profile))
|
|
12
|
+
return;
|
|
13
|
+
const value = profile && typeof profile === 'object' ? profile : undefined;
|
|
14
|
+
const token = value && 'token' in value ? value.token : undefined;
|
|
15
|
+
const branch = token && typeof token === 'object' ? 'env' in token ? '0' : 'file' in token ? '1' : undefined : undefined;
|
|
16
|
+
const http = value && 'origin' in value && typeof value.origin === 'string' && value.origin.startsWith('http:');
|
|
17
|
+
const errors = (validate.errors ?? []).filter(error => {
|
|
18
|
+
const alternative = error.schemaPath.match(/^#\/properties\/token\/oneOf\/(\d+)\//)?.[1];
|
|
19
|
+
return (!alternative || alternative === branch) && (!error.schemaPath.startsWith('#/then/') || http) && error.keyword !== 'if' && error.keyword !== 'oneOf';
|
|
20
|
+
});
|
|
21
|
+
const priority = { required: 0, additionalProperties: 1 };
|
|
22
|
+
const error = errors.find(error => error.schemaPath === '#/required') ?? errors.sort((a, b) => (priority[a.keyword] ?? 2) - (priority[b.keyword] ?? 2) || b.instancePath.split('/').length - a.instancePath.split('/').length)[0] ?? validate.errors?.find(error => error.keyword === 'oneOf');
|
|
23
|
+
const property = error?.keyword === 'required' ? error.params.missingProperty : error?.keyword === 'additionalProperties' ? error.params.additionalProperty : undefined;
|
|
24
|
+
const field = [...(error?.instancePath.split('/').slice(1) ?? []), ...(property ? [String(property)] : [])].join('.');
|
|
25
|
+
if (field === 'allowInsecureHttp' && error?.schemaPath.startsWith('#/then/'))
|
|
26
|
+
throw new CliError('CONFIG_INVALID', 'HTTP requires allowInsecureHttp: true on the selected profile');
|
|
27
|
+
const correction = error?.keyword === 'additionalProperties' ? 'remove this unknown field' :
|
|
28
|
+
field === 'token.file' ? 'set an absolute local file path' :
|
|
29
|
+
field === 'token.env' ? 'set a valid environment variable name' :
|
|
30
|
+
field === 'token' ? 'set exactly one of env (an environment variable name) or file (an absolute local path)' :
|
|
31
|
+
field === 'allowInsecureHttp' ? 'set true or false' :
|
|
32
|
+
error?.keyword === 'required' ? 'set this required field' : 'use a value matching the credential profile schema';
|
|
33
|
+
throw new CliError('CONFIG_INVALID', 'Invalid profile field: ' + (field || 'profile') + '; ' + correction);
|
|
34
|
+
}
|
|
9
35
|
const secrets = [];
|
|
36
|
+
export function registerSecret(value) { if (value)
|
|
37
|
+
secrets.push(value); }
|
|
38
|
+
const controls = new Set();
|
|
39
|
+
export function controlText(value) {
|
|
40
|
+
controls.add(value);
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
10
43
|
export function redact(value) {
|
|
11
44
|
if (Array.isArray(value))
|
|
12
45
|
return value.map(redact);
|
|
@@ -15,6 +48,10 @@ export function redact(value) {
|
|
|
15
48
|
/^(?:token|access_token|refresh_token|password|secret|authorization|private_key)$/i.test(key) ? '[REDACTED]' : redact(item)]));
|
|
16
49
|
if (typeof value !== 'string')
|
|
17
50
|
return value;
|
|
51
|
+
// Callers register only generated control text, never payloads or upstream response text.
|
|
52
|
+
const preserved = [...controls].filter(text => value.includes(text)).sort((a, b) => b.length - a.length);
|
|
53
|
+
if (preserved.length)
|
|
54
|
+
return value.split(new RegExp('(' + preserved.map(text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')')).map(part => controls.has(part) ? part : redact(part)).join('');
|
|
18
55
|
let output = value;
|
|
19
56
|
for (const secret of secrets) {
|
|
20
57
|
for (const form of [secret, JSON.stringify(secret).slice(1, -1), encodeURIComponent(secret)]) {
|
|
@@ -69,11 +106,31 @@ export function canonicalOrigin(value) {
|
|
|
69
106
|
export async function credentials(override, allowHttp, options = {}) {
|
|
70
107
|
const path = configPath(options.config);
|
|
71
108
|
const selected = options.profile ?? process.env.GITEA_AXI_PROFILE;
|
|
109
|
+
const hostname = options.hostname ?? (selected === 'artium' ? undefined : process.env.GH_HOST);
|
|
110
|
+
if (hostname === '')
|
|
111
|
+
throw new CliError('USAGE', '--hostname/GH_HOST requires a nonempty host', 2);
|
|
112
|
+
const matchesHost = (origin) => {
|
|
113
|
+
const url = new URL(canonicalOrigin(origin));
|
|
114
|
+
if (!hostname || /[\s\\/?#@]/.test(hostname))
|
|
115
|
+
throw new CliError('USAGE', '--hostname/GH_HOST requires a host with optional port', 2);
|
|
116
|
+
let host;
|
|
117
|
+
try {
|
|
118
|
+
host = new URL(url.protocol + '//' + hostname);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
throw new CliError('USAGE', '--hostname/GH_HOST requires a host with optional port', 2);
|
|
122
|
+
}
|
|
123
|
+
return url.host === host.host;
|
|
124
|
+
};
|
|
72
125
|
const fields = ['ORIGIN', 'TOKEN_ORIGIN', 'TOKEN', 'ALLOW_INSECURE_HTTP'];
|
|
73
126
|
const hasEnv = fields.some(field => process.env['GITEA_AXI_' + field] !== undefined);
|
|
74
127
|
let profile;
|
|
75
128
|
let profileName = 'environment';
|
|
129
|
+
let revision = 'environment';
|
|
76
130
|
if (!selected && hasEnv) {
|
|
131
|
+
const missing = ['ORIGIN', 'TOKEN_ORIGIN', 'TOKEN'].filter(field => !process.env['GITEA_AXI_' + field]);
|
|
132
|
+
if (missing.length)
|
|
133
|
+
throw new CliError('CONFIG_INVALID', 'Set missing environment variables: ' + missing.map(field => 'GITEA_AXI_' + field).join(', '));
|
|
77
134
|
let token;
|
|
78
135
|
try {
|
|
79
136
|
token = JSON.parse(process.env.GITEA_AXI_TOKEN ?? 'null');
|
|
@@ -87,18 +144,30 @@ export async function credentials(override, allowHttp, options = {}) {
|
|
|
87
144
|
}
|
|
88
145
|
else if (existsSync(path)) {
|
|
89
146
|
const config = await readConfig(path);
|
|
90
|
-
const names = Object.keys(config.profiles)
|
|
91
|
-
|
|
147
|
+
const names = Object.keys(config.profiles).filter(name => {
|
|
148
|
+
if (!hostname)
|
|
149
|
+
return true;
|
|
150
|
+
const profile = config.profiles[name];
|
|
151
|
+
validateProfile(profile);
|
|
152
|
+
return matchesHost(profile.origin);
|
|
153
|
+
});
|
|
154
|
+
if (hostname && !selected && !names.length)
|
|
155
|
+
throw new CliError('PROFILE_NOT_FOUND', 'No configured profile matches --hostname/GH_HOST');
|
|
156
|
+
const name = selected ?? (hostname ? names.length === 1 ? names[0] : undefined : config.defaultProfile ?? (names.length === 1 ? names[0] : undefined));
|
|
92
157
|
if (!name)
|
|
93
158
|
throw new CliError(names.length ? 'PROFILE_AMBIGUOUS' : 'PROFILE_REQUIRED', 'Select a profile');
|
|
94
159
|
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name) || !Object.hasOwn(config.profiles, name))
|
|
95
160
|
throw new CliError('PROFILE_NOT_FOUND', 'Selected profile was not found');
|
|
96
161
|
profile = config.profiles[name];
|
|
97
162
|
profileName = name;
|
|
163
|
+
revision = process.env.GITEA_AXI_CREDENTIAL_REVISION ?? String((await stat(path, { bigint: true })).mtimeNs);
|
|
98
164
|
}
|
|
99
165
|
else if (selected || options.config || process.env.GITEA_AXI_CONFIG) {
|
|
100
166
|
throw new CliError('PROFILE_NOT_FOUND', 'Selected profile configuration was not found');
|
|
101
167
|
}
|
|
168
|
+
else if (hostname) {
|
|
169
|
+
throw new CliError('PROFILE_NOT_FOUND', '--hostname/GH_HOST requires a matching configured profile');
|
|
170
|
+
}
|
|
102
171
|
else if (override) {
|
|
103
172
|
const origin = canonicalOrigin(override);
|
|
104
173
|
if (origin.startsWith('http:') && !allowHttp)
|
|
@@ -108,9 +177,10 @@ export async function credentials(override, allowHttp, options = {}) {
|
|
|
108
177
|
else {
|
|
109
178
|
throw new CliError('AUTH_REQUIRED', 'Select a configured profile or an explicit anonymous --origin');
|
|
110
179
|
}
|
|
111
|
-
|
|
112
|
-
throw new CliError('CONFIG_INVALID', 'Invalid profile field: ' + (validate.errors?.[0]?.instancePath || 'token/origin'));
|
|
180
|
+
validateProfile(profile);
|
|
113
181
|
const origin = canonicalOrigin(profile.origin);
|
|
182
|
+
if (hostname && !matchesHost(origin))
|
|
183
|
+
throw new CliError('AUTH_BINDING_MISMATCH', 'Selected profile does not match --hostname/GH_HOST');
|
|
114
184
|
if (origin !== canonicalOrigin(profile.tokenOrigin) || (override && canonicalOrigin(override) !== origin)) {
|
|
115
185
|
throw new CliError('AUTH_BINDING_MISMATCH', 'origin must match tokenOrigin before resolving token');
|
|
116
186
|
}
|
|
@@ -123,11 +193,13 @@ export async function credentials(override, allowHttp, options = {}) {
|
|
|
123
193
|
if (!isAbsolute(profile.token.file) || /^\\\\/.test(profile.token.file))
|
|
124
194
|
throw new CliError('CONFIG_INVALID', 'token file must be an absolute local path');
|
|
125
195
|
token = (await readPrivateFile(profile.token.file, true)).replace(/^\uFEFF/, '').trimEnd();
|
|
196
|
+
revision += ':' + String((await stat(profile.token.file, { bigint: true })).mtimeNs);
|
|
126
197
|
}
|
|
127
198
|
if (!token)
|
|
128
199
|
throw new CliError('AUTH_REQUIRED', 'token reference is empty');
|
|
129
200
|
secrets.push(token);
|
|
130
201
|
if (/[\r\n]/.test(token))
|
|
131
202
|
throw new CliError('CONFIG_INVALID', 'token must be a single line');
|
|
132
|
-
return { origin, token, profile: profileName, source: 'env' in profile.token ? 'env:' + profile.token.env : 'file:' + profile.token.file
|
|
203
|
+
return { origin, token, profile: profileName, revision, source: 'env' in profile.token ? 'env:' + profile.token.env : 'file:' + profile.token.file,
|
|
204
|
+
...(profile.writes ? { writes: profile.writes } : {}) };
|
|
133
205
|
}
|