@mnaimfaizy/agent-kit 0.0.0-stage → 1.0.0-beta.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/README.md +21 -2
- package/bin/agent-kit.mjs +13 -0
- package/package.json +29 -4
- package/src/callers.mjs +204 -0
- package/src/checks/claude-app.mjs +55 -0
- package/src/checks/kit-owned-trees.mjs +78 -0
- package/src/checks/labels.mjs +100 -0
- package/src/checks/pins.mjs +76 -0
- package/src/checks/result.mjs +74 -0
- package/src/checks/rulesets.mjs +346 -0
- package/src/checks/secrets-and-variables.mjs +252 -0
- package/src/cli.mjs +229 -0
- package/src/doctor.mjs +405 -0
- package/src/exec.mjs +64 -0
- package/src/github.mjs +88 -0
- package/src/init.mjs +627 -0
- package/src/kit-owned-paths.mjs +48 -0
- package/src/kit-tag.mjs +257 -0
- package/src/local-tree.mjs +240 -0
- package/src/local-writes.mjs +307 -0
- package/src/prompt.mjs +42 -0
- package/src/safe-paths.mjs +68 -0
- package/src/tags.mjs +55 -0
- package/src/templates.mjs +133 -0
- package/src/upgrade.mjs +288 -0
- package/src/workflow-call.mjs +44 -0
- package/src/yaml-subset.mjs +118 -0
package/src/upgrade.mjs
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// `agent-kit upgrade --tag vY`: move a Consumer from the tag its pins name to
|
|
2
|
+
// vY. It replaces each Kit-owned path whole (delete, then copy from vY),
|
|
3
|
+
// deletes paths only the old tag listed, rewrites the `@ref` of every
|
|
4
|
+
// Agent-kit `uses:` pin, prints the examples/ diff for the Consumer to apply
|
|
5
|
+
// by hand, and then runs doctor's file and pin checks.
|
|
6
|
+
//
|
|
7
|
+
// Plan first, then write: every file is read from the tag and every refusal
|
|
8
|
+
// is checked before anything on disk changes, so a refusal never leaves a
|
|
9
|
+
// half-upgraded checkout. It writes only local files.
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { PinRewriteError, readWorkflows, rewriteKitPins } from "./callers.mjs";
|
|
14
|
+
import { checkPins } from "./checks/pins.mjs";
|
|
15
|
+
import { fileAndPinChecks, printFileAndPinChecks } from "./doctor.mjs";
|
|
16
|
+
import { firstLine } from "./exec.mjs";
|
|
17
|
+
import { openKitRepo, PreManifestTagError, serverFromEnv } from "./kit-tag.mjs";
|
|
18
|
+
import {
|
|
19
|
+
applyPlan,
|
|
20
|
+
checkDeletable,
|
|
21
|
+
checkLocalPath,
|
|
22
|
+
checkNoLinks,
|
|
23
|
+
kitFileWrites,
|
|
24
|
+
listed,
|
|
25
|
+
Refusal,
|
|
26
|
+
reportRefusal,
|
|
27
|
+
stageFileModes,
|
|
28
|
+
uncommittedChanges,
|
|
29
|
+
} from "./local-writes.mjs";
|
|
30
|
+
import { caseCollisions } from "./safe-paths.mjs";
|
|
31
|
+
import { compareTags } from "./tags.mjs";
|
|
32
|
+
|
|
33
|
+
const BOM = "\ufeff";
|
|
34
|
+
const utf8 = new TextDecoder("utf-8", { fatal: true });
|
|
35
|
+
|
|
36
|
+
/** A workflow file's text, or a Refusal when it is not UTF-8 (rewriting it would corrupt it). */
|
|
37
|
+
function readWorkflowText(root, path) {
|
|
38
|
+
// The Consumer's own file, rewritten in place: any name it has is fine.
|
|
39
|
+
checkNoLinks(root, path);
|
|
40
|
+
const raw = readFileSync(join(root, path));
|
|
41
|
+
try {
|
|
42
|
+
// TextDecoder drops a leading byte-order mark: keep it, so it is written back.
|
|
43
|
+
const hasBom = raw[0] === 0xef && raw[1] === 0xbb && raw[2] === 0xbf;
|
|
44
|
+
return (hasBom ? BOM : "") + utf8.decode(raw);
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Refusal(`${path} is not valid UTF-8, so its pins cannot be rewritten safely.`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Work out everything upgrade would do, reading every file it would write,
|
|
52
|
+
* and throw Refusal for anything that stops it. Touches nothing on disk.
|
|
53
|
+
*/
|
|
54
|
+
export function planUpgrade({ runner, root, oldSnap, newSnap, files, force, pinnedTag }) {
|
|
55
|
+
const newPaths = newSnap.kitOwnedPaths;
|
|
56
|
+
const removed = oldSnap.kitOwnedPaths.filter((p) => !newPaths.includes(p));
|
|
57
|
+
for (const snap of [newSnap, oldSnap]) {
|
|
58
|
+
const clash = caseCollisions(snap.kitOwnedPaths);
|
|
59
|
+
if (clash.length) {
|
|
60
|
+
throw new Refusal(
|
|
61
|
+
`${snap.tag}'s kit-owned-paths.txt lists paths that differ only in case: ${clash[0].join(" and ")}.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Every path is deleted first; the new tag's paths are then copied fresh.
|
|
67
|
+
const deletes = [...newPaths, ...removed].map((p) => p.replace(/\/$/, ""));
|
|
68
|
+
for (const rel of deletes) checkDeletable(root, rel);
|
|
69
|
+
const writes = kitFileWrites(newSnap);
|
|
70
|
+
for (const w of writes) checkLocalPath(root, w.path, { deleted: deletes });
|
|
71
|
+
|
|
72
|
+
const pinEdits = [];
|
|
73
|
+
if (pinnedTag !== newSnap.tag) {
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
if (file.pins.length === 0) continue;
|
|
76
|
+
const text = readWorkflowText(root, file.path);
|
|
77
|
+
let rewritten;
|
|
78
|
+
try {
|
|
79
|
+
rewritten = rewriteKitPins(text, newSnap.tag);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (err instanceof PinRewriteError) throw new Refusal(`${file.path}: ${err.message}`);
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
pinEdits.push({
|
|
85
|
+
path: file.path,
|
|
86
|
+
count: rewritten.count,
|
|
87
|
+
bytes: Buffer.from(rewritten.text, "utf8"),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { changed: dirty, ignored } = uncommittedChanges({ runner, root, paths: deletes });
|
|
93
|
+
if (dirty.length && !force) {
|
|
94
|
+
throw new Refusal(
|
|
95
|
+
`${dirty.length} file(s) in the paths upgrade would replace or delete have uncommitted changes. ` +
|
|
96
|
+
`Commit or stash them, or run again with --force to discard them.`,
|
|
97
|
+
listed(dirty),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { deletes, removed, writes, pinEdits, dirty, ignored };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {{
|
|
106
|
+
* cwd: string,
|
|
107
|
+
* runner: import("./exec.mjs").Runner,
|
|
108
|
+
* tag: string,
|
|
109
|
+
* from?: string, // the tag being left, when the pins already name `tag`
|
|
110
|
+
* force?: boolean,
|
|
111
|
+
* yes?: boolean,
|
|
112
|
+
* confirm?: ((question: string) => Promise<boolean>) | null,
|
|
113
|
+
* out: (text: string) => void,
|
|
114
|
+
* openRepo?: typeof openKitRepo,
|
|
115
|
+
* env?: NodeJS.ProcessEnv,
|
|
116
|
+
* }} opts
|
|
117
|
+
* @returns {Promise<number>} exit code
|
|
118
|
+
*/
|
|
119
|
+
export async function runUpgrade({
|
|
120
|
+
cwd,
|
|
121
|
+
runner,
|
|
122
|
+
tag: newTag,
|
|
123
|
+
from,
|
|
124
|
+
force = false,
|
|
125
|
+
yes = false,
|
|
126
|
+
confirm = null,
|
|
127
|
+
out,
|
|
128
|
+
openRepo = openKitRepo,
|
|
129
|
+
env = process.env,
|
|
130
|
+
}) {
|
|
131
|
+
const refuse = (err) => reportRefusal(out, err);
|
|
132
|
+
|
|
133
|
+
const top = runner("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
|
|
134
|
+
if (top.status !== 0) return refuse(new Refusal(`${cwd} is not a git repository.`));
|
|
135
|
+
const root = top.stdout.trim();
|
|
136
|
+
|
|
137
|
+
let workflows;
|
|
138
|
+
try {
|
|
139
|
+
workflows = readWorkflows(root);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
return refuse(new Refusal(firstLine(err?.message ?? err)));
|
|
142
|
+
}
|
|
143
|
+
const pins = checkPins(workflows.files);
|
|
144
|
+
if (!pins.tag) {
|
|
145
|
+
return refuse(
|
|
146
|
+
new Refusal(
|
|
147
|
+
`upgrade needs every Agent-kit uses: pin to name the same Release-line tag: ${pins.result.summary}`,
|
|
148
|
+
pins.result.details,
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const pinnedTag = pins.tag;
|
|
153
|
+
if (from !== undefined && pinnedTag !== from && pinnedTag !== newTag) {
|
|
154
|
+
return refuse(
|
|
155
|
+
new Refusal(
|
|
156
|
+
`The pins name ${pinnedTag}, which is neither --from ${from} nor --tag ${newTag}.`,
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
const oldTag = from ?? pinnedTag;
|
|
161
|
+
out(`Upgrading ${pins.repo} from ${oldTag} to ${newTag} in ${root}.`);
|
|
162
|
+
if (compareTags(newTag, oldTag) < 0) out(`Note: ${newTag} is older than ${oldTag}.`);
|
|
163
|
+
|
|
164
|
+
let kit;
|
|
165
|
+
try {
|
|
166
|
+
kit = openRepo({ runner, repo: pins.repo, serverUrl: serverFromEnv(env) });
|
|
167
|
+
} catch (err) {
|
|
168
|
+
return refuse(err);
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
let oldSnap;
|
|
172
|
+
let newSnap;
|
|
173
|
+
let plan;
|
|
174
|
+
try {
|
|
175
|
+
oldSnap = kit.fetchTag(oldTag, { forDeletion: true });
|
|
176
|
+
newSnap = newTag === oldTag ? oldSnap : kit.fetchTag(newTag, { forDeletion: true });
|
|
177
|
+
plan = planUpgrade({
|
|
178
|
+
runner,
|
|
179
|
+
root,
|
|
180
|
+
oldSnap,
|
|
181
|
+
newSnap,
|
|
182
|
+
files: workflows.files,
|
|
183
|
+
force,
|
|
184
|
+
pinnedTag,
|
|
185
|
+
});
|
|
186
|
+
} catch (err) {
|
|
187
|
+
// PreManifestTagError gives the same message doctor prints.
|
|
188
|
+
if (err instanceof Refusal || err instanceof PreManifestTagError) return refuse(err);
|
|
189
|
+
return refuse(new Refusal(firstLine(err?.message ?? err)));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
out("");
|
|
193
|
+
out(`Replace (delete, then copy from ${newTag}):`);
|
|
194
|
+
for (const p of newSnap.kitOwnedPaths) {
|
|
195
|
+
const n = plan.writes.filter((w) => w.path.startsWith(p)).length;
|
|
196
|
+
out(` ${p} (${n} file${n === 1 ? "" : "s"})`);
|
|
197
|
+
}
|
|
198
|
+
if (plan.removed.length) {
|
|
199
|
+
out(`Delete (no longer in ${newTag}'s kit-owned-paths.txt):`);
|
|
200
|
+
for (const p of plan.removed) out(` ${p}`);
|
|
201
|
+
}
|
|
202
|
+
if (plan.pinEdits.length) {
|
|
203
|
+
out(`Rewrite uses: pins to @${newTag}:`);
|
|
204
|
+
for (const e of plan.pinEdits) out(` ${e.path} (${e.count} pin${e.count === 1 ? "" : "s"})`);
|
|
205
|
+
} else {
|
|
206
|
+
out(`The uses: pins already name ${newTag}.`);
|
|
207
|
+
}
|
|
208
|
+
if (plan.dirty.length) {
|
|
209
|
+
out(`--force: discarding uncommitted changes in ${plan.dirty.length} file(s):`);
|
|
210
|
+
for (const d of listed(plan.dirty)) out(` ${d}`);
|
|
211
|
+
}
|
|
212
|
+
if (plan.ignored.length) {
|
|
213
|
+
out(`Also deleted: ${plan.ignored.length} ignored file(s) or folder(s) in those paths:`);
|
|
214
|
+
for (const d of listed(plan.ignored)) out(` ${d}`);
|
|
215
|
+
}
|
|
216
|
+
out("");
|
|
217
|
+
|
|
218
|
+
if (!yes) {
|
|
219
|
+
if (!confirm) {
|
|
220
|
+
out("Not run: there is no terminal to confirm in. Run again with --yes to go ahead.");
|
|
221
|
+
return 2;
|
|
222
|
+
}
|
|
223
|
+
if (!(await confirm("Go ahead? [y/N] "))) {
|
|
224
|
+
out("Stopped. Nothing was changed.");
|
|
225
|
+
return 1;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
applyPlan(root, { deletes: plan.deletes, writes: plan.writes, rewrites: plan.pinEdits });
|
|
231
|
+
} catch (err) {
|
|
232
|
+
if (err instanceof Refusal) return refuse(err);
|
|
233
|
+
out(`FAILED upgrade stopped part-way: ${firstLine(err?.message ?? err)}`);
|
|
234
|
+
out(" See `git status`; restore with `git checkout -- .` and `git clean` as needed.");
|
|
235
|
+
return 1;
|
|
236
|
+
}
|
|
237
|
+
out(
|
|
238
|
+
`Copied ${plan.writes.length} file(s) from ${newTag}` +
|
|
239
|
+
(plan.pinEdits.length ? ` and rewrote ${plan.pinEdits.length} workflow file(s).` : "."),
|
|
240
|
+
);
|
|
241
|
+
const note = stageFileModes({ runner, root, writes: plan.writes });
|
|
242
|
+
if (note) out(note);
|
|
243
|
+
|
|
244
|
+
out("");
|
|
245
|
+
if (oldTag === newTag) {
|
|
246
|
+
out(
|
|
247
|
+
`The pins already named ${newTag}, so there is no examples/ diff to show, and no path ` +
|
|
248
|
+
`only an older tag listed was deleted. If they were bumped from an older tag (by ` +
|
|
249
|
+
`Dependabot, say), run again with --from <that tag> to get both.`,
|
|
250
|
+
);
|
|
251
|
+
} else {
|
|
252
|
+
let diff = "";
|
|
253
|
+
try {
|
|
254
|
+
diff = kit.diff(oldSnap, newSnap, "examples/");
|
|
255
|
+
} catch (err) {
|
|
256
|
+
out(`Could not diff examples/: ${firstLine(err?.message ?? err)}`);
|
|
257
|
+
}
|
|
258
|
+
if (diff.trim()) {
|
|
259
|
+
out(
|
|
260
|
+
`examples/ changed between ${oldTag} and ${newTag}. Your Callers are yours: ` +
|
|
261
|
+
`apply what you need by hand.`,
|
|
262
|
+
);
|
|
263
|
+
// eslint-disable-next-line no-control-regex
|
|
264
|
+
if (/[\u0000-\u0009\u000b-\u001f]/.test(diff)) {
|
|
265
|
+
out(
|
|
266
|
+
"(Control characters, tabs and carriage returns included, are shown escaped as \\uXXXX.)",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
out(diff.replace(/\n$/, ""));
|
|
270
|
+
} else {
|
|
271
|
+
out(`examples/ did not change between ${oldTag} and ${newTag}.`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// The same file and pin checks as `doctor --ci`, against the tag already fetched.
|
|
276
|
+
out("");
|
|
277
|
+
const failed = printFileAndPinChecks(out, fileAndPinChecks({ runner, root, snap: newSnap }));
|
|
278
|
+
out("");
|
|
279
|
+
out(
|
|
280
|
+
failed
|
|
281
|
+
? "Result: a check failed (exit 1)."
|
|
282
|
+
: "Result: done. Review `git status` and `git diff`, then commit the pins and the Kit-owned paths together.",
|
|
283
|
+
);
|
|
284
|
+
return failed ? 1 : 0;
|
|
285
|
+
} finally {
|
|
286
|
+
kit.cleanup();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Reads a reusable workflow's `on.workflow_call.secrets` block: which secrets
|
|
2
|
+
// it declares and which of them are required.
|
|
3
|
+
|
|
4
|
+
import { parseYamlSubset } from "./yaml-subset.mjs";
|
|
5
|
+
|
|
6
|
+
/** @returns {Map<string, { required: boolean }> | null} null when the file is not a reusable workflow. */
|
|
7
|
+
export function parseWorkflowCallSecrets(text) {
|
|
8
|
+
const doc = parseYamlSubset(text);
|
|
9
|
+
const on = doc.on ?? doc["'on'"] ?? doc['"on"'];
|
|
10
|
+
if (!on || typeof on !== "object" || !("workflow_call" in on)) return null;
|
|
11
|
+
const call = on.workflow_call && typeof on.workflow_call === "object" ? on.workflow_call : {};
|
|
12
|
+
const secrets =
|
|
13
|
+
call.secrets && typeof call.secrets === "object" && !call.secrets.$seq ? call.secrets : {};
|
|
14
|
+
const out = new Map();
|
|
15
|
+
for (const [name, spec] of Object.entries(secrets)) {
|
|
16
|
+
const required =
|
|
17
|
+
spec && typeof spec === "object" ? String(spec.required).toLowerCase() === "true" : false;
|
|
18
|
+
out.set(name.toLowerCase(), { name, required });
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A reusable workflow's `on.workflow_call.inputs`: each input's default (or
|
|
25
|
+
* null) and whether it is required.
|
|
26
|
+
* @returns {Map<string, { default: string | null, required: boolean }> | null}
|
|
27
|
+
*/
|
|
28
|
+
export function parseWorkflowCallInputs(text) {
|
|
29
|
+
const doc = parseYamlSubset(text);
|
|
30
|
+
const on = doc.on ?? doc["'on'"] ?? doc['"on"'];
|
|
31
|
+
if (!on || typeof on !== "object" || !("workflow_call" in on)) return null;
|
|
32
|
+
const call = on.workflow_call && typeof on.workflow_call === "object" ? on.workflow_call : {};
|
|
33
|
+
const inputs =
|
|
34
|
+
call.inputs && typeof call.inputs === "object" && !call.inputs.$seq ? call.inputs : {};
|
|
35
|
+
const out = new Map();
|
|
36
|
+
for (const [name, spec] of Object.entries(inputs)) {
|
|
37
|
+
const obj = spec && typeof spec === "object" ? spec : {};
|
|
38
|
+
out.set(name, {
|
|
39
|
+
default: typeof obj.default === "string" ? obj.default : null,
|
|
40
|
+
required: String(obj.required).toLowerCase() === "true",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// A small, dependency-free reader for the YAML that workflow files use: block
|
|
2
|
+
// mappings, plain and quoted scalars, block scalars (| and >), and sequences.
|
|
3
|
+
// It returns nested plain objects. Sequences come back as { $seq: rawLines }
|
|
4
|
+
// and block scalars as strings; flow collections ([a, b], {a: b}) stay as
|
|
5
|
+
// their raw text. That is enough to read a Caller's jobs and a reusable
|
|
6
|
+
// workflow's `on.workflow_call.secrets`, which is all the CLI needs.
|
|
7
|
+
|
|
8
|
+
const BOM = String.fromCharCode(0xfeff);
|
|
9
|
+
|
|
10
|
+
/** Drop a leading UTF-8 byte-order mark, which would otherwise hide the first key. */
|
|
11
|
+
export function stripBom(text) {
|
|
12
|
+
const s = String(text);
|
|
13
|
+
return s.startsWith(BOM) ? s.slice(1) : s;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Remove a trailing `# comment` that sits outside quotes. */
|
|
17
|
+
export function stripComment(line) {
|
|
18
|
+
let quote = null;
|
|
19
|
+
for (let i = 0; i < line.length; i++) {
|
|
20
|
+
const c = line[i];
|
|
21
|
+
if (quote) {
|
|
22
|
+
if (c === quote) quote = null;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (c === '"' || c === "'") quote = c;
|
|
26
|
+
else if (c === "#" && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i);
|
|
27
|
+
}
|
|
28
|
+
return line;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function parseScalar(raw) {
|
|
32
|
+
const v = raw.trim();
|
|
33
|
+
if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) {
|
|
34
|
+
return v.slice(1, -1).replace(/\\(["\\])/g, "$1");
|
|
35
|
+
}
|
|
36
|
+
if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) {
|
|
37
|
+
return v.slice(1, -1).replace(/''/g, "'");
|
|
38
|
+
}
|
|
39
|
+
return v;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const KEY = /^("[^"]*"|'[^']*'|[^\s"'#:-][^:]*?|-[^\s:][^:]*?):(?:\s+(.*))?$/;
|
|
43
|
+
|
|
44
|
+
export function parseYamlSubset(text) {
|
|
45
|
+
const lines = stripBom(text).replace(/\r\n?/g, "\n").split("\n");
|
|
46
|
+
const indentOf = (l) => l.length - l.trimStart().length;
|
|
47
|
+
const isBlank = (l) => stripComment(l).trim() === "";
|
|
48
|
+
|
|
49
|
+
function nextContent(i) {
|
|
50
|
+
while (i < lines.length && isBlank(lines[i])) i++;
|
|
51
|
+
return i;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseMapping(start, parentIndent) {
|
|
55
|
+
const obj = {};
|
|
56
|
+
let i = start;
|
|
57
|
+
let childIndent = null;
|
|
58
|
+
while (i < lines.length) {
|
|
59
|
+
if (isBlank(lines[i])) {
|
|
60
|
+
i++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const ind = indentOf(lines[i]);
|
|
64
|
+
if (ind <= parentIndent) break;
|
|
65
|
+
if (childIndent === null) childIndent = ind;
|
|
66
|
+
if (ind < childIndent) break;
|
|
67
|
+
const content = stripComment(lines[i].slice(ind)).trimEnd();
|
|
68
|
+
if (ind > childIndent || content.startsWith("- ") || content === "-") {
|
|
69
|
+
i++;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const m = KEY.exec(content);
|
|
73
|
+
i++;
|
|
74
|
+
if (!m) continue;
|
|
75
|
+
const key = parseScalar(m[1]);
|
|
76
|
+
const value = (m[2] ?? "").trim();
|
|
77
|
+
if (value === "") {
|
|
78
|
+
const j = nextContent(i);
|
|
79
|
+
if (
|
|
80
|
+
j < lines.length &&
|
|
81
|
+
stripComment(lines[j]).trimStart().startsWith("-") &&
|
|
82
|
+
indentOf(lines[j]) >= childIndent
|
|
83
|
+
) {
|
|
84
|
+
const raw = [];
|
|
85
|
+
while (i < lines.length) {
|
|
86
|
+
const l = lines[i];
|
|
87
|
+
if (!isBlank(l)) {
|
|
88
|
+
const li = indentOf(l);
|
|
89
|
+
const starts = l.trimStart().startsWith("-");
|
|
90
|
+
if (li < childIndent || (li === childIndent && !starts)) break;
|
|
91
|
+
}
|
|
92
|
+
raw.push(l);
|
|
93
|
+
i++;
|
|
94
|
+
}
|
|
95
|
+
obj[key] = { $seq: raw };
|
|
96
|
+
} else if (j < lines.length && indentOf(lines[j]) > childIndent) {
|
|
97
|
+
const [sub, next] = parseMapping(j, childIndent);
|
|
98
|
+
obj[key] = sub;
|
|
99
|
+
i = next;
|
|
100
|
+
} else {
|
|
101
|
+
obj[key] = null;
|
|
102
|
+
}
|
|
103
|
+
} else if (/^[|>][-+0-9]*$/.test(value)) {
|
|
104
|
+
const raw = [];
|
|
105
|
+
while (i < lines.length && (lines[i].trim() === "" || indentOf(lines[i]) > childIndent)) {
|
|
106
|
+
raw.push(lines[i]);
|
|
107
|
+
i++;
|
|
108
|
+
}
|
|
109
|
+
obj[key] = raw.join("\n");
|
|
110
|
+
} else {
|
|
111
|
+
obj[key] = parseScalar(value);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return [obj, i];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return parseMapping(0, -1)[0];
|
|
118
|
+
}
|