@knowttl/gitea-axi 0.1.1 → 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 +16 -4
- package/dist/api.js +16 -6
- package/dist/credentials.js +9 -0
- package/dist/writes.js +33 -12
- package/generated/routes.json +425 -0
- package/package.json +2 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) knowttl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -97,6 +97,12 @@ gitea-axi config path
|
|
|
97
97
|
gitea-axi doctor
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
+
Raw `api GET` contents reads support root and nested directories, returning directory arrays (including empty arrays) or individual file, symlink and submodule objects under `data`.
|
|
101
|
+
Nullable contents and link fields are accepted while non-null values retain their schema type checks.
|
|
102
|
+
Use literal slashes for nested paths such as `repos/OWNER/NAME/contents/src/main.ts`, `repos/OWNER/NAME/editorconfig/src/main.ts` and Git ref filters such as `repos/OWNER/NAME/git/refs/heads/main`.
|
|
103
|
+
This multi-segment support applies only to those GET routes; other route parameters and raw writes retain single-segment matching.
|
|
104
|
+
Encoded route separators and traversal remain refused.
|
|
105
|
+
|
|
100
106
|
Anonymous reads require an explicit origin; repository selection follows the rules below.
|
|
101
107
|
Anonymous HTTP reads require `--allow-insecure-http` for that selected origin.
|
|
102
108
|
The tool follows no redirects and never imports tea or Git credentials.
|
|
@@ -107,10 +113,13 @@ An explicit repository wins over a matching local origin remote; a remote never
|
|
|
107
113
|
`-R` aliases `--repo` after an explicit command, with the same duplicate-selector checks.
|
|
108
114
|
For the default dashboard, use `gitea-axi --repo OWNER/NAME`; root-level `-R` remains unsupported.
|
|
109
115
|
Hosted repository reads require an explicit repository.
|
|
110
|
-
Lists default to 30 returned records, except `run list` and `release list` default to 10; collection scans
|
|
116
|
+
Lists default to 30 returned records, except `run list` and `release list` default to 10; collection scans stop at 20 pages or at least 1000 unique records and retain at most 1000.
|
|
117
|
+
Automatic page queries apply only to routes whose retained Gitea specification declares a `page` parameter.
|
|
118
|
+
Unpaginated collections, including inline review comments and attachment metadata, are complete unless the server supplies a continuation link or the collection exceeds the record bound; a response of 50 records alone does not imply another page.
|
|
111
119
|
Issue and PR lists filter before globally sorting the collected records and applying the return limit.
|
|
112
120
|
Milestone selectors use exact names, including numeric-looking names.
|
|
113
|
-
List output distinguishes unknown
|
|
121
|
+
List output distinguishes unknown totals from known totals and reports continuation and truncation.
|
|
122
|
+
For an unpaginated collection without a continuation link, a missing server total is inferred from the unique records received.
|
|
114
123
|
Issue, PR and run lists expose `unfiltered_total` as the server count before client-side filtering; `total` is the filtered count when the bounded read is complete, otherwise null.
|
|
115
124
|
Truncated output includes guidance to raise `--limit` (maximum 1000), or use raw API pagination when the bounded read is incomplete.
|
|
116
125
|
The dashboard shows a compact repository projection (ID, full name, visibility, default branch, open issue and PR counts, and update time), plus up to five issues, PRs, labels, releases and task summaries per section.
|
|
@@ -253,6 +262,7 @@ gitea-axi label create --repo OWNER/NAME --name bug --color ff0000 --allow-write
|
|
|
253
262
|
The preview identifies the exact operations, payload and current preconditions without sending a mutation.
|
|
254
263
|
Confirmation binds the credential revision, origin, target, operations, canonical payload and preconditions.
|
|
255
264
|
Changing any binding requires a fresh preview.
|
|
265
|
+
Organization casing must match the preview exactly.
|
|
256
266
|
For issues that remain present, successful named writes verify identity and the expected final title, body, assignees, labels, milestone and state through fresh reads.
|
|
257
267
|
Pin changes verify the complete expected pin membership; deletion verifies absence of the selected identity and preservation of all remaining records.
|
|
258
268
|
A mismatched or failed verification reports an unknown outcome.
|
|
@@ -273,7 +283,8 @@ API timeouts, interrupted requests and partial failures return `WRITE_OUTCOME_UN
|
|
|
273
283
|
Dispatched local Git failures retain their specific error code and include reconciliation information; stack recovery follows the [metadata and recovery contract](docs/local-stack-contract.md).
|
|
274
284
|
Reconciliation performs complete metadata reads for inspection and never repeats a mutation.
|
|
275
285
|
An unresolved dispatched attempt cannot be replayed by reusing its confirmation or obtaining another preview for the same effect.
|
|
276
|
-
Replay guards also treat repository owner and
|
|
286
|
+
Replay guards also treat repository owner, repository name and organization name case variants as the same identity for named and raw writes.
|
|
287
|
+
Unresolved organization attempts from older versions conservatively block all writes to the same organization at the same origin, including different names or payloads, and return the original attempt's reconciliation command.
|
|
277
288
|
Keep its private receipts until the outcome has been investigated.
|
|
278
289
|
Native Windows write protection and live write commissioning remain unverified; Windows writes are refused.
|
|
279
290
|
|
|
@@ -301,6 +312,7 @@ See [the credential schema](axi-credential.schema.json) for profile fields and e
|
|
|
301
312
|
Inline tokens and secret arguments are refused.
|
|
302
313
|
Resolved credentials are redacted from response and error string values before TOON serialization, including their JSON-escaped and URL-encoded forms.
|
|
303
314
|
Redaction also masks recognized secret fields, secret query values, URL credentials, JWTs, private keys and account keys.
|
|
315
|
+
Generated confirmation IDs and fixed confirmation/reconciliation instructions remain intact when they overlap a protected value; payloads and upstream response text remain subject to redaction.
|
|
304
316
|
The complete environment profile uses `GITEA_AXI_ORIGIN`, `GITEA_AXI_TOKEN_ORIGIN` and a JSON SecretRef in `GITEA_AXI_TOKEN`.
|
|
305
317
|
Set `GITEA_AXI_ALLOW_INSECURE_HTTP=true` to permit HTTP for that environment profile; only the exact values `true` and `false` are accepted.
|
|
306
318
|
HTTP requires `allowInsecureHttp: true` on that exact profile; a runtime origin override cannot reuse the token for another origin.
|
|
@@ -412,4 +424,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution rules.
|
|
|
412
424
|
|
|
413
425
|
## License
|
|
414
426
|
|
|
415
|
-
|
|
427
|
+
This project is licensed under the MIT License. See [LICENSE](LICENSE).
|
package/dist/api.js
CHANGED
|
@@ -30,7 +30,11 @@ for (const definition of Object.values(vendor.definitions)) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
const requestDefinitions = structuredClone(vendor.definitions);
|
|
33
|
-
for (const [name, fields] of Object.entries({
|
|
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
|
+
})) {
|
|
34
38
|
for (const field of fields) {
|
|
35
39
|
const properties = vendor.definitions[name].properties;
|
|
36
40
|
properties[field] = { anyOf: [properties[field], { type: 'null' }] };
|
|
@@ -45,7 +49,11 @@ export function object(value) {
|
|
|
45
49
|
}
|
|
46
50
|
export function effectFor(method, path) {
|
|
47
51
|
path = canonicalPath(path);
|
|
48
|
-
return routes.find(route => route.method === method && new RegExp('^' + route.path.split(/(\{[^}]+\})/).map(part =>
|
|
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]));
|
|
49
57
|
}
|
|
50
58
|
export function canonicalPath(path) {
|
|
51
59
|
const pathname = path.split('?')[0];
|
|
@@ -96,7 +104,8 @@ export function validate(path, value) {
|
|
|
96
104
|
if (route.schema) {
|
|
97
105
|
let validator = validators.get(route.path);
|
|
98
106
|
if (!validator) {
|
|
99
|
-
|
|
107
|
+
const schema = route.operation === 'repoGetContents' ? { anyOf: [route.schema, { type: 'array', items: route.schema }] } : route.schema;
|
|
108
|
+
validator = ajv.compile({ ...schema, definitions: vendor.definitions });
|
|
100
109
|
validators.set(route.path, validator);
|
|
101
110
|
}
|
|
102
111
|
if (!validator(value))
|
|
@@ -158,7 +167,8 @@ export async function rawRead(client, path) {
|
|
|
158
167
|
return { data, total: count && /^\d+$/.test(count) ? Number(count) : null, next: nextPage(response.headers, path, client.origin), truncated: false };
|
|
159
168
|
}
|
|
160
169
|
export async function collect(client, path, envelope) {
|
|
161
|
-
|
|
170
|
+
const paginated = routeFor(path).paginated;
|
|
171
|
+
let next = paginated ? path + (path.includes('?') ? '&' : '?') + 'limit=50&page=1' : path;
|
|
162
172
|
const seenPages = new Set();
|
|
163
173
|
const items = new Map();
|
|
164
174
|
let total = null;
|
|
@@ -185,11 +195,11 @@ export async function collect(client, path, envelope) {
|
|
|
185
195
|
total = object(data).total_count;
|
|
186
196
|
const link = response.headers.get('link');
|
|
187
197
|
next = nextPage(response.headers, path, client.origin);
|
|
188
|
-
if (!next && !link && rows.length >= 50) {
|
|
198
|
+
if (paginated && !next && !link && rows.length >= 50) {
|
|
189
199
|
const url = new URL([...seenPages].at(-1), client.origin);
|
|
190
200
|
url.searchParams.set('page', String(Number(url.searchParams.get('page')) + 1));
|
|
191
201
|
next = url.pathname + url.search;
|
|
192
202
|
}
|
|
193
203
|
}
|
|
194
|
-
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 };
|
|
195
205
|
}
|
package/dist/credentials.js
CHANGED
|
@@ -35,6 +35,11 @@ function validateProfile(profile) {
|
|
|
35
35
|
const secrets = [];
|
|
36
36
|
export function registerSecret(value) { if (value)
|
|
37
37
|
secrets.push(value); }
|
|
38
|
+
const controls = new Set();
|
|
39
|
+
export function controlText(value) {
|
|
40
|
+
controls.add(value);
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
38
43
|
export function redact(value) {
|
|
39
44
|
if (Array.isArray(value))
|
|
40
45
|
return value.map(redact);
|
|
@@ -43,6 +48,10 @@ export function redact(value) {
|
|
|
43
48
|
/^(?:token|access_token|refresh_token|password|secret|authorization|private_key)$/i.test(key) ? '[REDACTED]' : redact(item)]));
|
|
44
49
|
if (typeof value !== 'string')
|
|
45
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('');
|
|
46
55
|
let output = value;
|
|
47
56
|
for (const secret of secrets) {
|
|
48
57
|
for (const form of [secret, JSON.stringify(secret).slice(1, -1), encodeURIComponent(secret)]) {
|
package/dist/writes.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto';
|
|
2
2
|
import { constants, existsSync } from 'node:fs';
|
|
3
|
-
import { mkdir, open, rm } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, open, readdir, rm } from 'node:fs/promises';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { dirname, isAbsolute, join } from 'node:path';
|
|
6
6
|
import { canonicalPath, collect, effectFor, object, read, validate, validatePayload } from './api.js';
|
|
@@ -10,6 +10,7 @@ import { verifyPull } from './pulls.js';
|
|
|
10
10
|
import { verifyWorkflow } from './repo-workflows.js';
|
|
11
11
|
import { actionValues, valuePlan, verifyValue } from './action-values.js';
|
|
12
12
|
import { verifyUploads } from './attachments.js';
|
|
13
|
+
import { controlText } from './credentials.js';
|
|
13
14
|
function canonical(value) {
|
|
14
15
|
if (Array.isArray(value))
|
|
15
16
|
return '[' + value.map(canonical).join(',') + ']';
|
|
@@ -21,9 +22,9 @@ const digest = (value) => createHash('sha256').update(canonical(value)).digest('
|
|
|
21
22
|
const usage = (message) => { throw new CliError('USAGE', message, 2); };
|
|
22
23
|
const logPath = () => process.env.GITEA_AXI_WRITE_LOG ?? join(process.platform === 'win32' ? process.env.LOCALAPPDATA ?? homedir() : process.env.XDG_STATE_HOME ?? join(homedir(), '.local/state'), 'gitea-axi', 'writes.log');
|
|
23
24
|
const directory = () => join(dirname(logPath()), 'write-receipts');
|
|
24
|
-
const command = (id, options) => 'gitea-axi write reconcile ' + id +
|
|
25
|
-
(options.config ? ' --config ' + shell(String(options.config)) : '') + (options.profile ? ' --profile ' + shell(String(options.profile)) : '') +
|
|
26
|
-
(options.origin ? ' --origin ' + shell(String(options.origin)) : '') + (options['allow-insecure-http'] ? ' --allow-insecure-http' : '');
|
|
25
|
+
const command = (id, options) => controlText('gitea-axi write reconcile ' + id) +
|
|
26
|
+
(options.config ? controlText(' --config ') + shell(String(options.config)) : '') + (options.profile ? controlText(' --profile ') + shell(String(options.profile)) : '') +
|
|
27
|
+
(options.origin ? controlText(' --origin ') + shell(String(options.origin)) : '') + (options['allow-insecure-http'] ? controlText(' --allow-insecure-http') : '');
|
|
27
28
|
function shell(value) { return "'" + value.replaceAll("'", "'\\''") + "'"; }
|
|
28
29
|
async function key(root) {
|
|
29
30
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
@@ -51,6 +52,7 @@ export async function reconcile(client, id) {
|
|
|
51
52
|
if (receipt.origin !== client.origin)
|
|
52
53
|
throw new CliError('CONFIRMATION_MISMATCH', 'Reconciliation origin differs from the attempted write');
|
|
53
54
|
const observations = await observe(client, receipt.reads);
|
|
55
|
+
controlText(id);
|
|
54
56
|
return { confirmation: id, operations: receipt.operations, states: receipt.states, observations,
|
|
55
57
|
help: 'Inspect the exact identity and requested fields in these reads before authorizing any new attempt' };
|
|
56
58
|
}
|
|
@@ -225,11 +227,9 @@ export async function write(client, identity, plan, options, executeLocal) {
|
|
|
225
227
|
return { method: step.method, path: step.path, payload: step.payload, local: localOperation(step).operation };
|
|
226
228
|
const route = validatePayload(step.method, step.path, step.payload);
|
|
227
229
|
const segments = canonicalPath(step.path).split('/');
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
segments[index] = encodeURIComponent(decodeURIComponent(segments[index]).toLowerCase());
|
|
232
|
-
}
|
|
230
|
+
for (const [index, segment] of route.path.split('/').entries()) {
|
|
231
|
+
if (/^\{(?:owner|repo|org|template_owner|template_repo)\}$/.test(segment)) {
|
|
232
|
+
segments[index] = encodeURIComponent(decodeURIComponent(segments[index]).toLowerCase());
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
235
|
return { method: step.method, path: segments.join('/'), ...(step.payload === undefined ? {} : { payload: step.payload }), ...(step.upload ? { upload: step.upload } : {}) };
|
|
@@ -238,6 +238,25 @@ export async function write(client, identity, plan, options, executeLocal) {
|
|
|
238
238
|
const effectDigest = (value) => plan.sensitive ? createHmac('sha256', secret).update(canonical(value)).digest('hex') : digest(value);
|
|
239
239
|
const attempt = effectDigest({ origin: client.origin, steps: effectSteps });
|
|
240
240
|
const guards = [...new Set(effectSteps.filter((_, index) => !plan.steps[index].createdPull).map(step => join(root, effectDigest({ origin: client.origin, step }) + '.attempt')))];
|
|
241
|
+
const organization = (path) => /^\/api\/v1\/orgs\/([^/?]+)/.exec(canonicalPath(path))?.[1];
|
|
242
|
+
const organizations = new Set(effectSteps.map(step => organization(step.path)).filter(value => value !== undefined));
|
|
243
|
+
if (organizations.size) {
|
|
244
|
+
for (const name of await readdir(root)) {
|
|
245
|
+
if (!/^[a-f0-9]{64}\.attempt$/.test(name))
|
|
246
|
+
continue;
|
|
247
|
+
const previous = await readPrivateFile(join(root, name), true);
|
|
248
|
+
if (!/^[a-f0-9-]{36}$/.test(previous))
|
|
249
|
+
usage('Invalid persisted confirmation ID');
|
|
250
|
+
const receipt = JSON.parse(await readPrivateFile(join(root, previous + '.json'), true));
|
|
251
|
+
if (receipt.guardVersion !== 2 && receipt.origin === client.origin &&
|
|
252
|
+
receipt.reads.some(path => {
|
|
253
|
+
const org = organization(path);
|
|
254
|
+
return org !== undefined && organizations.has(encodeURIComponent(decodeURIComponent(org).toLowerCase()));
|
|
255
|
+
})) {
|
|
256
|
+
throw new CliError('WRITE_OUTCOME_UNKNOWN', 'An attempt already exists; reconcile with ' + command(previous, options));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
241
260
|
for (const guard of guards) {
|
|
242
261
|
if (existsSync(guard)) {
|
|
243
262
|
const previous = await readPrivateFile(guard, true);
|
|
@@ -245,18 +264,19 @@ export async function write(client, identity, plan, options, executeLocal) {
|
|
|
245
264
|
}
|
|
246
265
|
}
|
|
247
266
|
if (options.preview) {
|
|
248
|
-
const id = randomUUID();
|
|
249
|
-
const receipt = { binding, origin: client.origin, reads: plan.reads, states: plan.steps.map(() => 'pending'), operations, attempt,
|
|
267
|
+
const id = controlText(randomUUID());
|
|
268
|
+
const receipt = { binding, origin: client.origin, reads: plan.reads, states: plan.steps.map(() => 'pending'), operations, attempt, guardVersion: 2,
|
|
250
269
|
...(plan.steps.every(step => step.local) ? { localTarget: plan.target, localGuards: guards.map(path => path.slice(root.length + 1, -8)) } : {}) };
|
|
251
270
|
await writePrivateFile(join(root, id + '.json'), JSON.stringify(receipt));
|
|
252
271
|
return { confirmation: id, origin: client.origin, target: plan.target, operations, steps: plan.steps.map(({ upload, ...step }) => ({ ...step, ...(plan.sensitive && step.payload !== undefined ? { payload: '[REDACTED]' } : {}), ...(upload ? { upload: { name: upload.name, type: upload.type, size: upload.size, sha256: upload.sha256 } } : {}) })), preconditions: plan.preconditions,
|
|
253
|
-
...(plan.notes ? { notes: plan.notes } : {}), help: 'Repeat the same command with --confirm ' + id + ' instead of --preview' };
|
|
272
|
+
...(plan.notes ? { notes: plan.notes } : {}), help: controlText('Repeat the same command with --confirm ' + id + ' instead of --preview') };
|
|
254
273
|
}
|
|
255
274
|
const id = String(options.confirm);
|
|
256
275
|
if (!/^[a-f0-9-]{36}$/.test(id))
|
|
257
276
|
usage('Use the confirmation ID returned by --preview');
|
|
258
277
|
const path = join(root, id + '.json');
|
|
259
278
|
const receipt = JSON.parse(await readPrivateFile(path, true));
|
|
279
|
+
controlText(id);
|
|
260
280
|
if (receipt.states.some(state => state !== 'pending'))
|
|
261
281
|
throw new CliError('WRITE_OUTCOME_UNKNOWN', 'Confirmation was already dispatched; reconcile with ' + command(id, options));
|
|
262
282
|
if (receipt.binding !== binding)
|
|
@@ -285,6 +305,7 @@ export async function write(client, identity, plan, options, executeLocal) {
|
|
|
285
305
|
throw error;
|
|
286
306
|
}
|
|
287
307
|
}
|
|
308
|
+
receipt.guardVersion = 2;
|
|
288
309
|
const results = [];
|
|
289
310
|
const outcome = () => 'steps=' + receipt.states.join(',') + (receipt.createdPull ? '; created PR id=' + receipt.createdPull.id + ' number=' + receipt.createdPull.number : '') + (receipt.createdParent ? '; existing parent id=' + receipt.createdParent.id + (receipt.createdParent.number ? ' number=' + receipt.createdParent.number : '') : '') + '; reconcile with ' + command(id, options);
|
|
290
311
|
for (const [index, step] of plan.steps.entries()) {
|