@gmickel/gno 1.37.1 → 1.39.0
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/assets/skill/README.md +2 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v1.37.1.zip → gno-browser-clipper-v1.39.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.39.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +243 -26
- package/spec/output-schemas/agents-mutation.schema.json +108 -0
- package/spec/output-schemas/agents-verify.schema.json +89 -0
- package/src/cli/commands/agents/block.ts +164 -0
- package/src/cli/commands/agents/commands.ts +413 -0
- package/src/cli/commands/agents/engine.ts +417 -0
- package/src/cli/commands/agents/harnesses.ts +298 -0
- package/src/cli/commands/agents/index.ts +35 -0
- package/src/cli/commands/cleanup.ts +8 -2
- package/src/cli/commands/collection/clear-embeddings.ts +6 -1
- package/src/cli/commands/completion/scripts.ts +5 -0
- package/src/cli/commands/doctor-activation.ts +5 -1
- package/src/cli/commands/doctor.ts +72 -2
- package/src/cli/commands/embed.ts +227 -194
- package/src/cli/commands/index-cmd.ts +74 -50
- package/src/cli/commands/init.ts +5 -1
- package/src/cli/commands/profile-apply.ts +5 -1
- package/src/cli/commands/setup-activation.ts +2 -1
- package/src/cli/commands/setup.ts +2 -1
- package/src/cli/commands/shared.ts +5 -1
- package/src/cli/commands/status.ts +5 -1
- package/src/cli/commands/tags.ts +18 -3
- package/src/cli/commands/update.ts +34 -27
- package/src/cli/commands/vec.ts +13 -4
- package/src/cli/errors.ts +3 -2
- package/src/cli/program.ts +449 -194
- package/src/config/defaults.ts +2 -0
- package/src/config/index.ts +3 -0
- package/src/config/types.ts +32 -1
- package/src/core/file-lock.ts +16 -4
- package/src/core/write-lease.ts +354 -0
- package/src/embed/backlog.ts +9 -1
- package/src/embed/retry.ts +116 -3
- package/src/sdk/client.ts +3 -1
- package/src/sdk/embed.ts +8 -3
- package/src/sdk/types.ts +2 -0
- package/src/serve/embed-scheduler.ts +8 -0
- package/src/serve/resident-runtime.ts +5 -1
- package/src/store/sqlite/adapter.ts +28 -4
- package/src/store/sqlite/scoped-index.ts +5 -1
- package/src/store/vector/sqlite-vec.ts +2 -1
- package/browser-extension/artifacts/gno-browser-clipper-v1.37.1.zip.sha256 +0 -1
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan/apply engine for `gno agents` — marker-managed block installation.
|
|
3
|
+
*
|
|
4
|
+
* Guarantees:
|
|
5
|
+
* - Only the owned block changes; content outside markers is byte-identical.
|
|
6
|
+
* - Backup-first: an existing file is copied to `<file>.gno-agents.bak.<ts>`
|
|
7
|
+
* before any write; the write itself lands via a sibling temp file and an
|
|
8
|
+
* atomic rename, so a failed write leaves the live file untouched.
|
|
9
|
+
* - Idempotent: re-running when current is a no-op (no write, no backup).
|
|
10
|
+
* - Symlink-aware: writes go through the resolved real file; targets that
|
|
11
|
+
* resolve to the same real file are written once.
|
|
12
|
+
* - Fail-closed: malformed markers, non-UTF-8 content, or any I/O failure
|
|
13
|
+
* produce an `error` row and no write — the command then prints the block
|
|
14
|
+
* so the operator can apply it by hand.
|
|
15
|
+
*
|
|
16
|
+
* @module src/cli/commands/agents/engine
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// node:fs/promises: Bun has no chmod (the backup and the replacement must
|
|
20
|
+
// keep the source file's mode), no atomic rename, and no unlink.
|
|
21
|
+
import { chmod, rename, stat, unlink } from "node:fs/promises";
|
|
22
|
+
|
|
23
|
+
import type { ExtractedBlock } from "./block.js";
|
|
24
|
+
import type { ResolvedTarget } from "./harnesses.js";
|
|
25
|
+
|
|
26
|
+
import { CliError } from "../../errors.js";
|
|
27
|
+
import { extractBlock, renderBlock } from "./block.js";
|
|
28
|
+
|
|
29
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
// Types
|
|
31
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export type PlanAction =
|
|
34
|
+
| "install" // no block yet — append it
|
|
35
|
+
| "update" // block present but differs — replace in place
|
|
36
|
+
| "current" // block present and identical — no-op
|
|
37
|
+
| "remove" // uninstall: block present — remove it
|
|
38
|
+
| "absent" // uninstall: no block — no-op
|
|
39
|
+
| "covered" // import chain or same real file — another target owns it
|
|
40
|
+
| "not-detected" // harness not installed on this machine — skipped
|
|
41
|
+
| "error"; // fail-closed (malformed markers, unreadable file)
|
|
42
|
+
|
|
43
|
+
export type PlanMode = "install" | "uninstall";
|
|
44
|
+
|
|
45
|
+
export interface TargetPlan {
|
|
46
|
+
target: ResolvedTarget;
|
|
47
|
+
action: PlanAction;
|
|
48
|
+
/** Target id that covers this one (import chain or shared real file). */
|
|
49
|
+
via?: string;
|
|
50
|
+
/** Human-readable detail (e.g. error guidance). */
|
|
51
|
+
detail?: string;
|
|
52
|
+
/** Existing file content ("" when the file does not exist yet). */
|
|
53
|
+
oldContent: string;
|
|
54
|
+
/** Content after the change (equal to oldContent for no-op actions). */
|
|
55
|
+
newContent: string;
|
|
56
|
+
fileExists: boolean;
|
|
57
|
+
/** The existing file began with a UTF-8 BOM; the write re-prepends it. */
|
|
58
|
+
bom?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* For `action: "error"`: exit-code category — VALIDATION for bad content
|
|
61
|
+
* (malformed markers, non-UTF-8), RUNTIME for filesystem failures.
|
|
62
|
+
*/
|
|
63
|
+
errorCode?: "VALIDATION" | "RUNTIME";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const UTF8_BOM = "";
|
|
67
|
+
const utf8Fatal = new TextDecoder("utf-8", { fatal: true });
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Decode an instruction file's bytes without normalizing operator content:
|
|
71
|
+
* a UTF-8 BOM is split off (and reported) rather than dropped, and any
|
|
72
|
+
* non-UTF-8 sequence is a hard error rather than a silent replacement
|
|
73
|
+
* character — rewriting such a file would corrupt bytes outside the markers.
|
|
74
|
+
*/
|
|
75
|
+
export function decodeInstructionFile(
|
|
76
|
+
bytes: Uint8Array,
|
|
77
|
+
path: string
|
|
78
|
+
): { content: string; bom: boolean } {
|
|
79
|
+
const bom =
|
|
80
|
+
bytes.length >= 3 &&
|
|
81
|
+
bytes[0] === 0xef &&
|
|
82
|
+
bytes[1] === 0xbb &&
|
|
83
|
+
bytes[2] === 0xbf;
|
|
84
|
+
const body = bom ? bytes.subarray(3) : bytes;
|
|
85
|
+
try {
|
|
86
|
+
return { content: utf8Fatal.decode(body), bom };
|
|
87
|
+
} catch {
|
|
88
|
+
throw new CliError(
|
|
89
|
+
"VALIDATION",
|
|
90
|
+
`${path} is not valid UTF-8; refusing to rewrite it (bytes outside the GNO agents block would be altered). Convert the file to UTF-8, then re-run.`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
96
|
+
// Content Transforms
|
|
97
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Append or replace the managed block; bytes outside the block untouched.
|
|
101
|
+
* A fresh install goes at the end of the file, separated by one blank line
|
|
102
|
+
* (a file without a final newline gets that newline first).
|
|
103
|
+
*/
|
|
104
|
+
export function withBlock(
|
|
105
|
+
oldContent: string,
|
|
106
|
+
block: ExtractedBlock | null,
|
|
107
|
+
rendered: string
|
|
108
|
+
): string {
|
|
109
|
+
if (block) {
|
|
110
|
+
return (
|
|
111
|
+
oldContent.slice(0, block.start) + rendered + oldContent.slice(block.end)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (oldContent.length === 0) {
|
|
115
|
+
return `${rendered}\n`;
|
|
116
|
+
}
|
|
117
|
+
const terminator = oldContent.endsWith("\n") ? "" : "\n";
|
|
118
|
+
return `${oldContent}${terminator}\n${rendered}\n`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Remove the managed block plus the newline that terminates it and the blank
|
|
123
|
+
* line install put above it (when one is there). Operator content is never
|
|
124
|
+
* touched beyond that single separator.
|
|
125
|
+
*/
|
|
126
|
+
export function withoutBlock(
|
|
127
|
+
oldContent: string,
|
|
128
|
+
block: ExtractedBlock
|
|
129
|
+
): string {
|
|
130
|
+
let start = block.start;
|
|
131
|
+
let end = block.end;
|
|
132
|
+
if (oldContent[end] === "\n") {
|
|
133
|
+
end += 1;
|
|
134
|
+
}
|
|
135
|
+
if (oldContent.slice(start - 2, start) === "\n\n") {
|
|
136
|
+
start -= 1;
|
|
137
|
+
}
|
|
138
|
+
return oldContent.slice(0, start) + oldContent.slice(end);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
142
|
+
// Planning
|
|
143
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
async function readFileIfExists(
|
|
146
|
+
path: string
|
|
147
|
+
): Promise<{ exists: boolean; content: string; bom: boolean }> {
|
|
148
|
+
const file = Bun.file(path);
|
|
149
|
+
if (!(await file.exists())) {
|
|
150
|
+
return { exists: false, content: "", bom: false };
|
|
151
|
+
}
|
|
152
|
+
// Bytes, not .text(): text() strips a BOM and replaces invalid sequences,
|
|
153
|
+
// which would silently alter operator-owned bytes on the rewrite.
|
|
154
|
+
const { content, bom } = decodeInstructionFile(await file.bytes(), path);
|
|
155
|
+
return { exists: true, content, bom };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function noopPlan(
|
|
159
|
+
target: ResolvedTarget,
|
|
160
|
+
action: PlanAction,
|
|
161
|
+
extra: Partial<TargetPlan> = {}
|
|
162
|
+
): TargetPlan {
|
|
163
|
+
return {
|
|
164
|
+
target,
|
|
165
|
+
action,
|
|
166
|
+
oldContent: "",
|
|
167
|
+
newContent: "",
|
|
168
|
+
fileExists: false,
|
|
169
|
+
...extra,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Build a per-target plan. Fail-closed per file: a target with malformed
|
|
175
|
+
* markers or an unreadable file gets an `error` plan (and never a write);
|
|
176
|
+
* other targets proceed.
|
|
177
|
+
*/
|
|
178
|
+
export async function planTargets(
|
|
179
|
+
targets: ResolvedTarget[],
|
|
180
|
+
mode: PlanMode
|
|
181
|
+
): Promise<TargetPlan[]> {
|
|
182
|
+
const plans: TargetPlan[] = [];
|
|
183
|
+
/** Real-file identity → id of the target that owns the write. */
|
|
184
|
+
const owners = new Map<string, string>();
|
|
185
|
+
|
|
186
|
+
// A detected covered target (e.g. grok → claude) is only truly covered
|
|
187
|
+
// when its covering target's file converges too — so the covering target
|
|
188
|
+
// is planned even when its own harness is not detected on this machine.
|
|
189
|
+
const requiredCovering = new Set<string>();
|
|
190
|
+
for (const t of targets) {
|
|
191
|
+
if (t.detected && t.coveredBy) {
|
|
192
|
+
requiredCovering.add(t.coveredBy);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const target of targets) {
|
|
197
|
+
if (!(target.detected || requiredCovering.has(target.id))) {
|
|
198
|
+
plans.push(noopPlan(target, "not-detected"));
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (target.coveredBy) {
|
|
202
|
+
const coveringResolved = targets.some((t) => t.id === target.coveredBy);
|
|
203
|
+
plans.push(
|
|
204
|
+
coveringResolved
|
|
205
|
+
? noopPlan(target, "covered", {
|
|
206
|
+
via: target.coveredBy,
|
|
207
|
+
detail: `covered via ${target.coveredBy}`,
|
|
208
|
+
})
|
|
209
|
+
: noopPlan(target, "error", {
|
|
210
|
+
errorCode: "VALIDATION",
|
|
211
|
+
detail: `covered via ${target.coveredBy}, but ${target.coveredBy} was not resolved in this run`,
|
|
212
|
+
})
|
|
213
|
+
);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const owner = owners.get(target.realFile);
|
|
217
|
+
if (owner) {
|
|
218
|
+
plans.push(
|
|
219
|
+
noopPlan(target, "covered", {
|
|
220
|
+
via: owner,
|
|
221
|
+
detail: `covered via ${owner} (same file)`,
|
|
222
|
+
})
|
|
223
|
+
);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
owners.set(target.realFile, target.id);
|
|
227
|
+
|
|
228
|
+
let exists = false;
|
|
229
|
+
let content = "";
|
|
230
|
+
let bom = false;
|
|
231
|
+
let extraction: ReturnType<typeof extractBlock>;
|
|
232
|
+
try {
|
|
233
|
+
({ exists, content, bom } = await readFileIfExists(target.file));
|
|
234
|
+
extraction = extractBlock(content, target.file);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
plans.push(
|
|
237
|
+
noopPlan(target, "error", {
|
|
238
|
+
errorCode:
|
|
239
|
+
err instanceof CliError && err.code === "VALIDATION"
|
|
240
|
+
? "VALIDATION"
|
|
241
|
+
: "RUNTIME",
|
|
242
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
243
|
+
oldContent: content,
|
|
244
|
+
newContent: content,
|
|
245
|
+
fileExists: exists,
|
|
246
|
+
})
|
|
247
|
+
);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const base = { target, oldContent: content, fileExists: exists, bom };
|
|
252
|
+
if (mode === "uninstall") {
|
|
253
|
+
plans.push(
|
|
254
|
+
extraction.found
|
|
255
|
+
? {
|
|
256
|
+
...base,
|
|
257
|
+
action: "remove",
|
|
258
|
+
newContent: withoutBlock(content, extraction.block),
|
|
259
|
+
}
|
|
260
|
+
: { ...base, action: "absent", newContent: content }
|
|
261
|
+
);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const newContent = withBlock(
|
|
266
|
+
content,
|
|
267
|
+
extraction.found ? extraction.block : null,
|
|
268
|
+
renderBlock()
|
|
269
|
+
);
|
|
270
|
+
plans.push({
|
|
271
|
+
...base,
|
|
272
|
+
action: !extraction.found
|
|
273
|
+
? "install"
|
|
274
|
+
: newContent === content
|
|
275
|
+
? "current"
|
|
276
|
+
: "update",
|
|
277
|
+
newContent,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return plans;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
285
|
+
// Apply
|
|
286
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
const WRITE_ACTIONS: PlanAction[] = ["install", "update", "remove"];
|
|
289
|
+
|
|
290
|
+
export function planWrites(plan: TargetPlan): boolean {
|
|
291
|
+
return WRITE_ACTIONS.includes(plan.action);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function describe(err: unknown): string {
|
|
295
|
+
return err instanceof Error ? err.message : String(err);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Apply one plan: back up an existing file, then write the new content via a
|
|
300
|
+
* sibling temp file + atomic rename, through the resolved real file so
|
|
301
|
+
* operator symlink schemes stay intact. Returns the backup path (null when
|
|
302
|
+
* the file did not exist). Any failure throws RUNTIME with the live file
|
|
303
|
+
* unchanged (a backup already made is reported, not removed).
|
|
304
|
+
*/
|
|
305
|
+
export async function applyPlan(plan: TargetPlan): Promise<string | null> {
|
|
306
|
+
if (!planWrites(plan)) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
const writePath = plan.target.realFile;
|
|
310
|
+
const bytes = (plan.bom ? UTF8_BOM : "") + plan.newContent;
|
|
311
|
+
|
|
312
|
+
if (!plan.fileExists) {
|
|
313
|
+
try {
|
|
314
|
+
await Bun.write(writePath, bytes);
|
|
315
|
+
} catch (err) {
|
|
316
|
+
throw new CliError(
|
|
317
|
+
"RUNTIME",
|
|
318
|
+
`Write failed for ${writePath}: ${describe(err)}`
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
325
|
+
const backupPath = `${writePath}.gno-agents.bak.${timestamp}`;
|
|
326
|
+
const tempPath = `${writePath}.gno-agents.tmp`;
|
|
327
|
+
try {
|
|
328
|
+
// Keep the source mode on both copies: a 0600 instruction file must not
|
|
329
|
+
// gain a world-readable backup or replacement.
|
|
330
|
+
const mode = (await stat(writePath)).mode & 0o777;
|
|
331
|
+
await Bun.write(backupPath, Bun.file(writePath));
|
|
332
|
+
await chmod(backupPath, mode);
|
|
333
|
+
await Bun.write(tempPath, bytes);
|
|
334
|
+
await chmod(tempPath, mode);
|
|
335
|
+
await rename(tempPath, writePath);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
// Best-effort: never accumulate temp files across repeated failures.
|
|
338
|
+
await unlink(tempPath).catch(() => {});
|
|
339
|
+
throw new CliError(
|
|
340
|
+
"RUNTIME",
|
|
341
|
+
`Write failed for ${writePath}: ${describe(err)}; the live file is unchanged`
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
return backupPath;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
348
|
+
// Unified Diff (dry-run)
|
|
349
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Minimal unified diff. Block install/update/remove is a single contiguous
|
|
353
|
+
* change, so common prefix/suffix with one hunk is exact.
|
|
354
|
+
*/
|
|
355
|
+
export function unifiedDiff(
|
|
356
|
+
oldContent: string,
|
|
357
|
+
newContent: string,
|
|
358
|
+
path: string
|
|
359
|
+
): string {
|
|
360
|
+
if (oldContent === newContent) {
|
|
361
|
+
return "";
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const oldLines = oldContent.split("\n");
|
|
365
|
+
const newLines = newContent.split("\n");
|
|
366
|
+
|
|
367
|
+
let prefix = 0;
|
|
368
|
+
while (
|
|
369
|
+
prefix < oldLines.length &&
|
|
370
|
+
prefix < newLines.length &&
|
|
371
|
+
oldLines[prefix] === newLines[prefix]
|
|
372
|
+
) {
|
|
373
|
+
prefix += 1;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
let suffix = 0;
|
|
377
|
+
while (
|
|
378
|
+
suffix < oldLines.length - prefix &&
|
|
379
|
+
suffix < newLines.length - prefix &&
|
|
380
|
+
oldLines[oldLines.length - 1 - suffix] ===
|
|
381
|
+
newLines[newLines.length - 1 - suffix]
|
|
382
|
+
) {
|
|
383
|
+
suffix += 1;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const oldChanged = oldLines.slice(prefix, oldLines.length - suffix);
|
|
387
|
+
const newChanged = newLines.slice(prefix, newLines.length - suffix);
|
|
388
|
+
|
|
389
|
+
const CONTEXT = 3;
|
|
390
|
+
const ctxBefore = oldLines.slice(Math.max(0, prefix - CONTEXT), prefix);
|
|
391
|
+
const ctxAfterStart = oldLines.length - suffix;
|
|
392
|
+
const ctxAfter = oldLines.slice(ctxAfterStart, ctxAfterStart + CONTEXT);
|
|
393
|
+
|
|
394
|
+
const oldStart = Math.max(1, prefix - CONTEXT + 1);
|
|
395
|
+
const newStart = oldStart;
|
|
396
|
+
const oldCount = ctxBefore.length + oldChanged.length + ctxAfter.length;
|
|
397
|
+
const newCount = ctxBefore.length + newChanged.length + ctxAfter.length;
|
|
398
|
+
|
|
399
|
+
const lines: string[] = [
|
|
400
|
+
`--- ${path}`,
|
|
401
|
+
`+++ ${path}`,
|
|
402
|
+
`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`,
|
|
403
|
+
];
|
|
404
|
+
for (const line of ctxBefore) {
|
|
405
|
+
lines.push(` ${line}`);
|
|
406
|
+
}
|
|
407
|
+
for (const line of oldChanged) {
|
|
408
|
+
lines.push(`-${line}`);
|
|
409
|
+
}
|
|
410
|
+
for (const line of newChanged) {
|
|
411
|
+
lines.push(`+${line}`);
|
|
412
|
+
}
|
|
413
|
+
for (const line of ctxAfter) {
|
|
414
|
+
lines.push(` ${line}`);
|
|
415
|
+
}
|
|
416
|
+
return lines.join("\n");
|
|
417
|
+
}
|