@bongos/core 1.19.1053 → 1.19.1055
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/.bongos-core.json +32 -22
- package/docs/file-map.md +3 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/provisioning/control-plane-guard.js +14 -1
- package/modules/provisioning/provisioning.js +5 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/fitness.js +9 -1
- package/scripts/gds/page-inventory.js +348 -0
- package/scripts/gds/provision-units.js +38 -2
- package/scripts/gds/provision.js +6 -6
- package/scripts/gds/ship-check.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/core_upgrade_door.mjs +16 -2
- package/tests/page_inventory.mjs +166 -0
- package/tests/provision_settings_apply.mjs +84 -0
- package/tests/provisioning_settings_apply.mjs +50 -5
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.1055",
|
|
6
|
+
"core_contract": "1.19.1055",
|
|
7
|
+
"source_commit": "dc38a2a4e86d707edd38427cf7df3c4437458758",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-28T05:05:07.855Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 542,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2511,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 3080,
|
|
20
|
+
"tree_sha256": "b6c500e72b0b49f4521c50a3282616cfdf968b85b827eace71deb76fca3f55dd",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2727,7 +2727,7 @@
|
|
|
2727
2727
|
{
|
|
2728
2728
|
"path": "docs/file-map.md",
|
|
2729
2729
|
"mode": "0000644",
|
|
2730
|
-
"sha256": "
|
|
2730
|
+
"sha256": "fe02a043a43ec6c062efea669b07722ae08e5ef93555ca80e489504dbc0180ad"
|
|
2731
2731
|
},
|
|
2732
2732
|
{
|
|
2733
2733
|
"path": "docs/handoff-template.md",
|
|
@@ -2737,7 +2737,7 @@
|
|
|
2737
2737
|
{
|
|
2738
2738
|
"path": "docs/module-api-changelog.md",
|
|
2739
2739
|
"mode": "0000644",
|
|
2740
|
-
"sha256": "
|
|
2740
|
+
"sha256": "5a093f82bbd86f7932a798a5e53f85609e08c7837ef64c2334a7a2b8efc401c1"
|
|
2741
2741
|
},
|
|
2742
2742
|
{
|
|
2743
2743
|
"path": "docs/modules-contract.md",
|
|
@@ -6897,7 +6897,7 @@
|
|
|
6897
6897
|
{
|
|
6898
6898
|
"path": "modules/provisioning/control-plane-guard.js",
|
|
6899
6899
|
"mode": "0000644",
|
|
6900
|
-
"sha256": "
|
|
6900
|
+
"sha256": "beb559945e2b00edfab521a65e2c8b91671a35e8a67f6d3be30d39a7a7d8118c"
|
|
6901
6901
|
},
|
|
6902
6902
|
{
|
|
6903
6903
|
"path": "modules/provisioning/core-upgrade-authz.js",
|
|
@@ -7107,7 +7107,7 @@
|
|
|
7107
7107
|
{
|
|
7108
7108
|
"path": "modules/provisioning/provisioning.js",
|
|
7109
7109
|
"mode": "0000644",
|
|
7110
|
-
"sha256": "
|
|
7110
|
+
"sha256": "1a4823ad0fde877d954faf5650942cb39ab063541433ac101cec04b7cb03d980"
|
|
7111
7111
|
},
|
|
7112
7112
|
{
|
|
7113
7113
|
"path": "modules/provisioning/public-refusal.js",
|
|
@@ -8462,12 +8462,12 @@
|
|
|
8462
8462
|
{
|
|
8463
8463
|
"path": "package-lock.json",
|
|
8464
8464
|
"mode": "0000644",
|
|
8465
|
-
"sha256": "
|
|
8465
|
+
"sha256": "b60b03c560a09917e3383f0630839ff29ffade876f87f0079f1cb718dac762e6"
|
|
8466
8466
|
},
|
|
8467
8467
|
{
|
|
8468
8468
|
"path": "package.json",
|
|
8469
8469
|
"mode": "0000644",
|
|
8470
|
-
"sha256": "
|
|
8470
|
+
"sha256": "21389dd422886a6fcf8088ac5be8dd63f39e76f6c28fd063f3cd72e9eadefc2c"
|
|
8471
8471
|
},
|
|
8472
8472
|
{
|
|
8473
8473
|
"path": "public-docs/index.html",
|
|
@@ -8487,7 +8487,7 @@
|
|
|
8487
8487
|
{
|
|
8488
8488
|
"path": "release-notes.json",
|
|
8489
8489
|
"mode": "0000644",
|
|
8490
|
-
"sha256": "
|
|
8490
|
+
"sha256": "3631f0c336ffb1fa2da262932c3158a4cb79c640c52ffc04e08a21b23c8f4534"
|
|
8491
8491
|
},
|
|
8492
8492
|
{
|
|
8493
8493
|
"path": "scripts/bongos-mcp.js",
|
|
@@ -9062,7 +9062,7 @@
|
|
|
9062
9062
|
{
|
|
9063
9063
|
"path": "scripts/gds/fitness.js",
|
|
9064
9064
|
"mode": "0000644",
|
|
9065
|
-
"sha256": "
|
|
9065
|
+
"sha256": "6ea5ed4e24c83e50d3931e3a5a769b9f37d029627540d410e772200d5cc3652d"
|
|
9066
9066
|
},
|
|
9067
9067
|
{
|
|
9068
9068
|
"path": "scripts/gds/gate-review.js",
|
|
@@ -9384,6 +9384,11 @@
|
|
|
9384
9384
|
"mode": "0000644",
|
|
9385
9385
|
"sha256": "aa29af97270a614699e5480e0db5b7bf34034958cc28e92bee5a04f7c5c95bec"
|
|
9386
9386
|
},
|
|
9387
|
+
{
|
|
9388
|
+
"path": "scripts/gds/page-inventory.js",
|
|
9389
|
+
"mode": "0000644",
|
|
9390
|
+
"sha256": "f4b676c3949015733bc3f1bcaf369c2ab1225583b0200f862c25a6f0f18d3873"
|
|
9391
|
+
},
|
|
9387
9392
|
{
|
|
9388
9393
|
"path": "scripts/gds/paste-token.js",
|
|
9389
9394
|
"mode": "0000644",
|
|
@@ -9477,12 +9482,12 @@
|
|
|
9477
9482
|
{
|
|
9478
9483
|
"path": "scripts/gds/provision-units.js",
|
|
9479
9484
|
"mode": "0000644",
|
|
9480
|
-
"sha256": "
|
|
9485
|
+
"sha256": "b35e21dabea1b2b45242c4744a743926259e593e01fe337d464dc4545588309d"
|
|
9481
9486
|
},
|
|
9482
9487
|
{
|
|
9483
9488
|
"path": "scripts/gds/provision.js",
|
|
9484
9489
|
"mode": "0000644",
|
|
9485
|
-
"sha256": "
|
|
9490
|
+
"sha256": "bb767eea3debd87ad13ea859d85267b5b6be88c2eadf3c3ac236408c58f84c23"
|
|
9486
9491
|
},
|
|
9487
9492
|
{
|
|
9488
9493
|
"path": "scripts/gds/publish-credential-check.js",
|
|
@@ -9842,7 +9847,7 @@
|
|
|
9842
9847
|
{
|
|
9843
9848
|
"path": "scripts/gds/ship-check.js",
|
|
9844
9849
|
"mode": "0000644",
|
|
9845
|
-
"sha256": "
|
|
9850
|
+
"sha256": "3ca2e32eaea17ef03a91606b3815551a8cfdd3987b6e182c08017c84c7947ee3"
|
|
9846
9851
|
},
|
|
9847
9852
|
{
|
|
9848
9853
|
"path": "scripts/gds/ship-deploy-target.js",
|
|
@@ -10562,7 +10567,7 @@
|
|
|
10562
10567
|
{
|
|
10563
10568
|
"path": "src/module-api.js",
|
|
10564
10569
|
"mode": "0000644",
|
|
10565
|
-
"sha256": "
|
|
10570
|
+
"sha256": "22a1cf07ea07585df430c3a395af379d9a6bb81b20d5bf647128272568b57580"
|
|
10566
10571
|
},
|
|
10567
10572
|
{
|
|
10568
10573
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11527,7 +11532,7 @@
|
|
|
11527
11532
|
{
|
|
11528
11533
|
"path": "tests/core_upgrade_door.mjs",
|
|
11529
11534
|
"mode": "0000644",
|
|
11530
|
-
"sha256": "
|
|
11535
|
+
"sha256": "52b55a04a99870a1501f2a422096337e3426a7624ec6e1728f291e426eb9567b"
|
|
11531
11536
|
},
|
|
11532
11537
|
{
|
|
11533
11538
|
"path": "tests/core_upgrade_owner_door.mjs",
|
|
@@ -13629,6 +13634,11 @@
|
|
|
13629
13634
|
"mode": "0000644",
|
|
13630
13635
|
"sha256": "3b24dc37c492b248242568db7dd9d508e17e05aa746bb719952b8a06a58c395f"
|
|
13631
13636
|
},
|
|
13637
|
+
{
|
|
13638
|
+
"path": "tests/page_inventory.mjs",
|
|
13639
|
+
"mode": "0000644",
|
|
13640
|
+
"sha256": "363855afde70c909be7b6392d36663848c823afed0131a8785286b7b93ca321b"
|
|
13641
|
+
},
|
|
13632
13642
|
{
|
|
13633
13643
|
"path": "tests/parallel-load.mjs",
|
|
13634
13644
|
"mode": "0000644",
|
|
@@ -13952,7 +13962,7 @@
|
|
|
13952
13962
|
{
|
|
13953
13963
|
"path": "tests/provision_settings_apply.mjs",
|
|
13954
13964
|
"mode": "0000644",
|
|
13955
|
-
"sha256": "
|
|
13965
|
+
"sha256": "edcaec20fe9fe6ae0c6fd542516d10ffb3412ab009f356af3ee52abca939342b"
|
|
13956
13966
|
},
|
|
13957
13967
|
{
|
|
13958
13968
|
"path": "tests/provision_teardown_address.mjs",
|
|
@@ -14072,7 +14082,7 @@
|
|
|
14072
14082
|
{
|
|
14073
14083
|
"path": "tests/provisioning_settings_apply.mjs",
|
|
14074
14084
|
"mode": "0000644",
|
|
14075
|
-
"sha256": "
|
|
14085
|
+
"sha256": "550f67b56dd92d413a1bafc70d76375df9c02d84f9d3e4dfd5789697ae0164f0"
|
|
14076
14086
|
},
|
|
14077
14087
|
{
|
|
14078
14088
|
"path": "tests/provisioning_settings_env.mjs",
|
package/docs/file-map.md
CHANGED
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
│ ├── file-map.notes.json ← ADR 0066 ([#1276](https://example.com/builders#/task/1276)): the sidecar of one-line notes for each skill/routine that gen-file-map.js renders into file-map.md; the ONE place to edit those notes — a new skill with no note here fails CI
|
|
49
49
|
│ ├── copy-registry.json ← GENERATED (scripts/gds/copy-inventory.js): the machine-readable copy registry — 1,096 user-facing strings with stable ids, render paths, and duplicates. R02's flag queue attaches to these ids; regenerate + commit, never hand-edit
|
|
50
50
|
│ ├── copy-inventory.md ← GENERATED twin of copy-registry.json: the human-readable copy report, including the "what this cannot see" section (server-side messages, skill prose, Discord copy, runtime-composed strings, and the src/ui-vs-reality surface mismatch)
|
|
51
|
+
│ ├── page-inventory.json ← GENERATED (scripts/gds/page-inventory.js, BV2.TW02 task 1004314): every page of every enabled web surface (builders hall, landing, status) with its stable id <surface>:<page>, title, portable url, the files that render it and its .states.json states. Tweak Mode keys page status, claims and pay on these ids; regenerate + commit, never hand-edit
|
|
52
|
+
│ ├── page-inventory.md ← GENERATED twin of page-inventory.json: the id rule, then one table per surface
|
|
51
53
|
│ ├── handoff-template.md ← session-end template + Definition of Done
|
|
52
54
|
│ ├── module-api-changelog.md ← the `CORE_VERSION` release ledger for the published doorway (ADR 0203, task 1003335). MOVED here out of src/module-api.js: the ADR 0161 CI carrier appends an entry on every release and pushes straight to main, which grew that .js file past the 1500-line oversized-file ratchet and turned `unit` red for main and every open PR. Markdown is exempt from the .js line budget. Written by scripts/gds/bump-version.js (entries oldest-first, each inserted above the closing divider); the CORE_VERSION literal itself stays in src/module-api.js
|
|
53
55
|
│ ├── google-chat-broadcast.md ← team-chat integration plan
|
|
@@ -199,6 +201,7 @@
|
|
|
199
201
|
│ ├── docs-entropy.js ← ([#…] docs-entropy) markdown-corpus linter: broken internal links + cross-file duplicate-prose detector across every tracked `.md`; `--verbose` / `--file-ideas`
|
|
200
202
|
│ ├── doc-comment-coverage.js ← ADR 0095 / task 1001654: the ADVISORY doc-comment nudge (fitness Check 28) — reports source files under src/·scripts/·modules/ that open with no module header. Never hard-fails: a doc-comment gate that blocks a ship buys filler prose, and making it blocking needs owner sign-off + its own ADR. CRLF-safe (unlike docs-entropy.js); an empty enumeration throws as a scan defect
|
|
201
203
|
│ ├── copy-inventory.js ← R01 of goal 1000074 (the artist's loop): the COPY REGISTRY — every user-facing string across the hall / status / landing surfaces, each row naming the render path that shows it (html-text · html-attr · js-markup · js-notice · js-text-assign) + a stable id that survives a move. Writes docs/copy-registry.json + docs/copy-inventory.md; `--check` fails on drift. Precision over recall: a JS literal with no render path is not listed
|
|
204
|
+
│ ├── page-inventory.js ← BV2.TW02 (task 1004314): the PAGE INVENTORY — surfaces from the loader's moduleWebSurfaces() over the ENABLED modules (no hardcoded list), pages from each surface's *.html, ids <surface>:<page> (page = body[data-page] else the file name). Writes docs/page-inventory.json + .md; `--check` (run by fitness.js + ship-check.js) fails on drift, a duplicate id or an empty surface
|
|
202
205
|
│ ├── copy-apply.js ← R03 of goal 1000074 (ADR 0233): lands a copy proposal as a REAL DIFF under a claim — the design half’s adapter.export() applied to text. Reads the fenced copy-proposal patch off a task (--task N; --dry-run, --patch-file), RE-DERIVES the string’s position by re-running copy-inventory’s own extractor over the working tree (the registry commits no offsets — they go stale, the id does not), re-injects the source’s own ${expr} into the proposal’s {…} holes, writes, regenerates the registry and verifies its own edit. Refuses BY NAME on target_gone / ambiguous_target / no_exact_span / placeholder_mismatch / path_outside_surface. The ONLY writer in the copy feature, and deliberately a command rather than a route
|
|
203
206
|
│ ├── gen-role-responsibilities.js ← writes each role pack's written-responsibility block from src/role-responsibilities.js (task 1003732, ADR 0275). Targets come from the role registry, not a hardcoded list, so a renamed pack or a fourth craft needs no edit here; the block takes the pack's own line ending (the packs are CRLF on Windows, LF in CI). `--check` is the CI gate, run by role-pack-guard.js — never hand-edit a block
|
|
204
207
|
│ ├── role-pack-guard.js ← the fitness check over the role packs (task 1002990, ADR 0274): every registry entry resolves to a real pack, every pack fits its char budget, three crafts carry one, and each pack's responsibility block is current. HARD-fails because claim.js reads the registry fail-open, so a broken pack path is silent at runtime
|
|
@@ -2593,5 +2593,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2593
2593
|
landed since 1.19.1051 with no explicit bump. run 36372902384. (task 1002620)
|
|
2594
2594
|
1.19.1053 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2595
2595
|
landed since 1.19.1052 with no explicit bump. run 36375859084. (task 1002620)
|
|
2596
|
+
1.19.1054 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2597
|
+
landed since 1.19.1053 with no explicit bump. run 36378326588. (task 1002620)
|
|
2598
|
+
1.19.1055 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2599
|
+
landed since 1.19.1054 with no explicit bump. run 36380323033. (task 1002620)
|
|
2596
2600
|
---------------------------------------------------------------------------
|
|
2597
2601
|
```
|
|
@@ -40,10 +40,23 @@ const { PublicRefusal } = require('./public-refusal');
|
|
|
40
40
|
// the provisioning module — so its hall is the only one with an
|
|
41
41
|
// Upgrade page at all. A preview that cannot run where the button
|
|
42
42
|
// lives is not a preview (task 1004125).
|
|
43
|
+
// settings-apply
|
|
44
|
+
// — push a SAVED setting into the box's own web.env, then restart
|
|
45
|
+
// (task 1004334). It is `restart` plus an in-place edit of the
|
|
46
|
+
// settings lines, and nothing else: the runner leg re-reads the
|
|
47
|
+
// file, replaces only the vocabulary keys (SETTINGS_VOCAB plus its
|
|
48
|
+
// declared companions) and refuses outright if the file cannot be
|
|
49
|
+
// read, so it cannot blank a credential, a port or the database
|
|
50
|
+
// name. It deletes nothing, provisions nothing and moves no pin.
|
|
51
|
+
// Refusing it here protected the box from nothing and left the
|
|
52
|
+
// owner's saved settings on the platform's own row unable to reach
|
|
53
|
+
// the live site — PATCH …/settings on this row stored 'off' while
|
|
54
|
+
// cloudbongos.com kept reading 'strict', and the only remedy was a
|
|
55
|
+
// hand edit on a machine the owner cannot reach.
|
|
43
56
|
//
|
|
44
57
|
// Adding to this list is a deliberate edit someone has to argue for. It is pinned
|
|
45
58
|
// by name in tests/core_upgrade_door.mjs so it cannot grow as a side effect.
|
|
46
|
-
const CONTROL_PLANE_ACTIONS = Object.freeze(['core-upgrade', 'core-upgrade-preflight', 'restart']);
|
|
59
|
+
const CONTROL_PLANE_ACTIONS = Object.freeze(['core-upgrade', 'core-upgrade-preflight', 'restart', 'settings-apply']);
|
|
47
60
|
|
|
48
61
|
/**
|
|
49
62
|
* May this intent action be aimed at an instance of this hosting shape? PURE.
|
|
@@ -104,8 +104,9 @@ const MAX_RELEASED_DOMAINS = 8;
|
|
|
104
104
|
// excludes 'active' (task 1002691).
|
|
105
105
|
const SETTINGS_APPLY_FROM = new Set(['active']);
|
|
106
106
|
// The hosting shapes the runner has a write path to. 'byo-host' is absent (the owner runs
|
|
107
|
-
// it, we reach into nothing)
|
|
108
|
-
|
|
107
|
+
// it, we reach into nothing). 'control-plane' is the runner's OWN box, admitted by name in
|
|
108
|
+
// control-plane-guard.js (task 1004334) so a setting saved on the platform's row reaches it.
|
|
109
|
+
const SETTINGS_APPLY_SHAPES = new Set(['co-tenant', 'cloud-host', 'control-plane']);
|
|
109
110
|
// The ONE state a restart is meaningful from (task 1003238). Liveness is an
|
|
110
111
|
// OVERLAY, not a seventh state — an instance the heartbeat cannot reach is still
|
|
111
112
|
// 'active', and 'active' is exactly where a restart applies. Every earlier state
|
|
@@ -115,8 +116,8 @@ const SETTINGS_APPLY_SHAPES = new Set(['co-tenant', 'cloud-host']);
|
|
|
115
116
|
// SETTINGS_APPLY_FROM: each action declaring its own gate is this module's
|
|
116
117
|
// convention, and the two answer different questions.
|
|
117
118
|
const RESTART_FROM = new Set(['active']);
|
|
118
|
-
// Same reachability rule as the settings push
|
|
119
|
-
// the
|
|
119
|
+
// Same reachability rule as the settings push (the runner can only restart what it runs),
|
|
120
|
+
// minus 'control-plane': task 1004334 gave the self row a settings push, not a restart door.
|
|
120
121
|
const RESTART_SHAPES = new Set(['co-tenant', 'cloud-host']);
|
|
121
122
|
|
|
122
123
|
// Per-project settings (task 1003040, goal 1000058): the known keys + their
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1055",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.1055",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1055",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -7619,5 +7619,17 @@
|
|
|
7619
7619
|
"id": "1004328",
|
|
7620
7620
|
"text": "Your thinking and the project map are now two separate pages that share only their drawing. Your thinking keeps just your own views, simple and your thinking, with your own tools: the list, recent, claude layer, my influence,"
|
|
7621
7621
|
}
|
|
7622
|
+
],
|
|
7623
|
+
"1.19.1054": [
|
|
7624
|
+
{
|
|
7625
|
+
"id": "1004334",
|
|
7626
|
+
"text": "The platform can now apply a saved setting to its own site, not just to the projects it hosts. Turning the artist gate off on Cloud Bongos will actually reach cloudbongos.com: the platform updates its own settings file and res"
|
|
7627
|
+
}
|
|
7628
|
+
],
|
|
7629
|
+
"1.19.1055": [
|
|
7630
|
+
{
|
|
7631
|
+
"id": "1004314",
|
|
7632
|
+
"text": "Every page the project serves now has a name that stays put. A new generated list names all 37 pages across the builders hall (30), the landing site (5) and the status site (2). Each entry gives the page a short id such as bui"
|
|
7633
|
+
}
|
|
7622
7634
|
]
|
|
7623
7635
|
}
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -243,9 +243,17 @@ function checkGeneratedArtifactsFresh() {
|
|
|
243
243
|
} catch {
|
|
244
244
|
violations.push('docs/copy-registry.json + docs/copy-inventory.md are STALE — every row carries a line number, so editing any scanned UI file shifts them. Run `node scripts/gds/copy-inventory.js` and commit (task 1003556).');
|
|
245
245
|
}
|
|
246
|
+
try {
|
|
247
|
+
// task 1004314 (BV2.TW02): the page inventory is the key Tweak Mode's page status,
|
|
248
|
+
// claims and pay join on, so a page added, renamed or retitled without it would
|
|
249
|
+
// be a page the chain cannot see. --check also fails on a duplicate or malformed id.
|
|
250
|
+
execFileSync(process.execPath, [path.join(__dirname, 'page-inventory.js'), '--check'], { cwd: REPO_ROOT, stdio: 'pipe' });
|
|
251
|
+
} catch {
|
|
252
|
+
violations.push('docs/page-inventory.json + docs/page-inventory.md are STALE (or two pages claim one id) — a page was added, renamed, retitled or changed its scripts/states. Run `node scripts/gds/page-inventory.js` and commit (task 1004314).');
|
|
253
|
+
}
|
|
246
254
|
// NB the role packs' responsibility blocks are generated too, but their freshness
|
|
247
255
|
// is asserted by role-pack-guard.js — one check owns "the packs are correct".
|
|
248
|
-
return { name: 'generated artifacts fresh', ok: violations.length === 0, hardFail: violations.length > 0, violations, warnings: [], note: 'Tracked generated content matches source: nested CLAUDE.md symbol blocks (code) + the CLAUDE.md §13 snippet (sessions) via --check-block, plus the CLAUDE.md §2 interaction-posture block (the knob defaults), docs/file-map.md skills/scheduled-tasks sections, docs/api/openapi.json + api-reference.md (API)
|
|
256
|
+
return { name: 'generated artifacts fresh', ok: violations.length === 0, hardFail: violations.length > 0, violations, warnings: [], note: 'Tracked generated content matches source: nested CLAUDE.md symbol blocks (code) + the CLAUDE.md §13 snippet (sessions) via --check-block, plus the CLAUDE.md §2 interaction-posture block (the knob defaults), docs/file-map.md skills/scheduled-tasks sections, docs/api/openapi.json + api-reference.md (API), docs/copy-registry.json + copy-inventory.md (the reader-facing copy, task 1003556) and docs/page-inventory.json + page-inventory.md (every page and its stable id, task 1004314). The gitignored docs/repo-map.md + docs/session-log-index.md are deploy-regenerated (ADR 0110), not gated here.' };
|
|
249
257
|
}
|
|
250
258
|
|
|
251
259
|
// ---- deterministic cron routine boundary (System 4 / ADR 0078) -------------
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
//
|
|
4
|
+
// scripts/gds/page-inventory.js — the PAGE INVENTORY: every page of every web
|
|
5
|
+
// surface an instance serves, each with a stable id (BV2.TW02, task 1004314;
|
|
6
|
+
// goal 1000095, the Tweak Mode chain — docs/specs/bongos-v2-tweak-mode.md).
|
|
7
|
+
//
|
|
8
|
+
// WHY. Tweak Mode's unit is the PAGE: its status, claim, draft, changelog and pay
|
|
9
|
+
// all key on a page id (spec, decision 1). Until now a page was implicit — a
|
|
10
|
+
// body[data-page] attribute in the hall, a file name on the landing and status
|
|
11
|
+
// surfaces — so nothing could say "these are the 42 pages, and this is the one you
|
|
12
|
+
// hold". This generator makes the list explicit, and gives every page an id the
|
|
13
|
+
// rest of the chain (TW03 the ADR, TW04 the page reader, TW05 statuses, TW06
|
|
14
|
+
// claims) can store and join on.
|
|
15
|
+
//
|
|
16
|
+
// PORTABLE BY CONSTRUCTION. The surfaces are NOT a list in this file: they come
|
|
17
|
+
// from the module loader's moduleWebSurfaces() over the modules THIS instance has
|
|
18
|
+
// enabled (src/modules.js isModuleEnabled). An instance that disables its front
|
|
19
|
+
// door has no landing pages here; a module that ships a new web surface appears
|
|
20
|
+
// with no edit to this file. Same seam serve-internal.js and platform-server.js
|
|
21
|
+
// route from, so the inventory and the router cannot disagree about what exists.
|
|
22
|
+
//
|
|
23
|
+
// THE ID RULE (also written into both artifacts' headers — it is the contract):
|
|
24
|
+
// <surface>:<page>
|
|
25
|
+
// surface = the web surface's Host prefix without its dot ("builders." ->
|
|
26
|
+
// "builders", "status." -> "status"); the apex-root surface is
|
|
27
|
+
// "landing" (ADR 0218 allows exactly one apex root per instance).
|
|
28
|
+
// page = the page's own body[data-page] when it declares one (the hall's
|
|
29
|
+
// pages do), otherwise the html file's base name ("index.html" ->
|
|
30
|
+
// "index").
|
|
31
|
+
// Both halves are what a person already sees in an address or a file, so an id
|
|
32
|
+
// reads as a place: builders:studio, landing:index, status:explore. Ids are
|
|
33
|
+
// lowercase [a-z0-9-] on both sides; a page that would break that, or two pages
|
|
34
|
+
// sharing one id, fail the build by name rather than being renamed silently.
|
|
35
|
+
// An id changes ONLY when the host prefix, the data-page value or the file name
|
|
36
|
+
// changes — each a deliberate rename, and the ledger records keyed on the old id
|
|
37
|
+
// (TW05) must be carried across in the same change.
|
|
38
|
+
//
|
|
39
|
+
// DELIBERATELY IN THE gen-* FAMILY, modelled on copy-inventory.js: fs only (no DB,
|
|
40
|
+
// no network, no clock), byte-deterministic on a given tree, a committed JSON twin
|
|
41
|
+
// plus a human report, and a --check that fitness.js ("generated artifacts fresh")
|
|
42
|
+
// and ship-check.js run so drift fails before it merges.
|
|
43
|
+
//
|
|
44
|
+
// Run: node scripts/gds/page-inventory.js (writes both artifacts)
|
|
45
|
+
// node scripts/gds/page-inventory.js --check (exit 1 if stale or invalid)
|
|
46
|
+
// node scripts/gds/page-inventory.js --json (inventory to stdout, no write)
|
|
47
|
+
|
|
48
|
+
const fs = require('node:fs');
|
|
49
|
+
const path = require('node:path');
|
|
50
|
+
const { sameContent } = require('./gen-freshness'); // eol-insensitive freshness (CRLF checkouts)
|
|
51
|
+
|
|
52
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
53
|
+
// Repo-relative paths inside the committed artifacts are joined with '/', never
|
|
54
|
+
// path.join — a Windows run must write the same bytes CI compares against.
|
|
55
|
+
const INVENTORY_REL = 'docs/page-inventory.json';
|
|
56
|
+
const REPORT_REL = 'docs/page-inventory.md';
|
|
57
|
+
|
|
58
|
+
const ID_RULE = [
|
|
59
|
+
'id = <surface>:<page>.',
|
|
60
|
+
'surface = the web surface Host prefix without its dot (builders., status.) or "landing" for the apex-root surface.',
|
|
61
|
+
'page = the page body[data-page] when declared, otherwise the html file base name.',
|
|
62
|
+
'Both halves are lowercase [a-z0-9-]; a duplicate or malformed id fails the generator by name.',
|
|
63
|
+
'An id changes only when the host prefix, the data-page value or the file name changes; that is a rename, and records keyed on the old id move with it.',
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const SLUG = /^[a-z0-9][a-z0-9-]*$/;
|
|
67
|
+
|
|
68
|
+
// Locale-independent order (see copy-inventory.js: localeCompare depends on ICU).
|
|
69
|
+
function cmp(a, b) {
|
|
70
|
+
if (a === b) return 0;
|
|
71
|
+
return a < b ? -1 : 1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function toPosix(p) { return p.split(path.sep).join('/'); }
|
|
75
|
+
|
|
76
|
+
// The few entities hall titles actually use. Not a general decoder, on purpose.
|
|
77
|
+
function decodeEntities(s) {
|
|
78
|
+
return String(s)
|
|
79
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
80
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, ' ');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The surface slug, derived from the loader's descriptor (see THE ID RULE).
|
|
84
|
+
function surfaceSlug(ws) {
|
|
85
|
+
if (ws.apexRoot) return 'landing';
|
|
86
|
+
return String(ws.host || '').replace(/\.$/, '').toLowerCase();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Where a surface answers, as a portable address template. {apex} is the
|
|
90
|
+
// instance's own domain; the inventory never bakes one in.
|
|
91
|
+
function surfaceOrigin(ws) {
|
|
92
|
+
return ws.apexRoot ? 'https://{apex}' : `https://${ws.host}{apex}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The live path a page file is served at. Every surface resolves a bare slug to
|
|
96
|
+
// <slug>.html (the hall's buildersHtmlName, express.static extensions:['html'] on
|
|
97
|
+
// status, the landing routes), and index.html answers the root.
|
|
98
|
+
function pagePath(base) {
|
|
99
|
+
return base === 'index' ? '/' : `/${base}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Read the facts a page declares about itself, from its markup alone.
|
|
103
|
+
function readPageMarkup(source) {
|
|
104
|
+
const body = source.match(/<body\b([^>]*)>/i);
|
|
105
|
+
const attrs = body ? body[1] : '';
|
|
106
|
+
const attr = (name) => {
|
|
107
|
+
const m = attrs.match(new RegExp(`(?:^|\\s)${name}\\s*=\\s*"([^"]*)"`, 'i'));
|
|
108
|
+
return m ? decodeEntities(m[1]).trim() : null;
|
|
109
|
+
};
|
|
110
|
+
const title = source.match(/<title>([^<]*)<\/title>/i);
|
|
111
|
+
return {
|
|
112
|
+
dataPage: attr('data-page'),
|
|
113
|
+
dataPageTitle: attr('data-page-title'),
|
|
114
|
+
documentTitle: title ? decodeEntities(title[1]).replace(/\s+/g, ' ').trim() : null,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The local stylesheets and scripts a page pulls in, as repo-relative paths, in
|
|
119
|
+
// document order. A reference is resolved against the surface dir first, then with
|
|
120
|
+
// its first segment dropped (the hall addresses its assets as /builders/<file>),
|
|
121
|
+
// then against the repo (/modules/<key>/public/<file>, the loader's module-asset
|
|
122
|
+
// URL). Anything that does not resolve to a file on disk — a CDN, a font host, a
|
|
123
|
+
// data: URI — is not a file that renders the page here, and is left out.
|
|
124
|
+
function localAssets(source, surfaceDirAbs, { root = REPO_ROOT, fsImpl = fs } = {}) {
|
|
125
|
+
const refs = [];
|
|
126
|
+
const re = /<script\b[^>]*\bsrc\s*=\s*"([^"]+)"|<link\b[^>]*\brel\s*=\s*"stylesheet"[^>]*\bhref\s*=\s*"([^"]+)"|<link\b[^>]*\bhref\s*=\s*"([^"]+)"[^>]*\brel\s*=\s*"stylesheet"/gi;
|
|
127
|
+
let m;
|
|
128
|
+
while ((m = re.exec(source)) !== null) refs.push(m[1] || m[2] || m[3]);
|
|
129
|
+
const out = [];
|
|
130
|
+
for (const raw of refs) {
|
|
131
|
+
if (/^(?:[a-z]+:)?\/\//i.test(raw) || /^data:/i.test(raw)) continue;
|
|
132
|
+
const clean = raw.split(/[?#]/)[0];
|
|
133
|
+
if (!clean) continue;
|
|
134
|
+
const rel = clean.replace(/^\/+/, '');
|
|
135
|
+
const candidates = [
|
|
136
|
+
path.join(surfaceDirAbs, rel),
|
|
137
|
+
path.join(surfaceDirAbs, rel.split('/').slice(1).join('/')),
|
|
138
|
+
path.join(root, rel),
|
|
139
|
+
];
|
|
140
|
+
const hit = candidates.find((c) => {
|
|
141
|
+
try { return fsImpl.statSync(c).isFile(); } catch { return false; }
|
|
142
|
+
});
|
|
143
|
+
if (!hit) continue;
|
|
144
|
+
const repoRel = toPosix(path.relative(root, hit));
|
|
145
|
+
if (!out.includes(repoRel)) out.push(repoRel);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The .states.json sidecars for a page (the ui-design kit's convention:
|
|
151
|
+
// <dir>/<page>.states.json). A VARIANT file <base>-<variant>.states.json belongs to
|
|
152
|
+
// the page too — the landing's projects page keeps three — unless
|
|
153
|
+
// <base>-<variant>.html is itself a page, in which case it is that page's own.
|
|
154
|
+
function statesFor(base, names, surfaceDirAbs, surfaceRel) {
|
|
155
|
+
const pages = new Set(names.filter((n) => n.endsWith('.html')).map((n) => n.slice(0, -5)));
|
|
156
|
+
const files = names.filter((n) => n.endsWith('.states.json')).map((n) => n.slice(0, -'.states.json'.length))
|
|
157
|
+
.filter((stem) => {
|
|
158
|
+
if (stem === base) return true;
|
|
159
|
+
if (!stem.startsWith(`${base}-`)) return false;
|
|
160
|
+
if (pages.has(stem)) return false;
|
|
161
|
+
// The LONGEST page base that prefixes the stem owns it (a-b-c.states.json is
|
|
162
|
+
// a-b's, not a's, when a-b.html exists).
|
|
163
|
+
const owner = [...pages].filter((p) => stem === p || stem.startsWith(`${p}-`)).sort((x, y) => y.length - x.length)[0];
|
|
164
|
+
return owner === base;
|
|
165
|
+
})
|
|
166
|
+
.sort((a, b) => (a === base ? -1 : b === base ? 1 : cmp(a, b)));
|
|
167
|
+
return files.map((stem) => {
|
|
168
|
+
const abs = path.join(surfaceDirAbs, `${stem}.states.json`);
|
|
169
|
+
let parsed = null;
|
|
170
|
+
try { parsed = JSON.parse(fs.readFileSync(abs, 'utf8')); } catch (e) {
|
|
171
|
+
throw new Error(`page-inventory: ${surfaceRel}/${stem}.states.json is not valid JSON (${e.message})`);
|
|
172
|
+
}
|
|
173
|
+
const states = parsed && parsed.states && typeof parsed.states === 'object' ? Object.keys(parsed.states) : [];
|
|
174
|
+
return { file: `${surfaceRel}/${stem}.states.json`, page: typeof parsed.page === 'string' ? parsed.page : null, states };
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The enabled web surfaces, from the loader. Injected in tests.
|
|
179
|
+
function enabledSurfaces() {
|
|
180
|
+
const { moduleWebSurfaces } = require('../../src/module-loader/loader');
|
|
181
|
+
const { isModuleEnabled } = require('../../src/modules');
|
|
182
|
+
return moduleWebSurfaces({ isEnabled: isModuleEnabled });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function buildInventory({ surfaces = enabledSurfaces(), root = REPO_ROOT } = {}) {
|
|
186
|
+
const errors = [];
|
|
187
|
+
const pages = [];
|
|
188
|
+
const surfaceRows = [];
|
|
189
|
+
const seen = new Map();
|
|
190
|
+
const described = surfaces.map((ws) => ({ ws, slug: surfaceSlug(ws) }))
|
|
191
|
+
.sort((a, b) => cmp(a.slug, b.slug) || cmp(a.ws.key, b.ws.key));
|
|
192
|
+
const slugOwner = new Map();
|
|
193
|
+
for (const { ws, slug } of described) {
|
|
194
|
+
const surfaceRel = toPosix(path.relative(root, ws.dir));
|
|
195
|
+
if (!SLUG.test(slug)) { errors.push(`surface "${ws.key}" (${surfaceRel}) derives the id prefix "${slug}", which is not [a-z0-9-]`); continue; }
|
|
196
|
+
if (slugOwner.has(slug)) { errors.push(`surfaces "${slugOwner.get(slug)}" and "${ws.key}" both derive the id prefix "${slug}"`); continue; }
|
|
197
|
+
slugOwner.set(slug, ws.key);
|
|
198
|
+
const names = fs.readdirSync(ws.dir).sort(cmp);
|
|
199
|
+
const htmls = names.filter((n) => n.endsWith('.html'));
|
|
200
|
+
let count = 0;
|
|
201
|
+
for (const name of htmls) {
|
|
202
|
+
const base = name.slice(0, -5);
|
|
203
|
+
const rel = `${surfaceRel}/${name}`;
|
|
204
|
+
const source = fs.readFileSync(path.join(ws.dir, name), 'utf8');
|
|
205
|
+
const markup = readPageMarkup(source);
|
|
206
|
+
const pageSlug = markup.dataPage || base;
|
|
207
|
+
const id = `${slug}:${pageSlug}`;
|
|
208
|
+
if (!SLUG.test(pageSlug)) { errors.push(`${rel}: page "${pageSlug}" is not [a-z0-9-] (from ${markup.dataPage ? 'body[data-page]' : 'the file name'})`); continue; }
|
|
209
|
+
if (seen.has(id)) { errors.push(`${rel} and ${seen.get(id)} both claim the page id "${id}"`); continue; }
|
|
210
|
+
seen.set(id, rel);
|
|
211
|
+
const states = statesFor(base, names, ws.dir, surfaceRel);
|
|
212
|
+
pages.push({
|
|
213
|
+
id,
|
|
214
|
+
surface: slug,
|
|
215
|
+
module: ws.key,
|
|
216
|
+
page: pageSlug,
|
|
217
|
+
id_from: markup.dataPage ? 'data-page' : 'file-name',
|
|
218
|
+
title: markup.dataPageTitle || markup.documentTitle || pageSlug,
|
|
219
|
+
document_title: markup.documentTitle,
|
|
220
|
+
path: pagePath(base),
|
|
221
|
+
url: `${surfaceOrigin(ws)}${pagePath(base)}`,
|
|
222
|
+
files: [rel, ...localAssets(source, ws.dir, { root })],
|
|
223
|
+
states_files: states.map((s) => s.file),
|
|
224
|
+
states: states.flatMap((s) => s.states),
|
|
225
|
+
});
|
|
226
|
+
count += 1;
|
|
227
|
+
}
|
|
228
|
+
// A scan breaks by quietly finding nothing, and an empty surface would read as
|
|
229
|
+
// a clean --check. Every surface the loader returns ships a directory it serves
|
|
230
|
+
// pages from, so zero pages there is a scan defect, not a quiet surface.
|
|
231
|
+
if (count === 0 && htmls.length === 0) errors.push(`surface "${ws.key}" (${surfaceRel}) has no .html pages — a web surface with nothing to serve is a scan defect`);
|
|
232
|
+
surfaceRows.push({
|
|
233
|
+
id: slug, module: ws.key, at: surfaceRel,
|
|
234
|
+
answers_at: ws.apexRoot ? 'apex root' : `host prefix ${ws.host}`,
|
|
235
|
+
origin: surfaceOrigin(ws), pages: count,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (errors.length) {
|
|
239
|
+
const err = new Error(`page-inventory: ${errors.length} page id problem(s):\n - ${errors.join('\n - ')}`);
|
|
240
|
+
err.problems = errors;
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
pages.sort((a, b) => cmp(a.id, b.id));
|
|
244
|
+
return {
|
|
245
|
+
schema: 1,
|
|
246
|
+
generator: 'scripts/gds/page-inventory.js',
|
|
247
|
+
task: 1004314,
|
|
248
|
+
id_rule: ID_RULE,
|
|
249
|
+
counts: {
|
|
250
|
+
pages: pages.length,
|
|
251
|
+
surfaces: surfaceRows.length,
|
|
252
|
+
with_states: pages.filter((p) => p.states_files.length).length,
|
|
253
|
+
states: pages.reduce((n, p) => n + p.states.length, 0),
|
|
254
|
+
},
|
|
255
|
+
surfaces: surfaceRows,
|
|
256
|
+
pages,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function renderReport(inv) {
|
|
261
|
+
const L = [];
|
|
262
|
+
const esc = (s) => String(s == null ? '' : s).replace(/\|/g, '\\|');
|
|
263
|
+
L.push('# Page inventory — every page of every surface, with its stable id');
|
|
264
|
+
L.push('');
|
|
265
|
+
L.push('> **Generated** by `node scripts/gds/page-inventory.js` (BV2.TW02, task 1004314) from the web surfaces of the modules this instance has enabled.');
|
|
266
|
+
L.push('> Deterministic on a given tree — regenerate and commit rather than hand-editing. Machine-readable twin: [`page-inventory.json`](page-inventory.json).');
|
|
267
|
+
L.push('');
|
|
268
|
+
L.push('## The id rule');
|
|
269
|
+
L.push('');
|
|
270
|
+
for (const r of inv.id_rule) L.push(`- ${r}`);
|
|
271
|
+
L.push('');
|
|
272
|
+
L.push(`**${inv.counts.pages} pages** across ${inv.counts.surfaces} surfaces · ${inv.counts.with_states} with a states file · ${inv.counts.states} states in all.`);
|
|
273
|
+
L.push('');
|
|
274
|
+
L.push('## Surfaces');
|
|
275
|
+
L.push('');
|
|
276
|
+
L.push('| surface | module | lives at | answers at | pages |');
|
|
277
|
+
L.push('|---|---|---|---|---|');
|
|
278
|
+
for (const s of inv.surfaces) L.push(`| \`${s.id}\` | \`${s.module}\` | \`${s.at}\` | ${esc(s.answers_at)} | ${s.pages} |`);
|
|
279
|
+
L.push('');
|
|
280
|
+
for (const s of inv.surfaces) {
|
|
281
|
+
const rows = inv.pages.filter((p) => p.surface === s.id);
|
|
282
|
+
L.push(`## \`${s.id}\` — ${rows.length} pages`);
|
|
283
|
+
L.push('');
|
|
284
|
+
L.push('| id | title | url | files | states |');
|
|
285
|
+
L.push('|---|---|---|---|---|');
|
|
286
|
+
for (const p of rows) {
|
|
287
|
+
L.push(`| \`${p.id}\` | ${esc(p.title)} | \`${esc(p.url)}\` | ${p.files.length} | ${p.states.length ? esc(p.states.join(', ')) : '—'} |`);
|
|
288
|
+
}
|
|
289
|
+
L.push('');
|
|
290
|
+
}
|
|
291
|
+
L.push('---');
|
|
292
|
+
L.push('');
|
|
293
|
+
L.push('`{apex}` is the instance\'s own domain, and a title may still carry a `{{token}}` the server fills from the branding pack. The page files column counts the html plus the local stylesheets and scripts it loads; the JSON lists them.');
|
|
294
|
+
L.push('');
|
|
295
|
+
return L.join('\n');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// A missing file is stale by definition (sameContent would coerce null to '').
|
|
299
|
+
function matchesGenerated(have, want) {
|
|
300
|
+
if (have === null || have === undefined) return false;
|
|
301
|
+
return sameContent(have, want);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function write({ check = false, build = buildInventory } = {}) {
|
|
305
|
+
const inv = build();
|
|
306
|
+
const json = `${JSON.stringify(inv, null, 2)}\n`;
|
|
307
|
+
const md = renderReport(inv);
|
|
308
|
+
const targets = [[path.join(REPO_ROOT, INVENTORY_REL), json], [path.join(REPO_ROOT, REPORT_REL), md]];
|
|
309
|
+
if (check) {
|
|
310
|
+
const stale = [];
|
|
311
|
+
for (const [p, want] of targets) {
|
|
312
|
+
const have = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null;
|
|
313
|
+
if (!matchesGenerated(have, want)) stale.push(toPosix(path.relative(REPO_ROOT, p)));
|
|
314
|
+
}
|
|
315
|
+
return { inv, stale };
|
|
316
|
+
}
|
|
317
|
+
for (const [p, content] of targets) fs.writeFileSync(p, content);
|
|
318
|
+
return { inv, written: [INVENTORY_REL, REPORT_REL] };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (require.main === module) {
|
|
322
|
+
const args = process.argv.slice(2);
|
|
323
|
+
try {
|
|
324
|
+
if (args.includes('--json')) {
|
|
325
|
+
process.stdout.write(`${JSON.stringify(buildInventory(), null, 2)}\n`);
|
|
326
|
+
} else if (args.includes('--check')) {
|
|
327
|
+
const { stale } = write({ check: true });
|
|
328
|
+
if (stale.length) {
|
|
329
|
+
console.error(`page-inventory: STALE — ${stale.join(', ')} do not match the tree. Run: node scripts/gds/page-inventory.js`);
|
|
330
|
+
process.exitCode = 1;
|
|
331
|
+
} else {
|
|
332
|
+
console.log('page-inventory: artifacts match the tree.');
|
|
333
|
+
}
|
|
334
|
+
} else {
|
|
335
|
+
const { inv, written } = write();
|
|
336
|
+
console.log(`page-inventory: ${inv.counts.pages} pages across ${inv.counts.surfaces} surfaces -> wrote ${written.join(', ')}`);
|
|
337
|
+
}
|
|
338
|
+
} catch (e) {
|
|
339
|
+
console.error(e.message);
|
|
340
|
+
process.exitCode = 1;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
module.exports = {
|
|
345
|
+
INVENTORY_REL, REPORT_REL, ID_RULE,
|
|
346
|
+
cmp, surfaceSlug, surfaceOrigin, pagePath, readPageMarkup, localAssets, statesFor,
|
|
347
|
+
enabledSurfaces, buildInventory, renderReport, matchesGenerated, write,
|
|
348
|
+
};
|
|
@@ -304,9 +304,44 @@ function originEnvVarsFor(inst) {
|
|
|
304
304
|
};
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
-
|
|
307
|
+
// `envBody` is the web.env being patched; only the control-plane row reads it (see
|
|
308
|
+
// selfEnvPrefix). Every other shape keeps the provisioned-instance prefix, unchanged.
|
|
309
|
+
function settingsEnvVarsFor(inst, envBody) {
|
|
308
310
|
const { settingsEnvVars } = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning')); // eslint-disable-line global-require
|
|
309
|
-
|
|
311
|
+
const prefix = inst && inst.hosting_shape === 'control-plane' ? selfEnvPrefix({ envBody }) : provisionedEnvPrefix();
|
|
312
|
+
return settingsEnvVars(inst, prefix);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// The env-var prefix the CONTROL PLANE's own web process resolves (task 1004334). The
|
|
316
|
+
// self row is not a provisioned instance: it loads the pack src/branding.js loads, from
|
|
317
|
+
// ITS instance root, so provisionedEnvPrefix() (the neutral pack's floor) is the wrong
|
|
318
|
+
// question — a value written under a prefix the site does not resolve is read by
|
|
319
|
+
// nothing, and the push would report success over a site that never changed.
|
|
320
|
+
//
|
|
321
|
+
// Precedence mirrors loadBranding: the instance pack is a <PREFIX>_BRANDING_FILE line in
|
|
322
|
+
// the web.env being patched (the file the web service actually boots with) when there is
|
|
323
|
+
// one, else <instanceRoot>/config/branding.json; under it sits the core's neutral pack,
|
|
324
|
+
// then the fallback. The first that names an envPrefix wins. A wrong answer is not silent: the
|
|
325
|
+
// leg's read-back (settingsConsumed) compares the live manifest after the restart and
|
|
326
|
+
// records "still reports the OLD values".
|
|
327
|
+
function selfEnvPrefix({ envBody = '', instanceRoot, readFile = (p) => fs.readFileSync(p, 'utf8') } = {}) {
|
|
328
|
+
const { FALLBACK_ENV_PREFIX, LEGACY_ENV_PREFIXES } = require('../../src/instance-config');
|
|
329
|
+
const root = instanceRoot || require('./provision-repo.js').selfInstanceRoot(); // eslint-disable-line global-require
|
|
330
|
+
const names = [FALLBACK_ENV_PREFIX, ...LEGACY_ENV_PREFIXES].map((p) => `${p}_BRANDING_FILE`);
|
|
331
|
+
const override = String(envBody || '').split(/\r?\n/)
|
|
332
|
+
.map((l) => /^([A-Z]+_BRANDING_FILE)=(.*)$/.exec(l.trim()))
|
|
333
|
+
.find((m) => m && names.includes(m[1]) && m[2].trim());
|
|
334
|
+
const candidates = [
|
|
335
|
+
override ? path.resolve(root, override[2].trim()) : path.join(root, 'config', 'branding.json'),
|
|
336
|
+
path.join(REPO_ROOT, ENV_PREFIX_PACK),
|
|
337
|
+
];
|
|
338
|
+
for (const p of candidates) {
|
|
339
|
+
try {
|
|
340
|
+
const pack = JSON.parse(readFile(p));
|
|
341
|
+
if (pack && typeof pack.envPrefix === 'string' && pack.envPrefix) return pack.envPrefix;
|
|
342
|
+
} catch { /* absent or unreadable — the next candidate answers */ }
|
|
343
|
+
}
|
|
344
|
+
return FALLBACK_ENV_PREFIX;
|
|
310
345
|
}
|
|
311
346
|
|
|
312
347
|
// The instance's own public manifest — the settings read-back ADR 0190 names
|
|
@@ -375,6 +410,7 @@ module.exports = {
|
|
|
375
410
|
instanceManifestCmd,
|
|
376
411
|
originEnvVarsFor,
|
|
377
412
|
provisionedEnvPrefix,
|
|
413
|
+
selfEnvPrefix,
|
|
378
414
|
serviceUnit,
|
|
379
415
|
serviceUnitPath,
|
|
380
416
|
settingsConsumed,
|
package/scripts/gds/provision.js
CHANGED
|
@@ -875,12 +875,12 @@ async function domainAttachInstance(inst, deps) {
|
|
|
875
875
|
// ---------------------------------------------------------------------------
|
|
876
876
|
async function settingsApplyInstance(inst, deps) {
|
|
877
877
|
const { db, provisioning, exec, writeFile, log, apply, privileged } = deps;
|
|
878
|
-
//
|
|
879
|
-
const
|
|
880
|
-
const boxExec =
|
|
881
|
-
const boxWriteFile =
|
|
878
|
+
// A standalone runs control-plane-local; the control plane's own row IS this box (task 1004334).
|
|
879
|
+
const onControlBox = inst.hosting_shape === 'cloud-host' || inst.hosting_shape === 'control-plane';
|
|
880
|
+
const boxExec = onControlBox ? (deps.controlExec || exec) : exec;
|
|
881
|
+
const boxWriteFile = onControlBox ? (deps.controlWriteFile || writeFile) : writeFile;
|
|
882
882
|
const sudoP = privileged ? 'sudo ' : '';
|
|
883
|
-
const vars = settingsEnvVarsFor(inst);
|
|
883
|
+
const vars = settingsEnvVarsFor(inst); // the self row's prefix is re-read from the live file at the write
|
|
884
884
|
const shown = Object.entries(vars).map(([k, v]) => `${k}=${v}`).join(' ');
|
|
885
885
|
log(`settings-apply ${inst.slug} (shape=${inst.hosting_shape}, status=${inst.status}): ${shown}`);
|
|
886
886
|
|
|
@@ -917,7 +917,7 @@ async function settingsApplyInstance(inst, deps) {
|
|
|
917
917
|
}
|
|
918
918
|
try {
|
|
919
919
|
log(' [config] patch the settings lines of web.env in place (creds + origins untouched)');
|
|
920
|
-
boxWriteFile(webEnvPath(inst), upsertEnvVars(current,
|
|
920
|
+
boxWriteFile(webEnvPath(inst), upsertEnvVars(current, settingsEnvVarsFor(inst, current)), { mode: 0o600, sudo: true });
|
|
921
921
|
log(' [service] restart so the memoized branding pack re-reads them');
|
|
922
922
|
pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
|
|
923
923
|
} catch (e) {
|
|
@@ -34,6 +34,7 @@ const STEPS = [
|
|
|
34
34
|
{ key: 'file-map', label: 'docs/file-map.md skills/routines sections fresh + every module documented', argv: ['scripts/gds/gen-file-map.js', '--check'], fix: 'node scripts/gds/gen-file-map.js (+ a note in docs/file-map.notes.json for a new skill, or a modules/ entry in docs/file-map.md for a new module)' },
|
|
35
35
|
{ key: 'api-docs', label: 'docs/api/openapi.json + api-reference fresh', argv: ['scripts/gds/gen-api-docs.js', '--check'], fix: 'node scripts/gds/gen-api-docs.js' },
|
|
36
36
|
{ key: 'copy-registry', label: 'docs/copy-registry.json fresh', argv: ['scripts/gds/copy-inventory.js', '--check'], fix: 'node scripts/gds/copy-inventory.js', optional: true },
|
|
37
|
+
{ key: 'page-inventory', label: 'docs/page-inventory.json fresh (every page + its stable id)', argv: ['scripts/gds/page-inventory.js', '--check'], fix: 'node scripts/gds/page-inventory.js', optional: true },
|
|
37
38
|
{ key: 'linkify', label: 'no bare #NNN task refs in committed markdown', argv: ['scripts/gds/linkify-refs.js', '--check'], fix: 'node scripts/gds/linkify-refs.js', optional: true },
|
|
38
39
|
{ key: 'skill-lint', label: 'SKILL.md frontmatter parses', argv: ['scripts/gds/skill-lint.js'], fix: 'fix the ✗ lines (description: >- block scalars)' },
|
|
39
40
|
{ key: 'render-fit', label: 'rendered text fit + sibling overlap on the pages this branch changed (advisory)', argv: ['scripts/gds/render-check.js'], fix: 'look at the shots it names; fix the element, or say in the ship notes why it is right', advisory: true, optional: true, skipWith: '--no-render', slow: true },
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1055'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -102,18 +102,32 @@ await t('the allow-list is exactly the acts that cannot destroy the box', () =>
|
|
|
102
102
|
// dry run returns at pre-flight having moved no pin, run no npm and restarted
|
|
103
103
|
// nothing, and the control plane is the only instance whose hall HAS an Upgrade
|
|
104
104
|
// page — so refusing the preview there protected nothing and removed everything.
|
|
105
|
+
// Widened a second time, by 'settings-apply' (task 1004334), for the reason argued in
|
|
106
|
+
// the guard's own comment: it is restart plus an in-place edit of the settings lines,
|
|
107
|
+
// and without it a setting saved on the platform's row could never reach the live site.
|
|
105
108
|
assert.deepEqual([...G.CONTROL_PLANE_ACTIONS].sort(),
|
|
106
|
-
['core-upgrade', 'core-upgrade-preflight', 'restart']);
|
|
109
|
+
['core-upgrade', 'core-upgrade-preflight', 'restart', 'settings-apply']);
|
|
107
110
|
});
|
|
108
111
|
|
|
109
112
|
await t('the preview is permitted on the control plane — the one row that can use it', () => {
|
|
110
113
|
assert.equal(G.intentAllowedForShape('core-upgrade-preflight', 'control-plane'), true);
|
|
111
114
|
});
|
|
112
115
|
|
|
116
|
+
await t('the settings push is permitted on the control plane, and the guard says WHY by name (task 1004334)', () => {
|
|
117
|
+
assert.equal(G.intentAllowedForShape('settings-apply', 'control-plane'), true);
|
|
118
|
+
// The argument is part of the edit: an entry added without its reason is exactly the
|
|
119
|
+
// "grew as a side effect" this pin exists to stop.
|
|
120
|
+
const src = read('modules', 'provisioning', 'control-plane-guard.js');
|
|
121
|
+
const reason = src.slice(src.indexOf('// settings-apply'), src.indexOf('const CONTROL_PLANE_ACTIONS'));
|
|
122
|
+
assert.ok(reason.length > 0 && src.includes('// settings-apply'), 'the guard comment carries a settings-apply entry');
|
|
123
|
+
assert.match(reason, /task 1004334/, 'the reason names the task that argued it');
|
|
124
|
+
assert.match(reason, /deletes nothing/, 'the reason states the property that makes it safe');
|
|
125
|
+
});
|
|
126
|
+
|
|
113
127
|
await t('and widening it did NOT let the destructive acts through', () => {
|
|
114
128
|
// The whole risk of arguing one action onto an allow-list is arguing four.
|
|
115
129
|
for (const a of P.INTENT_ACTIONS) {
|
|
116
|
-
if (['core-upgrade', 'core-upgrade-preflight', 'restart'].includes(a)) continue;
|
|
130
|
+
if (['core-upgrade', 'core-upgrade-preflight', 'restart', 'settings-apply'].includes(a)) continue;
|
|
117
131
|
assert.equal(G.intentAllowedForShape(a, 'control-plane'), false,
|
|
118
132
|
`'${a}' must still be refused against the box the platform runs on`);
|
|
119
133
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// tests/page_inventory.mjs — the page inventory (BV2.TW02, task 1004314; the
|
|
2
|
+
// Tweak Mode chain, goal 1000095).
|
|
3
|
+
//
|
|
4
|
+
// What these tests defend:
|
|
5
|
+
// 1. COVERAGE. Every html page of every enabled web surface is listed — counted
|
|
6
|
+
// here from the tree independently of the generator, so a scanner that quietly
|
|
7
|
+
// finds less cannot pass.
|
|
8
|
+
// 2. THE ID RULE. <surface>:<page>, where the page half is body[data-page] when
|
|
9
|
+
// declared and the file name otherwise. TW03–TW06 store these ids.
|
|
10
|
+
// 3. DRIFT FAILS. --check reds on a stale artifact, on a duplicate id and on an
|
|
11
|
+
// empty surface — each by name.
|
|
12
|
+
// 4. PORTABILITY. Surfaces come from the loader over the ENABLED modules, so a
|
|
13
|
+
// disabled module's pages are absent with no edit to the generator.
|
|
14
|
+
// 5. DETERMINISM. The artifacts are committed; two runs give identical bytes.
|
|
15
|
+
//
|
|
16
|
+
// DB-free, network-free. Fixture surfaces are built in a fresh temp dir.
|
|
17
|
+
// Run: node --test tests/page_inventory.mjs
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { test } from 'node:test';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
|
+
const inv = require(path.join(ROOT, 'scripts', 'gds', 'page-inventory.js'));
|
|
30
|
+
const { moduleWebSurfaces } = require(path.join(ROOT, 'src', 'module-loader', 'loader.js'));
|
|
31
|
+
const { isModuleEnabled } = require(path.join(ROOT, 'src', 'modules.js'));
|
|
32
|
+
|
|
33
|
+
function fixtureRoot() {
|
|
34
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'page-inv-'));
|
|
35
|
+
}
|
|
36
|
+
function writeFiles(dir, files) {
|
|
37
|
+
for (const [name, body] of Object.entries(files)) {
|
|
38
|
+
const p = path.join(dir, name);
|
|
39
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
40
|
+
fs.writeFileSync(p, body);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const page = ({ dataPage, dataTitle, title = 'T', head = '' } = {}) => `<!doctype html><html><head><title>${title}</title>${head}</head>`
|
|
44
|
+
+ `<body${dataPage ? ` data-page="${dataPage}"` : ''}${dataTitle ? ` data-page-title="${dataTitle}"` : ''}><main>x</main></body></html>`;
|
|
45
|
+
|
|
46
|
+
test('COVERAGE: every html page of every enabled web surface is listed, and the three surfaces are there', () => {
|
|
47
|
+
const surfaces = moduleWebSurfaces({ isEnabled: isModuleEnabled });
|
|
48
|
+
const built = inv.buildInventory();
|
|
49
|
+
// Counted from the tree here, not taken from the generator.
|
|
50
|
+
let expected = 0;
|
|
51
|
+
for (const s of surfaces) expected += fs.readdirSync(s.dir).filter((n) => n.endsWith('.html')).length;
|
|
52
|
+
assert.ok(expected > 0, 'the enabled surfaces ship pages — a zero here is a scan defect in the test');
|
|
53
|
+
assert.equal(built.pages.length, expected, 'one inventory row per html page');
|
|
54
|
+
const ids = new Set(built.surfaces.map((s) => s.id));
|
|
55
|
+
for (const want of ['builders', 'landing', 'status']) assert.ok(ids.has(want), `surface ${want} listed (have: ${[...ids].join(', ')})`);
|
|
56
|
+
for (const p of built.pages) {
|
|
57
|
+
assert.match(p.id, /^[a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9-]*$/, p.id);
|
|
58
|
+
assert.ok(p.files.length >= 1 && p.files[0].endsWith('.html'), `${p.id} names its html first`);
|
|
59
|
+
for (const f of p.files) assert.ok(fs.existsSync(path.join(ROOT, f)), `${p.id}: ${f} exists`);
|
|
60
|
+
assert.ok(p.url.startsWith('https://') && p.url.includes('{apex}'), `${p.id}: a portable url, no baked-in domain`);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('THE ID RULE: a hall page is builders:<body[data-page]>, landing and status are <surface>:<file name>', () => {
|
|
65
|
+
const built = inv.buildInventory();
|
|
66
|
+
const byFile = new Map(built.pages.map((p) => [p.files[0], p]));
|
|
67
|
+
const hall = path.join(ROOT, 'modules', 'hall-ui', 'public');
|
|
68
|
+
for (const name of fs.readdirSync(hall).filter((n) => n.endsWith('.html'))) {
|
|
69
|
+
const dataPage = fs.readFileSync(path.join(hall, name), 'utf8').match(/<body\b[^>]*\bdata-page="([^"]+)"/)[1];
|
|
70
|
+
const row = byFile.get(`modules/hall-ui/public/${name}`);
|
|
71
|
+
assert.ok(row, `${name} is listed`);
|
|
72
|
+
assert.equal(row.id, `builders:${dataPage}`);
|
|
73
|
+
assert.equal(row.id_from, 'data-page');
|
|
74
|
+
}
|
|
75
|
+
assert.equal(byFile.get('modules/hall-ui/public/index.html').path, '/', 'the index answers the root');
|
|
76
|
+
assert.equal(byFile.get('modules/hall-ui/public/studio.html').id, 'builders:studio');
|
|
77
|
+
assert.equal(byFile.get('modules/public-landing/public/index.html').id, 'landing:index');
|
|
78
|
+
assert.equal(byFile.get('modules/status-ui/public/explore.html').id, 'status:explore');
|
|
79
|
+
assert.equal(byFile.get('modules/status-ui/public/explore.html').url, 'https://status.{apex}/explore');
|
|
80
|
+
// The rule is written into the artifact itself.
|
|
81
|
+
assert.ok(built.id_rule.some((r) => r.includes('<surface>:<page>')));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('STATES: a page carries its own .states.json and its <page>-<variant> files, never another page\'s', () => {
|
|
85
|
+
const root = fixtureRoot();
|
|
86
|
+
const dir = path.join(root, 'site');
|
|
87
|
+
writeFiles(dir, {
|
|
88
|
+
'a.html': page(), 'a-b.html': page(), 'c.html': page(),
|
|
89
|
+
'a.states.json': JSON.stringify({ page: '/a', states: { one: {}, two: {} } }),
|
|
90
|
+
'a-x.states.json': JSON.stringify({ page: '/a', states: { three: {} } }),
|
|
91
|
+
'a-b.states.json': JSON.stringify({ page: '/a-b', states: { own: {} } }),
|
|
92
|
+
'a-b-y.states.json': JSON.stringify({ page: '/a-b', states: { variant: {} } }),
|
|
93
|
+
});
|
|
94
|
+
const built = inv.buildInventory({ surfaces: [{ key: 'fx', host: 'fx.', dir }], root });
|
|
95
|
+
const get = (id) => built.pages.find((p) => p.id === id);
|
|
96
|
+
assert.deepEqual(get('fx:a').states, ['one', 'two', 'three']);
|
|
97
|
+
assert.deepEqual(get('fx:a').states_files, ['site/a.states.json', 'site/a-x.states.json']);
|
|
98
|
+
assert.deepEqual(get('fx:a-b').states, ['own', 'variant'], 'a-b-y belongs to a-b, the longest page prefix');
|
|
99
|
+
assert.deepEqual(get('fx:c').states, []);
|
|
100
|
+
assert.deepEqual(get('fx:c').states_files, []);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('FILES: local stylesheets and scripts resolve (the hall\'s /builders/ prefix too); CDNs and data: URIs do not', () => {
|
|
104
|
+
const root = fixtureRoot();
|
|
105
|
+
const dir = path.join(root, 'site');
|
|
106
|
+
writeFiles(dir, {
|
|
107
|
+
'p.html': page({ head: '<link rel="stylesheet" href="/builders/style.css?v=1"><link rel="icon" href="data:x"><link rel="stylesheet" href="https://fonts.example/css">'
|
|
108
|
+
+ '<script src="https://cdn.example/lib.js"></script><script src="/app.js"></script><script src="/app.js?v=2"></script><script src="/gone.js"></script>' }),
|
|
109
|
+
'style.css': '', 'app.js': '',
|
|
110
|
+
});
|
|
111
|
+
const built = inv.buildInventory({ surfaces: [{ key: 'fx', apexRoot: true, dir }], root });
|
|
112
|
+
assert.deepEqual(built.pages[0].files, ['site/p.html', 'site/style.css', 'site/app.js']);
|
|
113
|
+
assert.equal(built.pages[0].id, 'landing:p', 'the apex-root surface is "landing"');
|
|
114
|
+
assert.equal(built.pages[0].url, 'https://{apex}/p');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('DRIFT FAILS: a duplicate id, a malformed id and an empty surface each fail by name', () => {
|
|
118
|
+
const root = fixtureRoot();
|
|
119
|
+
const dup = path.join(root, 'dup');
|
|
120
|
+
writeFiles(dup, { 'one.html': page({ dataPage: 'same' }), 'two.html': page({ dataPage: 'same' }) });
|
|
121
|
+
assert.throws(() => inv.buildInventory({ surfaces: [{ key: 'fx', host: 'fx.', dir: dup }], root }),
|
|
122
|
+
(e) => /dup\/two\.html and dup\/one\.html both claim the page id "fx:same"/.test(e.message));
|
|
123
|
+
const bad = path.join(root, 'bad');
|
|
124
|
+
writeFiles(bad, { 'x.html': page({ dataPage: 'Not A Slug' }) });
|
|
125
|
+
assert.throws(() => inv.buildInventory({ surfaces: [{ key: 'fx', host: 'fx.', dir: bad }], root }), /page "Not A Slug" is not \[a-z0-9-\]/);
|
|
126
|
+
const empty = path.join(root, 'empty');
|
|
127
|
+
fs.mkdirSync(empty);
|
|
128
|
+
assert.throws(() => inv.buildInventory({ surfaces: [{ key: 'fx', host: 'fx.', dir: empty }], root }), /surface "fx" \(empty\) has no \.html pages/);
|
|
129
|
+
const one = path.join(root, 'one');
|
|
130
|
+
writeFiles(one, { 'i.html': page() });
|
|
131
|
+
assert.throws(() => inv.buildInventory({ surfaces: [{ key: 'a', host: 'fx.', dir: one }, { key: 'b', host: 'fx.', dir: one }], root }),
|
|
132
|
+
/surfaces "a" and "b" both derive the id prefix "fx"/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('DRIFT FAILS: --check reports both artifacts stale when the tree says otherwise, and the committed pair is fresh', () => {
|
|
136
|
+
assert.deepEqual(inv.write({ check: true }).stale, [], 'the committed docs/page-inventory.{json,md} match this tree');
|
|
137
|
+
const real = inv.buildInventory();
|
|
138
|
+
const drifted = () => ({ ...real, pages: real.pages.map((p) => (p.id === 'builders:studio' ? { ...p, title: 'Studio, renamed' } : p)) });
|
|
139
|
+
assert.deepEqual(inv.write({ check: true, build: drifted }).stale, [inv.INVENTORY_REL, inv.REPORT_REL]);
|
|
140
|
+
assert.equal(inv.matchesGenerated(null, ''), false, 'a missing artifact is stale, never vacuously fresh');
|
|
141
|
+
assert.equal(inv.matchesGenerated('a\r\nb\n', 'a\nb\n'), true, 'CRLF checkouts are not drift');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('DRIFT FAILS: the CLI --check exits 0 on this tree (the gate fitness.js and ship-check.js run)', () => {
|
|
145
|
+
const r = spawnSync(process.execPath, [path.join(ROOT, 'scripts', 'gds', 'page-inventory.js'), '--check'], { cwd: ROOT, encoding: 'utf8' });
|
|
146
|
+
assert.equal(r.status, 0, r.stderr);
|
|
147
|
+
const fitness = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'fitness.js'), 'utf8');
|
|
148
|
+
const shipCheck = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'ship-check.js'), 'utf8');
|
|
149
|
+
assert.match(fitness, /'page-inventory\.js'\), '--check'\]/, 'fitness.js runs the --check');
|
|
150
|
+
assert.match(shipCheck, /'scripts\/gds\/page-inventory\.js', '--check'\]/, 'ship-check.js runs the --check');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('PORTABILITY: a disabled module\'s surface and pages are absent, with no edit to the generator', () => {
|
|
154
|
+
const surfaces = moduleWebSurfaces({ isEnabled: (k) => isModuleEnabled(k) && k !== 'public-landing' });
|
|
155
|
+
const built = inv.buildInventory({ surfaces });
|
|
156
|
+
assert.ok(!built.surfaces.some((s) => s.id === 'landing'), 'no landing surface');
|
|
157
|
+
assert.ok(!built.pages.some((p) => p.surface === 'landing'), 'no landing pages');
|
|
158
|
+
assert.ok(built.pages.some((p) => p.surface === 'builders'), 'the rest stay');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('DETERMINISM: two builds give identical bytes', () => {
|
|
162
|
+
const a = JSON.stringify(inv.buildInventory());
|
|
163
|
+
const b = JSON.stringify(inv.buildInventory());
|
|
164
|
+
assert.equal(a, b);
|
|
165
|
+
assert.equal(inv.renderReport(JSON.parse(a)), inv.renderReport(JSON.parse(b)));
|
|
166
|
+
});
|
|
@@ -315,6 +315,90 @@ await ta('the shape refusal: dedicated → ok:false with guidance naming the val
|
|
|
315
315
|
assert.equal(writes.length, 0);
|
|
316
316
|
});
|
|
317
317
|
|
|
318
|
+
console.log('\nthe control plane\'s own row — the platform pushes a setting to itself (task 1004334):');
|
|
319
|
+
|
|
320
|
+
const U = require('../scripts/gds/provision-units.js');
|
|
321
|
+
const { FALLBACK_ENV_PREFIX } = require('../src/instance-config.js');
|
|
322
|
+
|
|
323
|
+
t('selfEnvPrefix: the web.env BRANDING_FILE line wins, then the instance pack, then the neutral pack, then the fallback', () => {
|
|
324
|
+
const packs = (map) => (p) => {
|
|
325
|
+
const hit = Object.keys(map).find((k) => p.replace(/\\/g, '/').endsWith(k));
|
|
326
|
+
if (!hit) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; }
|
|
327
|
+
return JSON.stringify(map[hit]);
|
|
328
|
+
};
|
|
329
|
+
const neutral = { 'config/branding.neutral.json': { envPrefix: 'NEUTRALX' } };
|
|
330
|
+
const withOwn = { ...neutral, 'config/branding.json': { envPrefix: 'OWNPACK' }, 'packs/site.json': { envPrefix: 'OVERRIDE' } };
|
|
331
|
+
const root = '/srv/instance';
|
|
332
|
+
assert.equal(U.selfEnvPrefix({ envBody: 'PORT=1\nBONGOS_BRANDING_FILE=packs/site.json\n', instanceRoot: root, readFile: packs(withOwn) }), 'OVERRIDE');
|
|
333
|
+
assert.equal(U.selfEnvPrefix({ envBody: 'GDS_BRANDING_FILE=packs/site.json\r\n', instanceRoot: root, readFile: packs(withOwn) }), 'OVERRIDE', 'a legacy spelling still names the pack, as loadBranding reads it');
|
|
334
|
+
assert.equal(U.selfEnvPrefix({ envBody: 'PORT=1\n', instanceRoot: root, readFile: packs(withOwn) }), 'OWNPACK', 'no override → the instance root\'s config/branding.json');
|
|
335
|
+
assert.equal(U.selfEnvPrefix({ envBody: 'FOO_BRANDING_FILE=packs/site.json\n', instanceRoot: root, readFile: packs(withOwn) }), 'OWNPACK', 'an unknown prefix is not the branding knob');
|
|
336
|
+
assert.equal(U.selfEnvPrefix({ envBody: '# BONGOS_BRANDING_FILE=packs/site.json\n', instanceRoot: root, readFile: packs(withOwn) }), 'OWNPACK', 'a commented line is not set');
|
|
337
|
+
// loadBranding's own rule: an override that names a missing file loads NO instance pack,
|
|
338
|
+
// so the neutral one answers — config/branding.json is not consulted as a second chance.
|
|
339
|
+
assert.equal(U.selfEnvPrefix({ envBody: 'BONGOS_BRANDING_FILE=packs/gone.json\n', instanceRoot: root, readFile: packs(withOwn) }), 'NEUTRALX');
|
|
340
|
+
assert.equal(U.selfEnvPrefix({ envBody: '', instanceRoot: root, readFile: packs({ ...neutral, 'config/branding.json': { name: 'no prefix here' } }) }), 'NEUTRALX', 'a pack that names no envPrefix inherits the neutral one');
|
|
341
|
+
assert.equal(U.selfEnvPrefix({ envBody: '', instanceRoot: root, readFile: packs({}) }), FALLBACK_ENV_PREFIX, 'nothing readable → the fallback');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
t('settingsEnvVarsFor: only the control-plane row reads the self prefix — every other shape is byte-identical to before', () => {
|
|
345
|
+
const body = 'BONGOS_BRANDING_FILE=/nowhere/at/all.json\n';
|
|
346
|
+
for (const shape of ['cloud-host', 'co-tenant']) {
|
|
347
|
+
assert.deepEqual(P.settingsEnvVarsFor({ ...activeStandalone, hosting_shape: shape }, body), P.settingsEnvVarsFor({ ...activeStandalone, hosting_shape: shape }), shape);
|
|
348
|
+
assert.ok(Object.keys(P.settingsEnvVarsFor({ ...activeStandalone, hosting_shape: shape }, body)).every((k) => k.startsWith(`${NEUTRAL_PREFIX}_`)), shape);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const selfRow = {
|
|
353
|
+
id: 41, slug: 'cloudbongos', hosting_shape: 'control-plane', status: 'active', owner_builder_id: 3,
|
|
354
|
+
port: 3002, domain: 'cloudbongos.com', settings: { artist_gate: 'off' },
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
await ta('--apply, the control-plane row: its OWN /etc/<slug>/web.env on the control-plane pair, under ITS pack\'s prefix, then restart <slug>; nothing else moves', async () => {
|
|
358
|
+
const fs = await import('node:fs');
|
|
359
|
+
const os = await import('node:os');
|
|
360
|
+
const path = await import('node:path');
|
|
361
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cp-pack-'));
|
|
362
|
+
const pack = path.join(dir, 'site.json');
|
|
363
|
+
fs.writeFileSync(pack, JSON.stringify({ envPrefix: 'CPSELF' }));
|
|
364
|
+
const selfEnv = `CPSELF_GITHUB_CLIENT_SECRET=keepme0123456789\nBONGOS_BRANDING_FILE=${pack}\nPORT=3002\nCPSELF_ARTIST_GATE=strict\nGITHUB_PUSH_TOKEN=keepme-too\n`;
|
|
365
|
+
const seen = { box: [], control: [] };
|
|
366
|
+
const mk = (bucket) => (cmd) => {
|
|
367
|
+
seen[bucket].push(cmd);
|
|
368
|
+
if (/\bcat /.test(cmd)) return { ok: true, stdout: selfEnv };
|
|
369
|
+
if (/api\/bongos\/instance/.test(cmd)) return { ok: true, stdout: manifestWith({ platformVisibility: 'public', joinability: 'apply', visibility: 'public', joinGrant: 'full', artistGate: 'off', spendPayer: 'none' }) };
|
|
370
|
+
return { ok: true, stdout: '{"ok":true}' };
|
|
371
|
+
};
|
|
372
|
+
try {
|
|
373
|
+
const { deps, writes, events } = recorder({ apply: true });
|
|
374
|
+
deps.exec = mk('box'); deps.controlExec = mk('control');
|
|
375
|
+
const r = await P.settingsApplyInstance(selfRow, deps);
|
|
376
|
+
assert.equal(r.ok, true, JSON.stringify(r));
|
|
377
|
+
assert.equal(r.consumed, true, 'the read-back saw the live site report the new value');
|
|
378
|
+
assert.equal(seen.box.length, 0, 'nothing runs on the co-hosting box executor — the self row IS the control plane');
|
|
379
|
+
assert.ok(seen.control.some((c) => /^cat \/etc\/cloudbongos\/web\.env$/.test(c)), seen.control.join(' | '));
|
|
380
|
+
assert.ok(seen.control.some((c) => /^systemctl restart cloudbongos$/.test(c)), 'the service the core-upgrade door already restarts');
|
|
381
|
+
assert.deepEqual(writes.map((w) => [w.via, w.path]), [['control', '/etc/cloudbongos/web.env']]);
|
|
382
|
+
const out = writes[0].body;
|
|
383
|
+
assert.match(out, /^CPSELF_ARTIST_GATE=off$/m, 'replaced IN PLACE under the prefix the site resolves');
|
|
384
|
+
assert.doesNotMatch(out, /^BONGOS_ARTIST_GATE=/m, 'not written under the provisioned floor, which this site would never read');
|
|
385
|
+
assert.match(out, /^CPSELF_GITHUB_CLIENT_SECRET=keepme0123456789$/m, 'creds untouched');
|
|
386
|
+
assert.match(out, /^GITHUB_PUSH_TOKEN=keepme-too$/m);
|
|
387
|
+
assert.match(out, /^PORT=3002$/m);
|
|
388
|
+
assert.ok(![...seen.control].some((c) => /systemctl (stop|disable)|caddy|box-dns|rm -|upgrade\.js|npm/.test(c)), 'settings only — no teardown, DNS, Caddy or pin legs');
|
|
389
|
+
assert.match(events[0].detail, /reads the new values/);
|
|
390
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
await ta('the control-plane row keeps every refusal of the leg: an unreadable web.env writes nothing and restarts nothing', async () => {
|
|
394
|
+
const { deps, cmds, writes } = recorder({ apply: true, readFails: true });
|
|
395
|
+
const r = await P.settingsApplyInstance(selfRow, deps);
|
|
396
|
+
assert.equal(r.ok, false);
|
|
397
|
+
assert.match(r.error, /cannot read \/etc\/cloudbongos\/web\.env/);
|
|
398
|
+
assert.equal(writes.length, 0);
|
|
399
|
+
assert.ok(!cmds.some((c) => /restart/.test(c)), 'the platform is never restarted over a file it could not read');
|
|
400
|
+
});
|
|
401
|
+
|
|
318
402
|
console.log('\nthe verdict — recorded only when earned:');
|
|
319
403
|
|
|
320
404
|
await ta('--apply success, manifest matches: consumed=true, event says it reads the new values, F19 latch released, domain-bearing PASS records no liveness verdict', async () => {
|
|
@@ -72,19 +72,30 @@ t('SETTINGS_APPLY_FROM is exactly {active} — the provision intent cannot stand
|
|
|
72
72
|
assert.ok(!provisioning.PROVISION_ENQUEUE_FROM.has('active'), 'the premise the gate rests on');
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
-
t('SETTINGS_APPLY_SHAPES is the reachable
|
|
76
|
-
|
|
75
|
+
t('SETTINGS_APPLY_SHAPES is the reachable three, and the set is a subset of HOSTING_SHAPES', () => {
|
|
76
|
+
// 'control-plane' joined in task 1004334: the runner's OWN box, admitted by name in
|
|
77
|
+
// control-plane-guard.js, so a setting saved on the platform's row reaches the live site.
|
|
78
|
+
assert.deepEqual([...provisioning.SETTINGS_APPLY_SHAPES].sort(), ['cloud-host', 'co-tenant', 'control-plane']);
|
|
77
79
|
for (const s of provisioning.SETTINGS_APPLY_SHAPES) assert.ok(provisioning.HOSTING_SHAPES.includes(s), s);
|
|
78
80
|
// The exclusion this used to assert was 'dedicated', which task 1004182 removed from
|
|
79
81
|
// HOSTING_SHAPES entirely — so that assertion became vacuously true and stopped testing
|
|
80
|
-
// anything. Name the
|
|
81
|
-
// of ours runs a byo-host
|
|
82
|
-
for (const unreachable of ['byo-host'
|
|
82
|
+
// anything. Name the shape that DOES exist and is still deliberately unreachable:
|
|
83
|
+
// nothing of ours runs a byo-host.
|
|
84
|
+
for (const unreachable of ['byo-host']) {
|
|
83
85
|
assert.ok(provisioning.HOSTING_SHAPES.includes(unreachable), `${unreachable} must be a real shape or this assertion is vacuous`);
|
|
84
86
|
assert.ok(!provisioning.SETTINGS_APPLY_SHAPES.has(unreachable), unreachable);
|
|
85
87
|
}
|
|
86
88
|
});
|
|
87
89
|
|
|
90
|
+
t('the two gates agree: every settings-apply shape is one the self-row guard lets a settings-apply through', () => {
|
|
91
|
+
// A shape on SETTINGS_APPLY_SHAPES that the guard refuses would make the route say
|
|
92
|
+
// "a restart is coming" and then answer the owner with control_plane_protected.
|
|
93
|
+
const G = require('../modules/provisioning/control-plane-guard.js');
|
|
94
|
+
for (const s of provisioning.SETTINGS_APPLY_SHAPES) assert.equal(G.intentAllowedForShape('settings-apply', s), true, s);
|
|
95
|
+
// and the restart door was NOT opened on the self row as a side effect
|
|
96
|
+
assert.ok(!provisioning.RESTART_SHAPES.has('control-plane'), 'task 1004334 widened the settings push only');
|
|
97
|
+
});
|
|
98
|
+
|
|
88
99
|
await ta('enqueueSettingsApply: a fresh intent → created:true; the INSERT carries the settings-apply action', async () => {
|
|
89
100
|
const calls = [];
|
|
90
101
|
const db = { query: async (sql, params) => { calls.push({ sql, params }); return { rows: [{ id: 1, action: params[1], state: 'pending' }] }; } };
|
|
@@ -104,6 +115,26 @@ await ta('enqueueSettingsApply: a fresh intent → created:true; the INSERT carr
|
|
|
104
115
|
assert.deepEqual(insert.params, [18, 'settings-apply', 'api:self', null]);
|
|
105
116
|
});
|
|
106
117
|
|
|
118
|
+
await ta('enqueueSettingsApply on the CONTROL-PLANE row passes the self-row guard and INSERTs; a teardown on the same row is still refused (task 1004334)', async () => {
|
|
119
|
+
const selfRow = (calls) => ({ query: async (sql, params) => {
|
|
120
|
+
calls.push({ sql, params });
|
|
121
|
+
if (/SELECT hosting_shape FROM provisioning_instances/.test(sql)) return { rows: [{ hosting_shape: 'control-plane' }] };
|
|
122
|
+
return { rows: [{ id: 3, action: params[1], state: 'pending' }] };
|
|
123
|
+
} });
|
|
124
|
+
const calls = [];
|
|
125
|
+
const r = await provisioning.enqueueSettingsApply(selfRow(calls), 41, 'api:self');
|
|
126
|
+
assert.equal(r.created, true, 'the real guard (not a stub) admits the push onto the self row');
|
|
127
|
+
const insert = calls.find((c) => /INSERT INTO provisioning_intents/.test(c.sql));
|
|
128
|
+
assert.deepEqual(insert.params, [41, 'settings-apply', 'api:self', null]);
|
|
129
|
+
// Everything the guard existed to refuse is still refused through the same door.
|
|
130
|
+
for (const destructive of ['teardown', 'disconnect', 'provision', 'domain-attach', 'repo-private']) {
|
|
131
|
+
const seen = [];
|
|
132
|
+
await assert.rejects(provisioning.enqueueIntent(selfRow(seen), 41, destructive, 'api:self'),
|
|
133
|
+
(e) => e.code === 'control_plane_protected' && e.status === 409, destructive);
|
|
134
|
+
assert.ok(!seen.some((c) => /INSERT/.test(c.sql)), `${destructive}: nothing written`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
107
138
|
await ta('enqueueSettingsApply: an identical PENDING push already open → created:false, no conflict (it carries this change)', async () => {
|
|
108
139
|
const db = { query: async (sql) => {
|
|
109
140
|
if (/INSERT/.test(sql)) { const e = new Error('dup'); e.code = '23505'; throw e; }
|
|
@@ -260,6 +291,20 @@ await ta('ACTIVE co-tenant queues too; the Archon override names WHICH archon on
|
|
|
260
291
|
assert.equal(events[0].actor, 'api:archon:999');
|
|
261
292
|
});
|
|
262
293
|
|
|
294
|
+
await ta('ACTIVE control-plane row (the platform itself): artist_gate off QUEUES a push and says a restart is coming (task 1004334)', async () => {
|
|
295
|
+
// The case TW01 hit: this save used to answer "runs on a machine the platform doesn't
|
|
296
|
+
// manage", stored 'off', and left the live site reading 'strict' with no way to apply it.
|
|
297
|
+
reset({ inst: { owner_builder_id: 7, slug: 'cloudbongos', status: 'active', hosting_shape: 'control-plane', domain: 'cloudbongos.com', port: 3002, settings: {} } });
|
|
298
|
+
const r = await patch('/provisioning/instances/41/settings', { artist_gate: 'off' });
|
|
299
|
+
assert.equal(r.status, 200, r.raw);
|
|
300
|
+
assert.equal(r.body.saved.artist_gate, 'off');
|
|
301
|
+
assert.deepEqual(updateCalls.map((c) => c.patch), [{ artist_gate: 'off' }]);
|
|
302
|
+
assert.equal(updateCalls[0].enqueuedSoFar, 0, 'the row is written before the intent the runner reads it through');
|
|
303
|
+
assert.deepEqual(enqueueCalls, [{ id: 41, requestedBy: 'api:self' }]);
|
|
304
|
+
receiptShape(r.body.push, { queued: true, restart: true, reachable: true });
|
|
305
|
+
assert.doesNotMatch(r.body.push.message, /doesn.t manage/, 'no longer the unreachable-machine sentence');
|
|
306
|
+
});
|
|
307
|
+
|
|
263
308
|
await ta('an identical PENDING push already open → 200, queued:false, "already queued … will carry this change"', async () => {
|
|
264
309
|
reset();
|
|
265
310
|
enqueueResult = { intent: { id: 5, action: 'settings-apply', state: 'pending' }, created: false };
|