@datumos/datum-cli 0.0.0 → 0.589.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,463 @@
1
+ /**
2
+ * The files `datum space-manifest init <dir>` writes (#1809): a repository
3
+ * that keeps ONE space's configuration as code — the manifest files under
4
+ * `space/`, a CI that plans on a pull request and applies on `main`, a
5
+ * daily sync that brings panel edits back as a pull request, and the rules
6
+ * for whoever (or whatever agent) edits it. No Terraform, no state: the
7
+ * server plans every change against the live space
8
+ * (docs/space-admin/space-repository.md).
9
+ */
10
+ /** Where the manifest files live inside the repository. */
11
+ export const SPACE_REPO_MANIFEST_DIR = "space";
12
+ /** The repository secret the workflows read — a key bound to the space. */
13
+ export const SPACE_REPO_KEY_SECRET = "DATUM_SPACE_API_KEY";
14
+ /** The repository variable with the organisation host. */
15
+ export const SPACE_REPO_BASE_URL_VAR = "DATUM_BASE_URL";
16
+ /** The branch the sync workflow opens its pull request from. */
17
+ export const SPACE_REPO_SYNC_BRANCH = "datum/sync-from-space";
18
+ /** The public package the workflows install (docs/cli.md "Running it"). */
19
+ export const DATUM_CLI_NPM_PACKAGE = "@datumos/datum-cli";
20
+ /** The recommended repository name: the team's namespace first, like the
21
+ * namespace's other repositories (repositories are created in Cloud Market,
22
+ * in a namespace's "GitHub repositories" purchase, never directly in GitHub). */
23
+ export function recommendedSpaceRepoName(space, namespace = "<namespace>") {
24
+ return `${namespace}-datum-space-${space}`;
25
+ }
26
+ const DIR = SPACE_REPO_MANIFEST_DIR;
27
+ function installCli() {
28
+ return ` - uses: actions/setup-node@v4
29
+ with: { node-version: 22 }
30
+ - name: Install the datum CLI
31
+ run: |
32
+ npm install -g ${DATUM_CLI_NPM_PACKAGE}`;
33
+ }
34
+ function planApplyWorkflow(space) {
35
+ return `# The space's configuration as code: plan on a pull request, apply on main.
36
+ # Written by \`datum space-manifest init\` — docs/space-admin/space-repository.md.
37
+ #
38
+ # Repository settings it needs:
39
+ # secret ${SPACE_REPO_KEY_SECRET} a key bound to the space "${space}", role admin
40
+ # (panel: Settings → API keys)
41
+ # variable ${SPACE_REPO_BASE_URL_VAR} the organisation host, e.g. https://datum.example.com
42
+ name: Datum space
43
+
44
+ on:
45
+ # The plan needs the space key, so it runs as pull_request_TARGET: the
46
+ # workflow comes from the base branch (a pull request cannot edit what
47
+ # runs with the key) and the pull request is checked out only as DATA —
48
+ # the manifest files — never executed. Every pull request gets a plan
49
+ # (no paths filter), so a required "plan" check never blocks one that
50
+ # does not touch ${DIR}/.
51
+ pull_request_target:
52
+ push:
53
+ branches: [main]
54
+ paths: ["${DIR}/**", ".github/workflows/datum-space.yml"]
55
+ # Run on main to re-apply the repository — e.g. after closing a sync pull
56
+ # request to revert an edit made in the panel. Tick "prune" to delete the
57
+ # entries whose files a merged pull request removed.
58
+ workflow_dispatch:
59
+ inputs:
60
+ prune:
61
+ description: Delete space entries the files no longer name (within the kinds space/ declares)
62
+ type: boolean
63
+ default: false
64
+
65
+ permissions:
66
+ contents: read
67
+
68
+ env:
69
+ DATUM_BASE_URL: \${{ vars.${SPACE_REPO_BASE_URL_VAR} }}
70
+ DATUM_SPACE: ${space}
71
+
72
+ jobs:
73
+ plan:
74
+ if: github.event_name == 'pull_request_target'
75
+ runs-on: [self-hosted, ubuntu-latest, ARM64]
76
+ # Per pull request: a new push supersedes that PR's running plan only.
77
+ concurrency:
78
+ group: datum-space-plan-\${{ github.event.pull_request.number }}
79
+ cancel-in-progress: true
80
+ permissions:
81
+ contents: read
82
+ pull-requests: write
83
+ steps:
84
+ # The pull request's files land in pr/ as data: no script, hook, .npmrc
85
+ # or workflow of theirs runs — the steps below run from the workspace
86
+ # root with the base branch's definitions.
87
+ - uses: actions/checkout@v6
88
+ with:
89
+ ref: \${{ github.event.pull_request.head.sha }}
90
+ path: pr
91
+ persist-credentials: false
92
+ ${installCli()}
93
+ - name: Plan (dry run against the live space)
94
+ run: |
95
+ if [ ! -d pr/${DIR} ]; then echo "No ${DIR}/ yet — run the Datum space sync workflow to import the space."; exit 0; fi
96
+ # A refused plan replaces the comment too — never leave an older,
97
+ # successful plan standing on a pull request that no longer plans.
98
+ if ! datum space-manifest push pr/${DIR} --dry-run --markdown plan.md 2> plan.err; then
99
+ cat plan.err
100
+ { printf '## Datum space plan\\n\\n**The plan was refused** — nothing can be applied until this is fixed:\\n\\n\`\`\`\\n'; cat plan.err; printf '\`\`\`\\n'; } > plan.md
101
+ exit 1
102
+ fi
103
+ env:
104
+ DATUM_API_KEY: \${{ secrets.${SPACE_REPO_KEY_SECRET} }}
105
+ - name: Comment the plan on the pull request
106
+ if: always() && hashFiles('plan.md') != ''
107
+ uses: actions/github-script@v7
108
+ with:
109
+ script: |
110
+ const fs = require('fs');
111
+ const marker = '<!-- datum-space-plan -->';
112
+ const body = \`\${marker}\\n\${fs.readFileSync('plan.md', 'utf8')}\`;
113
+ const { owner, repo } = context.repo;
114
+ const issue_number = context.payload.pull_request.number;
115
+ const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number });
116
+ const mine = comments.find((c) => c.body?.startsWith(marker));
117
+ if (mine) await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body });
118
+ else await github.rest.issues.createComment({ owner, repo, issue_number, body });
119
+
120
+ apply:
121
+ # push / manual run on main ONLY — a pull_request_target run carries
122
+ # ref = main too, and must never re-apply main because a PR was opened.
123
+ if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main'
124
+ runs-on: [self-hosted, ubuntu-latest, ARM64]
125
+ # Applies queue one after another; a newer pending apply replaces an
126
+ # older pending one, which is fine — both apply main as it is now.
127
+ concurrency:
128
+ group: datum-space-apply
129
+ cancel-in-progress: false
130
+ steps:
131
+ - uses: actions/checkout@v6
132
+ ${installCli()}
133
+ - name: Apply
134
+ run: |
135
+ if [ ! -d ${DIR} ]; then echo "No ${DIR}/ yet — nothing to apply."; exit 0; fi
136
+ datum space-manifest push ${DIR} $PRUNE --markdown apply.md
137
+ cat apply.md >> "$GITHUB_STEP_SUMMARY"
138
+ env:
139
+ DATUM_API_KEY: \${{ secrets.${SPACE_REPO_KEY_SECRET} }}
140
+ PRUNE: \${{ inputs.prune && '--prune' || '' }}
141
+ # After a clean apply the space equals the files, so a second dry run
142
+ # must plan zero writes. A write here = a value the server normalises
143
+ # (write it the way the plan shows) or a panel edit racing the apply.
144
+ - name: Verify convergence
145
+ run: |
146
+ if [ ! -d ${DIR} ]; then exit 0; fi
147
+ datum space-manifest push ${DIR} $PRUNE --dry-run --expect-empty
148
+ env:
149
+ DATUM_API_KEY: \${{ secrets.${SPACE_REPO_KEY_SECRET} }}
150
+ PRUNE: \${{ inputs.prune && '--prune' || '' }}
151
+ `;
152
+ }
153
+ function syncWorkflow(space) {
154
+ return `# Brings the space's live configuration into the repository as a pull request.
155
+ # Written by \`datum space-manifest init\` — docs/space-admin/space-repository.md.
156
+ #
157
+ # - First run = the IMPORT (Actions → Datum space sync → Run workflow): a new
158
+ # repository has no ${DIR}/ yet; merge the pull request it opens.
159
+ # - Every weekday morning after that = DRIFT: an edit made in the panel comes
160
+ # back as a pull request. Merge it to keep the edit; close it and the next
161
+ # apply restores what the repository says.
162
+ #
163
+ # The pull request is opened with the workflow's GITHUB_TOKEN, for which
164
+ # GitHub starts no workflows: its plan does not run. A sync pull request
165
+ # mirrors the space, so its plan is empty by construction.
166
+ name: Datum space sync
167
+
168
+ on:
169
+ workflow_dispatch:
170
+ schedule:
171
+ - cron: "0 5 * * 1-5"
172
+
173
+ # Its own group: a queued sync must never cancel a pending apply (GitHub
174
+ # keeps one pending run per group). Instead the sync stays away from an
175
+ # apply in flight — it would read a half-applied space — see the steps.
176
+ concurrency:
177
+ group: datum-space-sync
178
+ cancel-in-progress: false
179
+
180
+ permissions:
181
+ actions: read
182
+ contents: write
183
+ pull-requests: write
184
+
185
+ env:
186
+ DATUM_BASE_URL: \${{ vars.${SPACE_REPO_BASE_URL_VAR} }}
187
+ DATUM_SPACE: ${space}
188
+
189
+ jobs:
190
+ sync:
191
+ runs-on: [self-hosted, ubuntu-latest, ARM64]
192
+ steps:
193
+ - uses: actions/checkout@v6
194
+ ${installCli()}
195
+ - name: Wait until no apply is running
196
+ id: idle
197
+ env:
198
+ GH_TOKEN: \${{ github.token }}
199
+ run: |
200
+ applying() {
201
+ gh run list --workflow datum-space.yml --limit 20 --json status,event \\
202
+ --jq '[.[] | select(.event != "pull_request_target" and .status != "completed")] | length'
203
+ }
204
+ for _ in $(seq 1 30); do [ "$(applying)" = "0" ] && break; sleep 20; done
205
+ if [ "$(applying)" != "0" ]; then
206
+ echo "An apply is still running — skipping this sync; the next one sees the result."
207
+ echo "skip=1" >> "$GITHUB_OUTPUT"
208
+ fi
209
+ - name: Pull the live configuration
210
+ if: steps.idle.outputs.skip != '1'
211
+ run: datum space-manifest pull ${DIR}
212
+ env:
213
+ DATUM_API_KEY: \${{ secrets.${SPACE_REPO_KEY_SECRET} }}
214
+ - name: Open or update the sync pull request
215
+ if: steps.idle.outputs.skip != '1'
216
+ env:
217
+ GH_TOKEN: \${{ github.token }}
218
+ run: |
219
+ # An apply that started while we pulled may have left a half-applied
220
+ # state in the files — drop this round rather than propose it.
221
+ if [ "$(gh run list --workflow datum-space.yml --limit 20 --json status,event --jq '[.[] | select(.event != "pull_request_target" and .status != "completed")] | length')" != "0" ]; then
222
+ echo "An apply started during the pull — skipping; the next sync sees the result."
223
+ exit 0
224
+ fi
225
+ git add -A ${DIR}
226
+ if git diff --cached --quiet; then
227
+ echo "The repository already matches the space."
228
+ exit 0
229
+ fi
230
+ # ${DIR}/ already on main = drift; absent = the first import.
231
+ if [ -n "$(git ls-tree -d --name-only HEAD ${DIR})" ]; then
232
+ title="Sync z przestrzeni: zmiany spoza repozytorium"
233
+ body="Konfiguracja przestrzeni ${space} różni się od repozytorium — ktoś zmienił ją w panelu albo innym narzędziem. Zmerguj, żeby zachować zmianę. Żeby ją cofnąć: zamknij i uruchom workflow Datum space na main (Run workflow) — przywróci stan z repozytorium."
234
+ else
235
+ title="Import konfiguracji przestrzeni ${space}"
236
+ body="Pierwszy import: stan przestrzeni ${space} jako pliki w ${DIR}/. Po merge apply nie powinien nic zmienić (plan = same noop)."
237
+ fi
238
+ git config user.name "datum-space-sync"
239
+ git config user.email "github-actions@users.noreply.github.com"
240
+ git checkout -B ${SPACE_REPO_SYNC_BRANCH}
241
+ git commit -m "$title"
242
+ git push --force origin ${SPACE_REPO_SYNC_BRANCH}
243
+ if [ -z "$(gh pr list --head ${SPACE_REPO_SYNC_BRANCH} --state open --json number --jq '.[].number')" ]; then
244
+ gh pr create --head ${SPACE_REPO_SYNC_BRANCH} --title "$title" --body "$body" || {
245
+ echo "::error::Could not open the pull request. Turn on Settings → Actions → General → \\"Allow GitHub Actions to create and approve pull requests\\" (off by default), then re-run. The branch ${SPACE_REPO_SYNC_BRANCH} is pushed — a person can open the pull request from it meanwhile."
246
+ exit 1
247
+ }
248
+ fi
249
+ `;
250
+ }
251
+ function agentsMd(space) {
252
+ return `# Working in the Datum space repository \`${space}\`
253
+
254
+ This repository holds the configuration of the Datum space \`${space}\` as
255
+ files. \`main\` is what the space runs: a merge applies it through the space
256
+ manifest. There is no Terraform and no state file — the server plans every
257
+ change against the live space.
258
+
259
+ ## Layout (\`${DIR}/\`)
260
+
261
+ | Path | What it is |
262
+ | --- | --- |
263
+ | \`${DIR}/space.json\` | \`apiVersion\` + scalar kinds (\`autonomyOptIn\`, \`skillsRepoUrl\`) |
264
+ | \`${DIR}/<kind>.json\` | a map kind: \`settings\`, \`github\`, \`jira\`, \`teams\`, \`victorops\`, \`grafanaAlerts\`, \`credentialBindings\` |
265
+ | \`${DIR}/<kind>/<entry>.json\` | one entry of a list kind: \`agents\`, \`routines\`, \`flows\`, \`queues\`, \`caseBoards\`, \`knowledgeBases\`, \`credentials\`, … |
266
+ | \`${DIR}/<kind>/<entry>.prompt.md\` | the entry's prompt (agents, routines) |
267
+ | \`${DIR}/<kind>/<entry>.<field>.json\` | a large JSON field: routine \`inputSchema\`/\`outputSchema\`, flow \`definition\` |
268
+
269
+ An extracted field is referenced from its entry as \`{ "$file": "<name>" }\`.
270
+ Every kind's fields, with descriptions: \`datum space-manifest schema <kind>
271
+ --space ${space}\` (no kind = the whole schema). Every \`datum\` command below
272
+ names the space with \`--space ${space}\`: a sign-in addresses the
273
+ organisation's host otherwise, where a space member has no authority.
274
+
275
+ ## Creating something new (an agent, a routine, a flow, a queue…)
276
+
277
+ 1. **Take the shape from Datum, nowhere else.** \`datum space-manifest schema
278
+ <kind> --space ${space}\` lists the kind's fields and what each means. For a
279
+ flow, look in the flow catalog first (\`datum flow-catalog entries list
280
+ --space ${space}\`, then \`datum flow-catalog entries get <id> --space ${space}\`)
281
+ — flows other spaces published and the organisation certified; one that
282
+ fits is installed through \`flowInstalls\`, not copied — and read the step
283
+ types in \`datum flows create --help\`. An entry already in \`${DIR}/\` is a
284
+ good second example.
285
+ 2. **Do not read other spaces' repositories, other spaces' configuration or
286
+ the \`datum-core\` source** to learn a shape. They are other teams' local
287
+ decisions, with queues, credentials and people that do not exist here,
288
+ and source code is not the contract — the schema, the catalog and
289
+ \`--help\` are.
290
+ 3. **Ask the space what it is for.** \`datum a2a send --space ${space} "…"\` hands
291
+ a question to the space's own agent: which routine already does this,
292
+ which queue triages it, who approves.
293
+ 4. **People by nick.** A routine's \`owner\`, an approver, a queue member: a
294
+ person of this space — \`datum space-members list --space ${space}\` (the
295
+ \`userId\` of a row is the nick; \`derived\` lists the people a bound Entra
296
+ group admits). Never an e-mail or an id.
297
+ 5. **Check before the pull request.** \`datum space-manifest validate ${DIR}
298
+ --space ${space}\` must pass (any member can run it: for a space admin it is the dry
299
+ run's full check, below admin the shape only). It calls the server, so it
300
+ needs a sign-in (\`datum login\`) or a key in \`DATUM_API_KEY\` — like \`schema\`;
301
+ offline or signed out both answer "not signed in". A space
302
+ admin also sees the plan against the live space: \`datum space-manifest
303
+ push ${DIR} --dry-run --space ${space}\`.
304
+
305
+ A minimal new routine is two files — the entry, named after its \`name\`
306
+ (lower-case, dashes; \`pull\` keeps that name), and its prompt next to it:
307
+
308
+ \`${DIR}/routines/raport-dnia.json\`
309
+
310
+ \`\`\`json
311
+ {
312
+ "name": "Raport dnia",
313
+ "prompt": { "$file": "raport-dnia.prompt.md" },
314
+ "owner": "<nick from datum space-members list>",
315
+ "cron": "0 7 * * 1-5",
316
+ "timezone": "Europe/Warsaw"
317
+ }
318
+ \`\`\`
319
+
320
+ \`${DIR}/routines/raport-dnia.prompt.md\` — the prompt itself, in Polish. No
321
+ \`cron\` (or \`""\`) = the routine runs on demand only; every other field
322
+ (\`inputSchema\`, \`triggers\`, \`runsAs\`, …) is in \`datum space-manifest
323
+ schema routines --space ${space}\`.
324
+
325
+ ## Rules
326
+
327
+ 1. **Change files, never the space directly.** Edit under \`${DIR}/\`, open a
328
+ pull request, read the plan the CI comments, merge. An edit made in the
329
+ panel comes back as a "Sync z przestrzeni" pull request.
330
+ 2. **\`main\` applies.** Plan locally as much as you like (\`push --dry-run\`, a
331
+ space admin's sign-in is enough). Apply from a workstation (\`push\`
332
+ without \`--dry-run\`) only when the person asks for it AND the same change
333
+ is on its way to \`main\` — otherwise the next apply on \`main\` reverts it.
334
+ 3. **No secrets, ever.** A credential is declared by \`label\` + \`type\`; a
335
+ person fills its material in the panel. \`push\` refuses strings in common
336
+ key and token formats — a safety net, not a guarantee. Never print
337
+ \`DATUM_API_KEY\`.
338
+ 4. **The repository mirrors the whole space; deleting is explicit.** The
339
+ sync writes every entry the space has. Removing a file does NOT delete
340
+ the entry: the plan reports it as \`orphan\`. To delete: remove the file
341
+ in a pull request, merge, then run **Actions → Datum space → Run workflow**
342
+ on \`main\` with **prune** ticked (it deletes only within kinds \`${DIR}/\`
343
+ still declares). The panel refuses deleting some entries the repository
344
+ manages (a routine answers 409) — the repository is the way.
345
+ 5. **A new entry gets its defaults from the server.** The sync after the
346
+ merge brings the entry back with every field filled in (and a file name
347
+ derived from its key) — merge that pull request; it is not a panel edit.
348
+ 6. **Organisation-tier kinds do not live here** (\`features\`,
349
+ \`monthlyBudgetUsd\`, \`allocation\`, \`members\`, grants) — the org admin
350
+ decides them. A kind whose feature is off for the organisation is
351
+ refused by the plan: ask an org admin.
352
+ 7. **Natural keys, never ids**: agents by nick (\`id\`), routines and flows by
353
+ \`name\`, credentials by \`label\`; a flow step names its routine by name.
354
+ 8. **Domain content is Polish** — prompts, descriptions. Keys and file names
355
+ stay as \`pull\` wrote them.
356
+ 9. **Write values the way the server stores them.** When "Verify
357
+ convergence" fails after an apply, the server normalised a value: copy
358
+ the form the plan shows into the file.
359
+
360
+ ## Identity
361
+
362
+ \`datum login\` signs a person in with their own role in the space — the same
363
+ as in the panel (\`datum whoami --space ${space}\` shows it). Any member can read
364
+ the schema and validate; a space admin can also pull and plan from a
365
+ workstation. The CI plans and applies with \`${SPACE_REPO_KEY_SECRET}\`, a key
366
+ bound to this space that lives only in the repository's secrets.
367
+
368
+ The \`datum\` Claude Code plugin gives an agent the space's tools as the
369
+ signed-in person; \`datum a2a send --space ${space} "…"\` hands a question to
370
+ the space's own agent — ask it what a routine or a flow is for before
371
+ changing it.`;
372
+ }
373
+ /** The markers around the part of AGENTS.md / CLAUDE.md Datum owns (#1824):
374
+ * a second \`datum space-manifest init\` replaces what is between them with the
375
+ * current text — the team's own notes outside them stay. */
376
+ export const SPACE_REPO_MANAGED_BEGIN = "<!-- datum:managed:begin — written by `datum space-manifest init`; a re-run replaces this block. Add your own notes below the end marker. -->";
377
+ export const SPACE_REPO_MANAGED_END = "<!-- datum:managed:end -->";
378
+ function managedBlock(body) {
379
+ return `${SPACE_REPO_MANAGED_BEGIN}\n${body.trimEnd()}\n${SPACE_REPO_MANAGED_END}\n`;
380
+ }
381
+ /** The first line of an AGENTS.md written before the managed block existed
382
+ * (0.530.0) — such a file is Datum's whole, so a re-run replaces it. */
383
+ const LEGACY_AGENTS_TITLE = "# Working in the Datum space repository";
384
+ /**
385
+ * What a re-run of init writes into an existing AGENTS.md / CLAUDE.md:
386
+ * the block between the markers replaced; a pre-marker file Datum wrote
387
+ * whole (the legacy AGENTS.md, the one-line CLAUDE.md) replaced; any other
388
+ * file keeps its text and gets the block appended. `null` = already current.
389
+ */
390
+ export function refreshManagedFile(existing, generated) {
391
+ const begin = existing.indexOf(SPACE_REPO_MANAGED_BEGIN);
392
+ const end = existing.indexOf(SPACE_REPO_MANAGED_END);
393
+ const block = generated.slice(0, generated.indexOf(SPACE_REPO_MANAGED_END) + SPACE_REPO_MANAGED_END.length);
394
+ let next;
395
+ if (begin !== -1 && end > begin) {
396
+ next = existing.slice(0, begin) + block + existing.slice(end + SPACE_REPO_MANAGED_END.length);
397
+ }
398
+ else if (existing.startsWith(LEGACY_AGENTS_TITLE) || existing.trim() === "@AGENTS.md") {
399
+ next = generated;
400
+ }
401
+ else {
402
+ next = `${existing.trimEnd()}\n\n${block}\n`;
403
+ }
404
+ return next === existing ? null : next;
405
+ }
406
+ /** Files of the repository Datum owns whole (the workflows, the line-ending
407
+ * rule): `init --refresh` overwrites them with the current version — e.g.
408
+ * to get the apply workflow's prune input into a repository set up before. */
409
+ export const SPACE_REPO_DATUM_OWNED = new Set([
410
+ ".github/workflows/datum-space.yml",
411
+ ".github/workflows/datum-space-sync.yml",
412
+ ".gitattributes",
413
+ ]);
414
+ /** Files with a managed block — every re-run of init refreshes the block. */
415
+ export const SPACE_REPO_MANAGED_BLOCK_FILES = new Set(["AGENTS.md", "CLAUDE.md"]);
416
+ function readme(space) {
417
+ return `# Datum space \`${space}\` — configuration as code
418
+
419
+ The agents, routines, flows, connectors and boards of the Datum space
420
+ \`${space}\`, kept as files in \`${DIR}/\` and applied on every merge to \`main\`.
421
+
422
+ ## Changing the space
423
+
424
+ 1. Edit files under \`${DIR}/\` (prompts are \`*.prompt.md\`) and open a pull request.
425
+ 2. The CI comments the plan: what will be created, updated, left alone.
426
+ 3. Merge — the CI applies it and checks that a second plan is empty.
427
+
428
+ An edit made in the panel comes back as a "Sync z przestrzeni" pull request
429
+ on the next weekday morning. Merge it to keep the edit. To revert it, close
430
+ the pull request and run **Actions → Datum space → Run workflow** on main —
431
+ that re-applies the repository (an apply otherwise runs only when a merge
432
+ touches \`${DIR}/\`).
433
+
434
+ ## Setup (once)
435
+
436
+ | Where | What |
437
+ | --- | --- |
438
+ | Settings → Actions → General | **Allow GitHub Actions to create and approve pull requests** (off by default — the sync needs it) |
439
+ | Runners | the workflows run on \`[self-hosted, ubuntu-latest, ARM64]\` — the organisation's runner group must serve this repository |
440
+ | Repository variable \`${SPACE_REPO_BASE_URL_VAR}\` | the organisation host, e.g. \`https://datum.example.com\` |
441
+ | Repository secret \`${SPACE_REPO_KEY_SECRET}\` | a key bound to \`${space}\`, role **admin** — panel: Settings → API keys |
442
+ | First import | Actions → **Datum space sync** → Run workflow, then merge its pull request |
443
+
444
+ Rules for people and agents editing this repository: [AGENTS.md](AGENTS.md).
445
+ `;
446
+ }
447
+ /** Every file of a fresh space repository, keyed by its path. */
448
+ export function spaceRepoFiles(space) {
449
+ return {
450
+ ".github/workflows/datum-space.yml": planApplyWorkflow(space),
451
+ ".github/workflows/datum-space-sync.yml": syncWorkflow(space),
452
+ "AGENTS.md": `${managedBlock(agentsMd(space))}\n## Team notes\n\nYour team's own rules for this repository go here, below the managed block — \`datum space-manifest init\` keeps them.\n`,
453
+ "CLAUDE.md": managedBlock("@AGENTS.md"),
454
+ "README.md": readme(space),
455
+ ".gitignore": "plan.md\napply.md\nnode_modules/\n.DS_Store\n",
456
+ // Git for Windows checks text out with CRLF by default (core.autocrlf):
457
+ // a prompt file would then carry \r on every line and plan as changed.
458
+ // The manifest files stay LF on every machine, byte for byte as `pull`
459
+ // wrote them.
460
+ ".gitattributes": `${DIR}/** text eol=lf\n.github/workflows/** text eol=lf\n`,
461
+ };
462
+ }
463
+ //# sourceMappingURL=spaceRepoTemplate.js.map
@@ -0,0 +1,184 @@
1
+ /**
2
+ * `datum update` (#1512) — the foreground face of selfUpdate.ts.
3
+ *
4
+ * datum update install the release the server runs, when newer
5
+ * datum update --check what runs here, what the server offers, the mode
6
+ * datum update --to <version> switch to an installed version (rollback)
7
+ * datum update --to package back to the npm-installed package
8
+ * datum update --auto on|off the person's opt-out (DATUM_AUTOUPDATE wins over it)
9
+ * datum update --background the detached check the launcher starts (silent)
10
+ */
11
+ import { checkBundleSignature } from "./bundleSignature.js";
12
+ import { AUTOUPDATE_MIN_ONLY, AUTOUPDATE_OFF, CLI_PACKAGE_NAME, CLI_PACKAGE_NAMES, ENV_LAUNCHER_VERSION, autoUpdateMode, checkForUpdate, clearCurrent, compareVersions, describeWhen, effectiveVersion, fetchLatest, installDeferredUntil, installedVersions, isPackagedInstall, oneLine, readCurrent, readState, switchTo, takeNotice, writeState, } from "./selfUpdate.js";
13
+ const KNOWN_FLAGS = new Set(["check", "to", "auto", "background"]);
14
+ function parse(args) {
15
+ const flags = new Map();
16
+ for (let i = 0; i < args.length; i++) {
17
+ const t = args[i];
18
+ if (!t.startsWith("--"))
19
+ throw new Error(`unexpected argument \`${t}\` — see: datum update --help`);
20
+ const eq = t.indexOf("=");
21
+ const name = eq === -1 ? t.slice(2) : t.slice(2, eq);
22
+ if (!KNOWN_FLAGS.has(name))
23
+ throw new Error(`unknown flag --${name}. Known flags: --check, --to, --auto`);
24
+ if (name === "check" || name === "background") {
25
+ flags.set(name, "");
26
+ continue;
27
+ }
28
+ const value = eq === -1 ? args[++i] : t.slice(eq + 1);
29
+ if (value === undefined || value === "")
30
+ throw new Error(`--${name} needs a value`);
31
+ flags.set(name, value);
32
+ }
33
+ return flags;
34
+ }
35
+ /** The `--check` line after a failed install (#3325): whether — and when — it is tried again. */
36
+ function nextAttemptLine(state, latest, mode, running, now) {
37
+ if (mode === AUTOUPDATE_OFF)
38
+ return "→ automatic updates are off here; `datum update` retries now";
39
+ if (mode === AUTOUPDATE_MIN_ONLY && compareVersions(running, latest.minVersion) >= 0) {
40
+ return "→ min-only: no automatic attempt above the server's minimum; `datum update` retries now";
41
+ }
42
+ if (state.failedInstall?.integrity !== latest.package?.integrity) {
43
+ return "→ the server now serves a rebuilt bundle: the next automatic attempt tries it, `datum update` now";
44
+ }
45
+ const until = installDeferredUntil(state, latest, now);
46
+ return until !== null
47
+ ? `→ next automatic attempt after ${describeWhen(until, now)}; \`datum update\` retries now`
48
+ : "→ the next automatic attempt may run now";
49
+ }
50
+ /** The `--check` line for the server's bundle signature (#2612) — checkable without downloading it. */
51
+ function describeSignature(version, pkg, rootKeys) {
52
+ try {
53
+ // Only these two fields: anything else a server put in `package` (a
54
+ // `version`, say) must not stand in for the release it announced.
55
+ const check = checkBundleSignature({ version, integrity: pkg.integrity, signature: pkg.signature }, { rootKeys, packageNames: CLI_PACKAGE_NAMES });
56
+ return check.signed ? `signed by Datum (root key ${check.rootKeyId})` : `UNSIGNED — ${check.reason}`;
57
+ }
58
+ catch (err) {
59
+ return `INVALID — ${err.message}; this CLI will not install it`;
60
+ }
61
+ }
62
+ export async function runUpdate(ctx, io) {
63
+ let flags;
64
+ try {
65
+ flags = parse(ctx.args);
66
+ }
67
+ catch (err) {
68
+ io.err.write(`datum: ${err.message}\n`);
69
+ return 2;
70
+ }
71
+ const background = flags.has("background");
72
+ const packaged = isPackagedInstall(ctx.entryDir);
73
+ // Handed over by the npm-installed launcher? Then THAT is "the package".
74
+ const packageVersion = ctx.env[ENV_LAUNCHER_VERSION] ?? ctx.ownVersion;
75
+ if (flags.has("auto")) {
76
+ const v = flags.get("auto").toLowerCase();
77
+ if (v !== "on" && v !== "off") {
78
+ io.err.write("datum: --auto takes on or off\n");
79
+ return 2;
80
+ }
81
+ writeState(ctx.paths, { autoUpdate: v === "on" });
82
+ io.out.write(`Automatic updates ${v === "on" ? "enabled" : "disabled"} for this user (${ctx.paths.stateFile}).\n`);
83
+ const effective = autoUpdateMode(ctx.env, readState(ctx.paths));
84
+ if ((v === "on") !== (effective !== AUTOUPDATE_OFF)) {
85
+ io.out.write(`Note: the environment overrides it here — the effective mode is \`${effective}\`.\n`);
86
+ }
87
+ return 0;
88
+ }
89
+ if (flags.has("to")) {
90
+ const target = flags.get("to");
91
+ try {
92
+ if (target === "package") {
93
+ clearCurrent(ctx.paths);
94
+ io.out.write(`The next \`datum\` runs the npm-installed package (${packageVersion}).\n`);
95
+ }
96
+ else {
97
+ switchTo(ctx.paths, target);
98
+ io.out.write(`The next \`datum\` runs ${target}.\n`);
99
+ if (compareVersions(target, packageVersion) < 0) {
100
+ io.out.write(`Note: ${target} is older than the npm-installed package (${packageVersion}), which wins — use \`--to package\` or reinstall the package at ${target}.\n`);
101
+ }
102
+ }
103
+ return 0;
104
+ }
105
+ catch (err) {
106
+ io.err.write(`datum: ${err.message}\n`);
107
+ return 1;
108
+ }
109
+ }
110
+ if (!packaged) {
111
+ if (!background) {
112
+ io.err.write(`datum: this CLI runs from a repository build (the app image or a checkout), which never updates itself — only the published ${CLI_PACKAGE_NAME} package does.\n`);
113
+ }
114
+ return background ? 0 : 1;
115
+ }
116
+ if (!ctx.baseUrl) {
117
+ if (!background) {
118
+ io.err.write("datum: no server to ask — pass --base-url, set DATUM_BASE_URL or run `datum login` first\n");
119
+ }
120
+ return background ? 0 : 2;
121
+ }
122
+ if (flags.has("check")) {
123
+ const state = readState(ctx.paths);
124
+ const lines = [
125
+ `running: ${effectiveVersion(ctx.ownVersion, ctx.paths)}`,
126
+ `package: ${packageVersion} (npm-installed)`,
127
+ `pointer: ${readCurrent(ctx.paths) ?? "(none)"}`,
128
+ `installed: ${installedVersions(ctx.paths).join(", ") || "(none)"}`,
129
+ `mode: ${autoUpdateMode(ctx.env, state)}`,
130
+ ];
131
+ try {
132
+ const latest = await fetchLatest(ctx.baseUrl, ctx.deps.fetch, 10_000);
133
+ writeState(ctx.paths, { lastCheckAt: new Date(ctx.deps.now()).toISOString(), latest, lastError: undefined });
134
+ lines.push(`server: ${latest.version} (minimum ${latest.minVersion}${latest.package ? "" : ", no bundle to install"}) at ${ctx.baseUrl}`);
135
+ if (latest.package)
136
+ lines.push(`signature: ${describeSignature(latest.version, latest.package, ctx.deps.rootKeys)}`);
137
+ const running = effectiveVersion(ctx.ownVersion, ctx.paths);
138
+ lines.push(compareVersions(latest.version, running) > 0
139
+ ? `→ ${latest.version} is available: run \`datum update\``
140
+ : "→ up to date");
141
+ const failed = state.failedInstall;
142
+ if (failed && latest.package && failed.version === latest.version && compareVersions(latest.version, running) > 0) {
143
+ lines.push(`failed: installing ${failed.version} failed ${failed.count}× (last ${failed.at}) — ${failed.error}`, nextAttemptLine(state, latest, autoUpdateMode(ctx.env, state), running, ctx.deps.now()));
144
+ }
145
+ }
146
+ catch (err) {
147
+ lines.push(`server: unreachable — ${err.message}`);
148
+ }
149
+ io.out.write(`${lines.join("\n")}\n`);
150
+ return 0;
151
+ }
152
+ try {
153
+ const before = effectiveVersion(ctx.ownVersion, ctx.paths);
154
+ const outcome = await checkForUpdate({
155
+ baseUrl: ctx.baseUrl,
156
+ ownVersion: ctx.ownVersion,
157
+ paths: ctx.paths,
158
+ deps: ctx.deps,
159
+ // A person asking for the update now gets an attempt now (#3325).
160
+ ignoreBackoff: !background,
161
+ });
162
+ if (background)
163
+ return 0;
164
+ if (outcome.installed) {
165
+ io.out.write(`Updated ${before} → ${outcome.installed}. The next \`datum\` runs it.\n`);
166
+ const notice = takeNotice(ctx.paths);
167
+ if (notice)
168
+ io.err.write(notice);
169
+ }
170
+ else if (!outcome.latest.package && compareVersions(outcome.latest.version, before) > 0) {
171
+ io.out.write(`The server runs ${outcome.latest.version} but carries no CLI bundle — install it with \`npm i -g ${CLI_PACKAGE_NAME}@${outcome.latest.version}\`.\n`);
172
+ }
173
+ else {
174
+ io.out.write(`datum ${before} is up to date (the server runs ${outcome.latest.version}).\n`);
175
+ }
176
+ return 0;
177
+ }
178
+ catch (err) {
179
+ if (!background)
180
+ io.err.write(`datum: the update failed — ${oneLine(err.message)}\n`);
181
+ return background ? 0 : 1;
182
+ }
183
+ }
184
+ //# sourceMappingURL=updateCommand.js.map