@datumos/datum-cli 0.0.0 → 0.588.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.
@@ -0,0 +1,481 @@
1
+ /**
2
+ * The repository layout of a space manifest (#1809, docs/space-admin/space-repository.md):
3
+ * how one manifest document (`apiVersion: datum/v1`, docs/terraform.md)
4
+ * maps onto files a person reviews in a pull request, and back — for
5
+ * `datum space-manifest pull|push <dir>`.
6
+ *
7
+ * <dir>/space.json apiVersion + every scalar kind
8
+ * <dir>/<kind>.json a map kind (settings, github, jira, …)
9
+ * <dir>/<kind>/<entry>.json one entry of a list kind (agents, routines, …)
10
+ * <dir>/<kind>/<entry>.<field>.md a long text field of that entry (prompts)
11
+ * <dir>/<kind>/<entry>.<field>.json a large JSON field (schemas, flow definitions)
12
+ *
13
+ * An extracted field is referenced from its entry as `{ "$file": "<name>" }`.
14
+ * Pure functions over an in-memory file map (`{ "relative/path": "text" }`),
15
+ * so the round trip is testable without a disk or a server.
16
+ *
17
+ * The kind table below MIRRORS the server's manifest kinds
18
+ * (`server/src/manifest/kinds`): the published CLI package ships `cli/` and
19
+ * the spec alone and cannot import the engine, so `spaceManifestDir.test.ts`
20
+ * pins every entry (tier, shape, natural key) to the engine's kinds — a new
21
+ * kind fails that test until it is added here.
22
+ */
23
+ export const SPACE_MANIFEST_KINDS = {
24
+ features: { tier: "org", shape: "map" },
25
+ monthlyBudgetUsd: { tier: "org", shape: "scalar" },
26
+ allocation: { tier: "org", shape: "map" },
27
+ members: { tier: "org", shape: "list", key: "user" },
28
+ systemGrants: { tier: "org", shape: "map" },
29
+ catalogBaseline: { tier: "org", shape: "scalar" },
30
+ customerGrants: { tier: "org", shape: "map" },
31
+ directoryGrants: { tier: "org", shape: "map" },
32
+ groupBindings: { tier: "org", shape: "map" },
33
+ teamGrants: { tier: "org", shape: "map" },
34
+ autonomyOptIn: { tier: "space", shape: "scalar" },
35
+ skillsRepoUrl: { tier: "space", shape: "scalar" },
36
+ settings: { tier: "space", shape: "map" },
37
+ credentials: { tier: "space", shape: "list", key: "label" },
38
+ credentialBindings: { tier: "space", shape: "map" },
39
+ accHosts: { tier: "space", shape: "list", key: "vhost" },
40
+ browserProfiles: { tier: "space", shape: "list", key: "name" },
41
+ observability: { tier: "space", shape: "list", key: "slug" },
42
+ github: { tier: "space", shape: "map" },
43
+ jira: { tier: "space", shape: "map" },
44
+ victorops: { tier: "space", shape: "map" },
45
+ grafanaAlerts: { tier: "space", shape: "map" },
46
+ teams: { tier: "space", shape: "map" },
47
+ slack: { tier: "space", shape: "map" },
48
+ queues: { tier: "space", shape: "list", key: "name" },
49
+ caseBoards: { tier: "space", shape: "list", key: "name" },
50
+ caseSteps: { tier: "space", shape: "list", key: "kind" },
51
+ caseScoreRules: { tier: "space", shape: "list", key: "name" },
52
+ chatEmbeds: { tier: "space", shape: "list", key: "slug" },
53
+ projects: { tier: "space", shape: "list", key: "key" },
54
+ accessPolicies: { tier: "space", shape: "list", key: "id" },
55
+ approvalPolicies: { tier: "space", shape: "list", key: "kind" },
56
+ agents: { tier: "space", shape: "list", key: "id", files: { prompt: "md" } },
57
+ routines: {
58
+ tier: "space",
59
+ shape: "list",
60
+ key: "name",
61
+ files: { prompt: "md", inputSchema: "json", outputSchema: "json" },
62
+ },
63
+ flows: { tier: "space", shape: "list", key: "name", files: { definition: "json" } },
64
+ flowReleases: { tier: "space", shape: "list", key: "flow" },
65
+ flowInstalls: { tier: "space", shape: "list", key: "name" },
66
+ spaceTools: { tier: "space", shape: "list", key: "name" },
67
+ toolInstalls: { tier: "space", shape: "list", key: "name" },
68
+ remoteMcpServers: { tier: "space", shape: "list", key: "label" },
69
+ spaceVariables: { tier: "space", shape: "list", key: "key" },
70
+ dataProducts: { tier: "space", shape: "list", key: "slug" },
71
+ artifacts: { tier: "space", shape: "list", key: "key" },
72
+ artifactCollections: { tier: "space", shape: "list", key: "name" },
73
+ knowledgeBases: { tier: "space", shape: "list", key: "name" },
74
+ eventSubscriptions: { tier: "space", shape: "list", key: "name" },
75
+ machinePools: { tier: "space", shape: "list", key: "name" },
76
+ };
77
+ export const SPACE_FILE = "space.json";
78
+ const FILE_REF = "$file";
79
+ const API_VERSION = "datum/v1";
80
+ /** Response envelope fields of GET /api/space-manifest — not kinds. */
81
+ const ENVELOPE_FIELDS = new Set(["spaceId"]);
82
+ /** A problem with the files, naming the file. */
83
+ export class SpaceManifestLayoutError extends Error {
84
+ }
85
+ /** A kind the CLI does not know yet (a newer server): handled by its
86
+ * runtime shape, keyed by "name" — `checkManifest` warns about it. */
87
+ function layoutOf(kind, value) {
88
+ const known = SPACE_MANIFEST_KINDS[kind];
89
+ if (known)
90
+ return known;
91
+ const shape = Array.isArray(value) ? "list" : value !== null && typeof value === "object" ? "map" : "scalar";
92
+ return { tier: "space", shape, ...(shape === "list" ? { key: "name" } : {}) };
93
+ }
94
+ /**
95
+ * A file-system-safe, readable name for an entry key: lower-case ASCII
96
+ * (Polish letters fold), anything outside [a-z0-9_-] — the DOT included —
97
+ * becomes a dash. No dot in an entry's base name is what keeps the layout
98
+ * unambiguous: `<base>.json` is always an entry and `<base>.<field>.<ext>`
99
+ * always an extracted field, so a flow named `demo.definition` can never
100
+ * land on the definition file of the flow `demo` — on a case-insensitive
101
+ * file system neither (#1809 e2e).
102
+ */
103
+ export function slugify(key) {
104
+ const folded = String(key)
105
+ .replace(/ł/g, "l")
106
+ .replace(/Ł/g, "L")
107
+ .normalize("NFKD")
108
+ .replace(/[̀-ͯ]/g, "");
109
+ const slug = folded
110
+ .toLowerCase()
111
+ .replace(/[^a-z0-9_-]+/g, "-")
112
+ .replace(/^-+|-+$/g, "")
113
+ .replace(/-{2,}/g, "-");
114
+ return slug || "entry";
115
+ }
116
+ /** An entry's own file: a base name without a dot, then `.json`. */
117
+ const ENTRY_FILE_RE = /^[^.]+\.json$/;
118
+ export function jsonFile(value) {
119
+ return `${JSON.stringify(value, null, 2)}\n`;
120
+ }
121
+ /** Text files end with exactly one added newline, which reading strips —
122
+ * lossless for any string, and an editor's final newline is not a change. */
123
+ function textFile(value) {
124
+ return `${value}\n`;
125
+ }
126
+ function readText(content) {
127
+ return content.endsWith("\n") ? content.slice(0, -1) : content;
128
+ }
129
+ function isEmpty(value) {
130
+ if (Array.isArray(value))
131
+ return value.length === 0;
132
+ if (value !== null && typeof value === "object")
133
+ return Object.keys(value).length === 0;
134
+ return false;
135
+ }
136
+ function byKey(entries, key) {
137
+ return [...entries].sort((a, b) => String(a[key]).localeCompare(String(b[key]), "en"));
138
+ }
139
+ /** Splits a live manifest (the body of GET /api/space-manifest) into files. */
140
+ export function splitManifest(manifest) {
141
+ const files = {};
142
+ const skipped = [];
143
+ const scalars = { apiVersion: manifest.apiVersion ?? API_VERSION };
144
+ for (const [kind, value] of Object.entries(manifest)) {
145
+ if (kind === "apiVersion" || ENVELOPE_FIELDS.has(kind))
146
+ continue;
147
+ const layout = SPACE_MANIFEST_KINDS[kind];
148
+ if (!layout) {
149
+ // Fail closed: a kind a newer server added may be organisation-tier
150
+ // or keyed differently — never export what this CLI cannot classify.
151
+ skipped.push({ kind, reason: "unknown to this CLI — run `datum update`, then pull again" });
152
+ continue;
153
+ }
154
+ if (layout.tier === "org") {
155
+ skipped.push({ kind, reason: "organisation tier — the org admin manages it" });
156
+ continue;
157
+ }
158
+ if (isEmpty(value)) {
159
+ // Left out = unmanaged; nothing exists to review.
160
+ skipped.push({ kind, reason: "empty in the space" });
161
+ continue;
162
+ }
163
+ if (Array.isArray(value)) {
164
+ Object.assign(files, splitList(kind, layout, value));
165
+ }
166
+ else if (value !== null && typeof value === "object") {
167
+ files[`${kind}.json`] = jsonFile(value);
168
+ }
169
+ else {
170
+ scalars[kind] = value;
171
+ }
172
+ }
173
+ files[SPACE_FILE] = jsonFile(scalars);
174
+ return { files, skipped };
175
+ }
176
+ function splitList(kind, layout, entries) {
177
+ const files = {};
178
+ const key = layout.key ?? "name";
179
+ const used = new Set();
180
+ // An entry whose key already IS its slug owns the bare name; the others
181
+ // take suffixes in key order — stable, so a second pull renames nothing.
182
+ const sorted = byKey(entries, key);
183
+ const exact = sorted.filter((e) => slugify(e[key]) === String(e[key]));
184
+ for (const entry of exact)
185
+ used.add(String(entry[key]));
186
+ for (const entry of sorted) {
187
+ let base = slugify(entry[key]);
188
+ if (!exact.includes(entry)) {
189
+ for (let n = 2; used.has(base); n += 1)
190
+ base = `${slugify(entry[key])}-${n}`;
191
+ used.add(base);
192
+ }
193
+ const body = { ...entry };
194
+ for (const [field, ext] of Object.entries(layout.files ?? {})) {
195
+ const value = body[field];
196
+ if (value === undefined || value === null)
197
+ continue;
198
+ if (ext === "md" && typeof value !== "string")
199
+ continue;
200
+ const name = `${base}.${field}.${ext}`;
201
+ files[`${kind}/${name}`] = ext === "md" ? textFile(value) : jsonFile(value);
202
+ body[field] = { [FILE_REF]: name };
203
+ }
204
+ files[`${kind}/${base}.json`] = jsonFile(body);
205
+ }
206
+ return files;
207
+ }
208
+ /**
209
+ * True for a path the layout owns — what `pull` may replace and `push`
210
+ * reads: `space.json`, `<kind>.json` of a known kind, and `.json`/`.md`
211
+ * files directly inside a known LIST kind's folder. Anything else in the
212
+ * directory (a README, scripts/, package.json) is never read or touched.
213
+ * `extraKinds`: kinds the live space reported that this CLI does not know.
214
+ */
215
+ export function isLayoutPath(path, extraKinds = new Set()) {
216
+ const known = (kind) => kind in SPACE_MANIFEST_KINDS || extraKinds.has(kind);
217
+ const parts = path.split("/");
218
+ if (parts.length === 1) {
219
+ return path === SPACE_FILE || (path.endsWith(".json") && known(path.slice(0, -".json".length)));
220
+ }
221
+ if (parts.length !== 2)
222
+ return false;
223
+ const [kind, name] = parts;
224
+ const list = SPACE_MANIFEST_KINDS[kind] ? SPACE_MANIFEST_KINDS[kind].shape === "list" : extraKinds.has(kind);
225
+ return list && !name.startsWith(".") && (name.endsWith(".json") || name.endsWith(".md"));
226
+ }
227
+ /** Assembles the manifest back from files. Throws SpaceManifestLayoutError
228
+ * naming the file for anything that cannot be read unambiguously. */
229
+ export function buildManifest(files) {
230
+ const spaceFile = files[SPACE_FILE];
231
+ if (spaceFile === undefined)
232
+ throw new SpaceManifestLayoutError(`${SPACE_FILE} is missing`);
233
+ const manifest = parseJson(SPACE_FILE, spaceFile);
234
+ if (manifest === null || typeof manifest !== "object" || Array.isArray(manifest)) {
235
+ throw new SpaceManifestLayoutError(`${SPACE_FILE}: must be a JSON object`);
236
+ }
237
+ const doc = manifest;
238
+ if (doc.apiVersion !== API_VERSION) {
239
+ throw new SpaceManifestLayoutError(`${SPACE_FILE}: apiVersion must be "${API_VERSION}"`);
240
+ }
241
+ const lists = {};
242
+ const referenced = new Set();
243
+ for (const path of Object.keys(files).sort()) {
244
+ if (path === SPACE_FILE)
245
+ continue;
246
+ const parts = path.split("/");
247
+ if (parts.length === 1) {
248
+ if (!path.endsWith(".json"))
249
+ continue;
250
+ const kind = path.slice(0, -".json".length);
251
+ if (kind in doc)
252
+ throw new SpaceManifestLayoutError(`${path}: kind "${kind}" is declared twice`);
253
+ doc[kind] = parseJson(path, files[path]);
254
+ continue;
255
+ }
256
+ if (parts.length !== 2) {
257
+ throw new SpaceManifestLayoutError(`${path}: list entries live directly under <kind>/`);
258
+ }
259
+ const [kind, name] = parts;
260
+ // `<base>.json` (no dot in the base) is an entry; every other file is a
261
+ // field an entry references through `$file` and is read there.
262
+ if (!ENTRY_FILE_RE.test(name))
263
+ continue;
264
+ const entry = parseJson(path, files[path]);
265
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
266
+ throw new SpaceManifestLayoutError(`${path}: an entry must be a JSON object`);
267
+ }
268
+ resolveRefs(path, entry, files, referenced);
269
+ (lists[kind] ??= []).push({ path, entry: entry });
270
+ }
271
+ // A file in a kind's folder is either an entry or a field an entry
272
+ // references. Anything else (`reviewer.v2.json`, a leftover prompt) would
273
+ // silently drop out of the manifest — refuse it instead.
274
+ for (const path of Object.keys(files)) {
275
+ const parts = path.split("/");
276
+ if (parts.length === 2 && !ENTRY_FILE_RE.test(parts[1]) && !referenced.has(path)) {
277
+ throw new SpaceManifestLayoutError(`${path}: neither an entry (<name>.json, no dot in the name) nor a file an entry references with "$file" — rename or remove it`);
278
+ }
279
+ }
280
+ for (const [kind, entries] of Object.entries(lists)) {
281
+ if (kind in doc)
282
+ throw new SpaceManifestLayoutError(`${kind}/: kind "${kind}" is declared twice`);
283
+ const key = layoutOf(kind, entries).key ?? "name";
284
+ const seen = new Map();
285
+ for (const { path, entry } of entries) {
286
+ const value = entry[key];
287
+ if (value === undefined || value === null || value === "") {
288
+ throw new SpaceManifestLayoutError(`${path}: the entry has no "${key}" (its natural key)`);
289
+ }
290
+ const other = seen.get(value);
291
+ if (other !== undefined) {
292
+ throw new SpaceManifestLayoutError(`${path}: "${String(value)}" is declared twice (also in ${other})`);
293
+ }
294
+ seen.set(value, path);
295
+ }
296
+ doc[kind] = byKey(entries.map((e) => e.entry), key);
297
+ }
298
+ return doc;
299
+ }
300
+ function resolveRefs(path, entry, files, referenced) {
301
+ const dir = path.slice(0, path.lastIndexOf("/") + 1);
302
+ for (const [field, value] of Object.entries(entry)) {
303
+ if (value === null || typeof value !== "object" || Array.isArray(value) || !(FILE_REF in value))
304
+ continue;
305
+ const ref = value[FILE_REF];
306
+ if (typeof ref !== "string" || ref.includes("/") || ref.includes("\\") || ref.startsWith(".")) {
307
+ throw new SpaceManifestLayoutError(`${path}: ${field}.${FILE_REF} must name a file next to the entry`);
308
+ }
309
+ const target = `${dir}${ref}`;
310
+ const content = files[target];
311
+ if (content === undefined) {
312
+ throw new SpaceManifestLayoutError(`${path}: ${field} points at ${target}, which does not exist`);
313
+ }
314
+ referenced.add(target);
315
+ entry[field] = ref.endsWith(".json") ? parseJson(target, content) : readText(content);
316
+ }
317
+ }
318
+ function parseJson(path, content) {
319
+ try {
320
+ return JSON.parse(content);
321
+ }
322
+ catch (err) {
323
+ throw new SpaceManifestLayoutError(`${path}: not valid JSON (${err.message})`);
324
+ }
325
+ }
326
+ // ------------------------------------------------------------------ checks
327
+ /** Shapes of key material that must never sit in a repository: a
328
+ * credential is declared by label + type only, its material is filled in
329
+ * the panel (docs/terraform.md "Secrets never travel in a manifest"). */
330
+ export const SECRET_PATTERNS = [
331
+ { name: "Datum API key", re: /\bdatum_[A-Za-z0-9_-]{24,}/ },
332
+ { name: "Anthropic API key", re: /sk-ant-[A-Za-z0-9_-]{20,}/ },
333
+ { name: "OpenAI API key", re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/ },
334
+ { name: "GitHub token", re: /\b(?:ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{20,}/ },
335
+ { name: "AWS access key id", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
336
+ { name: "Slack token", re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/ },
337
+ { name: "private key block", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
338
+ { name: "JWT", re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
339
+ { name: "Grafana service-account token", re: /\bglsa_[A-Za-z0-9_]{20,}/ },
340
+ { name: "Slack webhook URL", re: /hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]{10,}/ },
341
+ { name: "Atlassian API token", re: /\bATATT[A-Za-z0-9_=-]{20,}/ },
342
+ { name: "npm token", re: /\bnpm_[A-Za-z0-9]{30,}/ },
343
+ { name: "AWS secret access key", re: /aws_?secret_?access_?key["'\s:=]+[A-Za-z0-9/+]{40}/i },
344
+ { name: "password assignment", re: /\b(?:password|passwd|pwd)\s*[:=]\s*["']?[^\s"'`]{6,}/i },
345
+ ];
346
+ /** Offline checks before anything is sent: organisation-tier kinds (the
347
+ * space surface refuses them anyway) and secret material (which must never
348
+ * even travel). The server's dry run stays the authority on the rest. */
349
+ export function checkManifest(manifest) {
350
+ const errors = [];
351
+ const warnings = [];
352
+ for (const kind of Object.keys(manifest)) {
353
+ if (kind === "apiVersion")
354
+ continue;
355
+ const layout = SPACE_MANIFEST_KINDS[kind];
356
+ if (!layout) {
357
+ warnings.push(`${kind}: a kind this CLI does not know — run \`datum update\``);
358
+ }
359
+ else if (layout.tier === "org") {
360
+ errors.push(`${kind}: an organisation-tier kind — the space surface refuses it; the org admin manages it`);
361
+ }
362
+ }
363
+ for (const [kind, value] of Object.entries(manifest)) {
364
+ // A list entry is named by its natural key — what its file is named
365
+ // after — not by an index the person never sees.
366
+ const key = layoutOf(kind, value).key ?? "name";
367
+ const items = Array.isArray(value)
368
+ ? value.map((e, i) => [`${kind}[${JSON.stringify(e?.[key] ?? i)}]`, e])
369
+ : [[kind, value]];
370
+ for (const [label, item] of items) {
371
+ walkStrings(item, label, (path, text) => {
372
+ for (const { name, re } of SECRET_PATTERNS) {
373
+ if (re.test(text))
374
+ errors.push(`${path}: looks like a ${name} — secrets never go into a manifest`);
375
+ }
376
+ });
377
+ }
378
+ }
379
+ return { errors, warnings };
380
+ }
381
+ function walkStrings(value, path, onString) {
382
+ if (typeof value === "string")
383
+ onString(path || "(root)", value);
384
+ else if (Array.isArray(value))
385
+ value.forEach((item, i) => walkStrings(item, `${path}[${i}]`, onString));
386
+ else if (value !== null && typeof value === "object") {
387
+ for (const [key, item] of Object.entries(value))
388
+ walkStrings(item, path ? `${path}.${key}` : key, onString);
389
+ }
390
+ }
391
+ const WRITE_OPS = new Set(["create", "update", "delete"]);
392
+ const OP_ORDER = ["create", "update", "delete", "orphan", "noop"];
393
+ const OP_LABEL = {
394
+ create: "+ create",
395
+ update: "~ update",
396
+ delete: "- delete",
397
+ orphan: "? orphan",
398
+ noop: "= noop",
399
+ };
400
+ const MAX_VALUE = 160;
401
+ const CONTEXT_LINES = 1;
402
+ const MAX_DIFF_LINES = 40;
403
+ /** The writes the plan makes (create/update/delete) — orphans and noops are not. */
404
+ export function countWrites(answer) {
405
+ return (answer.plan ?? []).filter((op) => WRITE_OPS.has(op.op)).length;
406
+ }
407
+ /** The server's answer as the Markdown a pull-request comment carries. */
408
+ export function renderPlan(answer, title) {
409
+ const plan = answer.plan ?? [];
410
+ const counts = {};
411
+ for (const op of plan)
412
+ counts[op.op] = (counts[op.op] ?? 0) + 1;
413
+ const lines = [`## ${title}`, ""];
414
+ lines.push(answer.applied ? "**Applied.**" : "**Dry run** — nothing was written.", "");
415
+ lines.push(OP_ORDER.filter((op) => (counts[op] ?? 0) > 0)
416
+ .map((op) => `${counts[op]} ${op}`)
417
+ .join(" · ") || "Empty plan.");
418
+ const changes = plan.filter((op) => op.op !== "noop");
419
+ if (changes.length > 0) {
420
+ lines.push("", "```diff");
421
+ for (const op of sortOps(changes)) {
422
+ lines.push(`${OP_LABEL[op.op] ?? op.op} ${op.kind}/${op.name}`);
423
+ for (const [field, change] of Object.entries(op.changes ?? {})) {
424
+ if (isMultiline(change.before) || isMultiline(change.after)) {
425
+ lines.push(` ${field}:`, ...lineDiff(String(change.before ?? ""), String(change.after ?? "")));
426
+ }
427
+ else {
428
+ lines.push(` ${field}: ${short(change.before)} → ${short(change.after)}`);
429
+ }
430
+ }
431
+ }
432
+ lines.push("```");
433
+ }
434
+ if ((counts.orphan ?? 0) > 0) {
435
+ lines.push("", "_Orphans exist in the space but not in the files. They are left alone; add them to the files, or delete them on purpose with `--prune`._");
436
+ }
437
+ const warnings = answer.warnings ?? [];
438
+ if (warnings.length > 0)
439
+ lines.push("", "**Warnings**", "", ...warnings.map((w) => `- ${w}`));
440
+ return `${lines.join("\n")}\n`;
441
+ }
442
+ function sortOps(ops) {
443
+ return [...ops].sort((a, b) => OP_ORDER.indexOf(a.op) - OP_ORDER.indexOf(b.op) ||
444
+ a.kind.localeCompare(b.kind) ||
445
+ String(a.name).localeCompare(String(b.name)));
446
+ }
447
+ function isMultiline(value) {
448
+ return typeof value === "string" && value.includes("\n");
449
+ }
450
+ /** A prompt edit as the changed lines (common head and tail trimmed, one
451
+ * line of context) — not the first characters of two near-identical prompts. */
452
+ function lineDiff(before, after) {
453
+ const a = before.split("\n");
454
+ const b = after.split("\n");
455
+ let head = 0;
456
+ while (head < a.length && head < b.length && a[head] === b[head])
457
+ head += 1;
458
+ let tail = 0;
459
+ while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) {
460
+ tail += 1;
461
+ }
462
+ const out = [];
463
+ for (let i = Math.max(0, head - CONTEXT_LINES); i < head; i += 1)
464
+ out.push(` ${a[i]}`);
465
+ for (let i = head; i < a.length - tail; i += 1)
466
+ out.push(`- ${a[i]}`);
467
+ for (let i = head; i < b.length - tail; i += 1)
468
+ out.push(`+ ${b[i]}`);
469
+ for (let i = a.length - tail; i < Math.min(a.length, a.length - tail + CONTEXT_LINES); i += 1) {
470
+ out.push(` ${a[i]}`);
471
+ }
472
+ return out.length > MAX_DIFF_LINES
473
+ ? [...out.slice(0, MAX_DIFF_LINES), ` … ${out.length - MAX_DIFF_LINES} more lines`]
474
+ : out;
475
+ }
476
+ function short(value) {
477
+ const text = value === undefined ? "(absent)" : JSON.stringify(value);
478
+ const oneLine = text.replace(/\\n/g, " ").replace(/\s+/g, " ");
479
+ return oneLine.length > MAX_VALUE ? `${oneLine.slice(0, MAX_VALUE - 1)}…` : oneLine;
480
+ }
481
+ //# sourceMappingURL=spaceManifestDir.js.map