@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.
- package/LICENSE +201 -0
- package/NOTICE +8 -0
- package/README.md +37 -0
- package/cli/a2aCommand.js +225 -0
- package/cli/artifactsPublish.js +356 -0
- package/cli/authCommands.js +168 -0
- package/cli/bundleSignature.js +111 -0
- package/cli/commandTree.js +243 -0
- package/cli/credentialProcess.js +54 -0
- package/cli/credentials.js +93 -0
- package/cli/datum.js +814 -0
- package/cli/entraLogin.js +254 -0
- package/cli/help.js +509 -0
- package/cli/invoke.js +427 -0
- package/cli/mcpServe.js +361 -0
- package/cli/outputsDownload.js +126 -0
- package/cli/previewCommand.js +333 -0
- package/cli/satellitePairToken.js +48 -0
- package/cli/secureOrigin.js +37 -0
- package/cli/selfUpdate.js +779 -0
- package/cli/spaceManifestCommand.js +495 -0
- package/cli/spaceManifestDir.js +481 -0
- package/cli/spaceRepoTemplate.js +463 -0
- package/cli/updateCommand.js +184 -0
- package/npm-shrinkwrap.json +1231 -0
- package/openapi/spec.js +51199 -0
- package/package.json +25 -3
- package/spaces/reservedHostLabels.js +66 -0
|
@@ -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
|