@bongos/core 1.19.1079 → 1.19.1080
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 +20 -15
- package/docs/architecture.md +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/docs/modules-contract.md +7 -1
- package/package-lock.json +2 -2
- package/package.json +2 -2
- package/release-notes.json +6 -0
- package/scripts/gds/module.js +176 -46
- package/src/module-api.js +1 -1
- package/tests/module_update.mjs +232 -0
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.1080",
|
|
6
|
+
"core_contract": "1.19.1080",
|
|
7
|
+
"source_commit": "266542d6d4b7b351e1b5e52e624d6429f20e4a3e",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-29T22:14:01.252Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 550,
|
|
13
13
|
"agent_docs_stubbed": 25,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2535,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 3111,
|
|
20
|
+
"tree_sha256": "05cc1fdd03c698fc64c21d2c5b4c72150f293acda99233129517b7954d4dfda8",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2217,7 +2217,7 @@
|
|
|
2217
2217
|
{
|
|
2218
2218
|
"path": "docs/architecture.md",
|
|
2219
2219
|
"mode": "0000644",
|
|
2220
|
-
"sha256": "
|
|
2220
|
+
"sha256": "5e4d7c92368a07d0f363c7b6cd4db3ba9ed2c7baba3ecad66537baf3b9786b80"
|
|
2221
2221
|
},
|
|
2222
2222
|
{
|
|
2223
2223
|
"path": "docs/branding-contract.md",
|
|
@@ -2742,12 +2742,12 @@
|
|
|
2742
2742
|
{
|
|
2743
2743
|
"path": "docs/module-api-changelog.md",
|
|
2744
2744
|
"mode": "0000644",
|
|
2745
|
-
"sha256": "
|
|
2745
|
+
"sha256": "f6df65117a87e0af4721f2872bba4ee9f7ab4a01a8ae8b76d38206cad08bf712"
|
|
2746
2746
|
},
|
|
2747
2747
|
{
|
|
2748
2748
|
"path": "docs/modules-contract.md",
|
|
2749
2749
|
"mode": "0000644",
|
|
2750
|
-
"sha256": "
|
|
2750
|
+
"sha256": "a3969a4cdc2bdbb14a0a5ece7a16d8f280703eef2376ff9a49a3030f9f353160"
|
|
2751
2751
|
},
|
|
2752
2752
|
{
|
|
2753
2753
|
"path": "docs/onboarding/diagrams/01-task-lifecycle.mmd",
|
|
@@ -8602,12 +8602,12 @@
|
|
|
8602
8602
|
{
|
|
8603
8603
|
"path": "package-lock.json",
|
|
8604
8604
|
"mode": "0000644",
|
|
8605
|
-
"sha256": "
|
|
8605
|
+
"sha256": "4187718df5a94313b87e2871b5c296229ea1a92dabe2c75275f05e3174539c74"
|
|
8606
8606
|
},
|
|
8607
8607
|
{
|
|
8608
8608
|
"path": "package.json",
|
|
8609
8609
|
"mode": "0000644",
|
|
8610
|
-
"sha256": "
|
|
8610
|
+
"sha256": "4307d22a2cadfdbbd821db8058f22dc4b74f47864b5d405a8b6efca3fd842158"
|
|
8611
8611
|
},
|
|
8612
8612
|
{
|
|
8613
8613
|
"path": "public-docs/index.html",
|
|
@@ -8627,7 +8627,7 @@
|
|
|
8627
8627
|
{
|
|
8628
8628
|
"path": "release-notes.json",
|
|
8629
8629
|
"mode": "0000644",
|
|
8630
|
-
"sha256": "
|
|
8630
|
+
"sha256": "e987059aae853c4e12e0890a050c9d8b1f81d2254060d5ea714378ec60b2193e"
|
|
8631
8631
|
},
|
|
8632
8632
|
{
|
|
8633
8633
|
"path": "scripts/bongos-mcp.js",
|
|
@@ -9457,7 +9457,7 @@
|
|
|
9457
9457
|
{
|
|
9458
9458
|
"path": "scripts/gds/module.js",
|
|
9459
9459
|
"mode": "0000644",
|
|
9460
|
-
"sha256": "
|
|
9460
|
+
"sha256": "2bb8d87422e2b24db1ea9fa0c51556b09a7bea99da5af7ad3798c3b386809f4b"
|
|
9461
9461
|
},
|
|
9462
9462
|
{
|
|
9463
9463
|
"path": "scripts/gds/newcomer-floor.config.json",
|
|
@@ -10702,7 +10702,7 @@
|
|
|
10702
10702
|
{
|
|
10703
10703
|
"path": "src/module-api.js",
|
|
10704
10704
|
"mode": "0000644",
|
|
10705
|
-
"sha256": "
|
|
10705
|
+
"sha256": "5f4628ecd7603c1275a4ea0efcdef38540130eb9dfdb6eb0d37dbd28fe675663"
|
|
10706
10706
|
},
|
|
10707
10707
|
{
|
|
10708
10708
|
"path": "src/module-loader/catalog.js",
|
|
@@ -13614,6 +13614,11 @@
|
|
|
13614
13614
|
"mode": "0000644",
|
|
13615
13615
|
"sha256": "0db12d802203780f489e9a6d7abf970dfcde9099ac7011fcaaa6c575336804cd"
|
|
13616
13616
|
},
|
|
13617
|
+
{
|
|
13618
|
+
"path": "tests/module_update.mjs",
|
|
13619
|
+
"mode": "0000644",
|
|
13620
|
+
"sha256": "1b50c9f68bfe731b29ddcce6947847b6e6f3f7bc5b0a08bbacc4afb25d5a3d90"
|
|
13621
|
+
},
|
|
13617
13622
|
{
|
|
13618
13623
|
"path": "tests/modules.mjs",
|
|
13619
13624
|
"mode": "0000644",
|
package/docs/architecture.md
CHANGED
|
@@ -261,7 +261,7 @@ Bongos views: `claimable_tasks` (rebuilt in 006 to use SELECT t.*; rebuilt again
|
|
|
261
261
|
- Authenticated CLI/web: `GET /me` (returns `rank`), `GET /versions[/progress]`, `GET /tasks[?version=&status=&kind=&discipline=]`, `GET /tasks/claimable[?version=&discipline=]`, `GET /tasks/:id`, `POST /tasks` (accepts `discipline` + `criterion_ids`; **metic+** — [ADR 0090](adr/0090-metic-task-authoring.md)), `PATCH /tasks/:id` (supports `parent_task_id`, `kind`, `discipline`, `goal_id` — (re)assign the task's goal; must be a goal in the task's own version, task 1763; **metic+**), `POST /tasks/:id/promote` (**metic+**), `POST /claims`, `POST /claims/:id/resolve`, `POST /cost`.
|
|
262
262
|
- Criterion rollup + task↔criterion links ([ADR 0025](adr/0025-structured-criterion-task-link.md), [#435](https://example.com/builders#/task/435)/[#438](https://example.com/builders#/task/438)): `GET /versions/:id/progress` rolls up each done-when criterion → its gating tasks (via `task_criteria`) → live status counts + the not-yet-shipped `remaining[]` + `unattributed_tasks` (the read behind the `/status` skill, in `src/bongos/done-when.js criterionProgress`). The link CRUD mirrors the dependency endpoints: `GET /tasks/:id/criteria`, `POST /tasks/:id/criteria` (**metic+** — [ADR 0090](adr/0090-metic-task-authoring.md)) and `DELETE /tasks/:id/criteria/:criterionId` (**metic+**). A criterion ref is a numeric `done_when_criteria.id`, a positional `"Cn"` token, or a `criterion_id` slug, resolved against the task's version (`POST /tasks` links at create time so `/status` counts the task with no backfill).
|
|
263
263
|
- Module store publish (ADR 0338 D1, task 1004271): `POST /store/modules/:key/versions` (`requireBuilder` + `requirePermission('module.submit')`, Metic floor). The body is the raw gzip tarball `bongos module publish` builds (own `express.raw` parser, 5 MB cap; 16 MB / 2000 files unpacked). `scripts/gds/module-artifact.js` re-verifies every hash and the publish gate, then `src/bongos/module-store.js` keeps the file under `var/module-store/` and inserts the `store_module_versions` row in one transaction. The first publish makes the caller the author; later versions are author-only, newer than the last and never overwritten; a delisted key takes none.
|
|
264
|
-
- Module store install (ADR 0338 D1, task 1003785), all `requireBuilder` and own-scoped (the holder is the caller): `POST /store/modules/:key/acquire` (`{version?}`; grants a free entitlement — no price row, or a zero price, is free — or `402 payment_required` for a priced module; refuses a delisted one), `GET /store/modules/:key/versions/:version/tarball` (served only to a live entitlement holder; `X-Tarball-Sha256`), `POST /store/modules/:key/acquired` (`{version}` → `recordAcquired`). Read side in `src/bongos/module-store.js` (`resolveAcquirable`, `isFreePrice`, `versionArtifactFile`). No channel check yet (tasks 1003815, 1003805).
|
|
264
|
+
- Module store install (ADR 0338 D1, task 1003785), all `requireBuilder` and own-scoped (the holder is the caller): `POST /store/modules/:key/acquire` (`{version?}`; grants a free entitlement — no price row, or a zero price, is free — or `402 payment_required` for a priced module; refuses a delisted one), `GET /store/modules/:key/versions/:version/tarball` (served only to a live entitlement holder; `X-Tarball-Sha256`), `POST /store/modules/:key/acquired` (`{version}` → `recordAcquired`). Read side in `src/bongos/module-store.js` (`resolveAcquirable`, `isFreePrice`, `versionArtifactFile`). No channel check yet (tasks 1003815, 1003805). `bongos module update` (task 1003786) reuses the same three calls; the acquire route reads price only for a caller with no entitlement, so a holder is never charged again.
|
|
265
265
|
- Server-mediated branch publish (ADR 0055 / [#1025](https://example.com/builders#/task/1025) — lets a checkout with no GitHub push credential ship). Owner-gated like ship: `POST /tasks/:id/publish-branch` (`requireBuilder` + `gateTaskOwnership`; own 32 MB json parser for the base64 thin-bundle body, 20 MB decoded cap; the server pushes the branch + opens the PR + auto-merges with a server-side push credential — a **GitHub App** installation token (short-lived, repo-scoped; preferred, [ADR 0055](adr/0055-server-mediated-branch-publish.md) update / task 1028) or the `GITHUB_PUSH_TOKEN` PAT fallback; `503 push_unconfigured` when neither is set; `400 tip_mismatch` if the bundle tip ≠ the claimed `head_sha`) and `GET /tasks/:id/publish-status?branch=…` (polls PR + deploy-prod state derived live from GitHub). `branch` is optional: without it the server finds the task's own PR by its `task <id>: …` title, and the answer names the `branch` it used and its `branch_source` (`query` / `task_pr`, or `null` with a `branch_hint` when there is no such PR) — never a `400` (task 1003764). When the PR is still open it also carries `merge_driver`, whose `reason` says why it has not merged; on a red PR `failing_tests` names the failing unit tests (read from the `unit-report` commit status the `unit` workflow posts) and `checks_unreadable: true` says the check list is blind because the App lacks `Checks: read` — so an empty `checks[]` is never read as "nothing failed" (task 1003988, ADR 0301). Because the server's credential is the PR's AUTHOR, publish also **assigns the PR to the task's claim holder** and names them in the body (`modules/lifecycle/pr-assign.js`, task 1003991) — best-effort: the 201 carries `pr_assignee` (the login, or `null` when GitHub would not assign it) and a failed assignment never fails the publish. `ship.js` uses these in `ci` mode only when `pushVia()` resolves to server (no authenticated `gh` on the machine, or `<PREFIX>_PUSH_VIA_SERVER=1`); a machine with `gh` keeps the local push path unchanged. Implementation in `src/bongos/github-push.js`.
|
|
266
266
|
- Archon monitoring reads backing the `/watch` page (ADR 0036): `GET /builders/roster`, `GET /grades/by-builder[?days=N]` (**archon-only**, [#726](https://example.com/builders#/task/726) — per-builder grade breakdown for the MARKS section; the project-wide aggregate stays public at `/public/grades`), `GET /audit-log`, `GET /override-requests`, `GET /access-requests`, `GET /security/reports`. Personal-prefs writes backing `/settings`: `GET/PATCH /me/skill-prefs`, `PATCH /me/disciplines`.
|
|
267
267
|
- Per-builder "needs" + own Gemini key ([ADR 0073](adr/0073-builder-needs-signal-and-byok-gemini-key.md), [#1013](https://example.com/builders#/task/1013)): `GET /me` now also carries `needs` ({items, action_needed_count} — the consistent "the system needs an input from you" signal; `modules/builder-settings/builder-needs.js` is the SSOT — carved out in BV1.R80, resolved by `GET /me` via the `builder-settings` kernel port — rendered by the hall Standing card + the CLI `printNeedsNudge`). The own-key (pragmatic BYOK) endpoints, all own-scoped: `GET /me/art-key/own` (masked meta — last4 only), `PUT /me/art-key/own` (validate-on-save via a Google list-models call → encrypt with `src/bongos/secret-box.js` → store; `503 storage_not_configured` until `BUILDER_SECRET_KEY` is set, `422 invalid_key` on a bad key), `DELETE /me/art-key/own`. The existing `GET /me/art-key` (shared-key delivery) is extended to also deliver the decrypted own key, which `scripts/gds/fetch-art-key.js` syncs into the local `gemini_api_key` slot (own key wins in the `gen_api.py` cascade).
|
|
@@ -2649,5 +2649,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2649
2649
|
landed since 1.19.1077 with no explicit bump. run 36600137276. (task 1002620)
|
|
2650
2650
|
1.19.1079 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2651
2651
|
landed since 1.19.1078 with no explicit bump. run 36602720244. (task 1002620)
|
|
2652
|
+
1.19.1080 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2653
|
+
landed since 1.19.1079 with no explicit bump. run 36638268217. (task 1002620)
|
|
2652
2654
|
---------------------------------------------------------------------------
|
|
2653
2655
|
```
|
package/docs/modules-contract.md
CHANGED
|
@@ -364,9 +364,15 @@ Run `bongos upgrade` to confirm coreVersion compatibility, then `node scripts/gd
|
|
|
364
364
|
- The download is checked with `verifyModuleArtifact`, the same checker the store runs on publish, and must match the version and hashes the store described. A failed check places nothing.
|
|
365
365
|
- A module with no price, or a zero price, is free and granted on the spot. A priced one answers 402 until buying exists (area 8). An entitlement is kept for good, even if a price is added later (ADR 0338 D2).
|
|
366
366
|
- Install never enables. Switch the module on from the hall's Modules tab.
|
|
367
|
-
- A module already on disk is refused; moving one to a newer version is `update`. A module this core cannot mount is refused before download.
|
|
367
|
+
- A module already on disk, in the instance's `modules/` or bundled with the core, is refused; moving one to a newer version is `update`. A module this core cannot mount is refused before download.
|
|
368
368
|
- A delisted module cannot be acquired or downloaded. Channels are not checked yet: every version is general until the channel tasks land.
|
|
369
369
|
|
|
370
|
+
`bongos module update <key> [--version X.Y.Z]` moves an installed store module forward through the same three calls, so a paid module and a free one take one identical path, and a module you already hold is never charged again (`tests/module_update.mjs` fails if a price-conditional branch appears).
|
|
371
|
+
|
|
372
|
+
- With no `--version` it takes the newest, and says "up to date" without downloading when that isn't newer. An older `--version` is refused: update only moves forward.
|
|
373
|
+
- The new version is written beside the old one and swapped in; any failure leaves the installed version in place. Files the new version dropped are gone afterwards.
|
|
374
|
+
- The on/off switch is untouched. A module bundled with the core is refused; it updates with the core (`bongos upgrade`).
|
|
375
|
+
|
|
370
376
|
## Carving an existing core domain (the `db.js` per-domain pattern)
|
|
371
377
|
|
|
372
378
|
Most BONGOS-V1 module work is not scaffolding a *new* module — it's **carving an existing core domain out of the monolith** (ADR 0091, criterion C7). The reference carve is the kernel slice `src/bongos/db-kernel.js` (BV1.R69); the feature precedents are `modules/discord/db.js` + `modules/game/db.js`. Every later carve (memory R72, grading R73, …) copies this recipe. The invariant is **OTB byte-identical** at every step — you are *moving* code, not changing behavior.
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1080",
|
|
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.1080",
|
|
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.1080",
|
|
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",
|
|
@@ -29,6 +29,6 @@
|
|
|
29
29
|
"pino": "^9.14.0"
|
|
30
30
|
},
|
|
31
31
|
"overrides": {
|
|
32
|
-
"undici": "^6.28.
|
|
32
|
+
"undici": "^6.28.1"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/release-notes.json
CHANGED
|
@@ -7867,5 +7867,11 @@
|
|
|
7867
7867
|
"id": "1003785",
|
|
7868
7868
|
"text": "You can now install a module from the store with one command. It checks the download is exactly what was published and never switches the module on by itself. Modules without a price are free."
|
|
7869
7869
|
}
|
|
7870
|
+
],
|
|
7871
|
+
"1.19.1080": [
|
|
7872
|
+
{
|
|
7873
|
+
"id": "1003786",
|
|
7874
|
+
"text": "Installed modules can now be updated with one command. Paid and free modules update the exact same way, you are never charged twice, and a failed update leaves your working version in place."
|
|
7875
|
+
}
|
|
7870
7876
|
]
|
|
7871
7877
|
}
|
package/scripts/gds/module.js
CHANGED
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
// bongos upgrade pre-check enabled modules vs the core version
|
|
62
62
|
// bongos module check <key> [--sign-off "Name <email>"] AGPL/publish pre-check + DCO sign-off
|
|
63
63
|
// bongos module install <key> [--version X.Y.Z] acquire from the store, verify, place (not enabled)
|
|
64
|
+
// bongos module update <key> [--version X.Y.Z] move an installed store module forward
|
|
64
65
|
|
|
65
66
|
const fs = require('node:fs');
|
|
66
67
|
const path = require('node:path');
|
|
@@ -925,30 +926,104 @@ function placeModule(files, target) {
|
|
|
925
926
|
}
|
|
926
927
|
}
|
|
927
928
|
|
|
929
|
+
// Every discovery root the loader scans (core + instance, ADR 0108 §1). A key on
|
|
930
|
+
// disk in ANY of them is already taken: the loader treats a repeated key as a hard
|
|
931
|
+
// error, so placing a second copy in the instance root would break boot.
|
|
932
|
+
function discoveryRoots() {
|
|
933
|
+
return require('../../src/module-loader/loader').DEFAULT_ROOTS;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Parse `<key> [--version X.Y.Z]` for install and update. Returns { key, wanted }
|
|
937
|
+
// or { code } (2 = usage) after printing why.
|
|
938
|
+
function parseStoreArgs(args, verb, errlog) {
|
|
939
|
+
const usage = `usage: bongos module ${verb} <key> [--version X.Y.Z]`;
|
|
940
|
+
const vIdx = args.indexOf('--version');
|
|
941
|
+
const wanted = vIdx !== -1 ? args[vIdx + 1] : null;
|
|
942
|
+
const key = args.find((a, i) => !a.startsWith('-') && args[i - 1] !== '--version');
|
|
943
|
+
if (!key || (vIdx !== -1 && (!wanted || wanted.startsWith('--')))) { errlog(usage); return { code: 2 }; }
|
|
944
|
+
if (!KEY_RE.test(key)) { errlog(`REFUSING: "${key}" is not a module key (lowercase kebab-case).`); return { code: 2 }; }
|
|
945
|
+
const { parseVersion } = require('../../src/module-loader/semver');
|
|
946
|
+
if (wanted && !parseVersion(wanted)) { errlog(`REFUSING: --version must be exact X.Y.Z, not "${wanted}".`); return { code: 2 }; }
|
|
947
|
+
return { key, wanted };
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// The one acquisition path install AND update share (task 1003786): acquire, check
|
|
951
|
+
// the version can mount on this core, download, verify with verifyModuleArtifact and
|
|
952
|
+
// hold the bytes to what the store described. Nothing here reads a price or branches
|
|
953
|
+
// on how the module was obtained — the store decides entitlement, and a holder it has
|
|
954
|
+
// already granted (for any reason) is never asked again. tests/module_update.mjs
|
|
955
|
+
// fails if a price-conditional branch appears in this path.
|
|
956
|
+
//
|
|
957
|
+
// `shouldDownload(described)` lets update stop before the download when the store's
|
|
958
|
+
// version is not newer than what is installed. Resolves { ok, described, verified }
|
|
959
|
+
// or { ok: false, code, upToDate? } after printing why.
|
|
960
|
+
async function fetchVerified(key, { wanted, call, core, log, errlog, verb, shouldDownload = () => true }) {
|
|
961
|
+
const fail = (code, msg) => { if (msg) errlog(msg); return { ok: false, code }; };
|
|
962
|
+
const enc = encodeURIComponent(key);
|
|
963
|
+
let acq;
|
|
964
|
+
try { acq = await call('POST', `/store/modules/${enc}/acquire`, { json: wanted ? { version: wanted } : {} }); }
|
|
965
|
+
catch (e) { return fail(1, `bongos module ${verb}: the store could not be reached — ${e.message}`); }
|
|
966
|
+
if (!acq.ok) return fail(1, `REFUSING: the store would not hand over "${key}" (HTTP ${acq.status}): ${storeError(acq)}`);
|
|
967
|
+
const described = acq.data;
|
|
968
|
+
log(`bongos module ${verb} — "${key}" ${described.version} (needs core ${described.core_version})`);
|
|
969
|
+
|
|
970
|
+
const stop = shouldDownload(described);
|
|
971
|
+
if (stop !== true) return { ok: false, code: stop.code, upToDate: stop.upToDate };
|
|
972
|
+
|
|
973
|
+
const { satisfies } = require('../../src/module-loader/semver');
|
|
974
|
+
const coreV = core || require('../../src/module-api').CORE_VERSION;
|
|
975
|
+
if (!satisfies(coreV, described.core_version)) {
|
|
976
|
+
return fail(1, `REFUSING: "${key}" ${described.version} needs core ${described.core_version}; this core is ${coreV}, so it could not mount.`);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
let dl;
|
|
980
|
+
try { dl = await call('GET', `/store/modules/${enc}/versions/${encodeURIComponent(described.version)}/tarball`, { binary: true }); }
|
|
981
|
+
catch (e) { return fail(1, `bongos module ${verb}: download failed — ${e.message}`); }
|
|
982
|
+
if (!dl.ok) return fail(1, `REFUSING: the download was refused (HTTP ${dl.status}): ${storeError(dl)}`);
|
|
983
|
+
|
|
984
|
+
const artifact = require('./module-artifact');
|
|
985
|
+
const verified = await artifact.verifyModuleArtifact(dl.buf, { key, includeFiles: true });
|
|
986
|
+
if (!verified.ok) return fail(1, `REFUSING: the downloaded tarball failed verification (${verified.code}): ${verified.message}`);
|
|
987
|
+
if (verified.version !== described.version || verified.tarballSha256 !== described.tarball_sha256 || verified.treeSha256 !== described.tree_sha256) {
|
|
988
|
+
return fail(1, `REFUSING: the download is not the ${described.version} the store described (version or hash differs).`);
|
|
989
|
+
}
|
|
990
|
+
log(` ✓ verified ${verified.files.length} file(s), ${verified.bytes} bytes — tree ${verified.treeSha256.slice(0, 12)}…`);
|
|
991
|
+
return { ok: true, described, verified };
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Tell the store which exact version was placed. A failure here never undoes the
|
|
995
|
+
// placement: the files are good, and the next install or update records it.
|
|
996
|
+
async function recordPlaced(key, version, { call, errlog, verb }) {
|
|
997
|
+
let rec;
|
|
998
|
+
try { rec = await call('POST', `/store/modules/${encodeURIComponent(key)}/acquired`, { json: { version } }); }
|
|
999
|
+
catch (e) { rec = { ok: false, status: 0, data: { error: { message: e.message } } }; }
|
|
1000
|
+
if (!rec.ok) {
|
|
1001
|
+
errlog(`WARNING: "${key}" is ${verb === 'update' ? 'updated' : 'installed'}, but the store did not record the version (${storeError(rec)}). Nothing is lost; the next install or update records it.`);
|
|
1002
|
+
return false;
|
|
1003
|
+
}
|
|
1004
|
+
return true;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
928
1007
|
// `bongos module install <key> [--version X.Y.Z]` — task 1003785. Acquires the
|
|
929
1008
|
// module (a free one is granted on the spot; a priced one waits on the buy action,
|
|
930
1009
|
// area 8), downloads its tarball, verifies it with verifyModuleArtifact — the one
|
|
931
1010
|
// checker the store itself uses on publish — and places it under the instance's
|
|
932
1011
|
// modules/. It never enables: the loader discovers what is on disk and the hall's
|
|
933
|
-
// Modules tab decides what mounts. A
|
|
934
|
-
// installed module to a newer version is `update` (task 1003786).
|
|
1012
|
+
// Modules tab decides what mounts. A key already on disk in ANY discovery root is
|
|
1013
|
+
// refused; moving an installed module to a newer version is `update` (task 1003786).
|
|
935
1014
|
async function cmdInstall(args, {
|
|
936
1015
|
log = console.log, errlog = console.error,
|
|
937
|
-
modulesDir, request, core,
|
|
1016
|
+
modulesDir, roots, request, core,
|
|
938
1017
|
} = {}) {
|
|
939
|
-
const
|
|
940
|
-
|
|
941
|
-
const
|
|
942
|
-
const key = args.find((a, i) => !a.startsWith('-') && args[i - 1] !== '--version');
|
|
943
|
-
if (!key || (vIdx !== -1 && (!wanted || wanted.startsWith('--')))) { errlog(usage); return 2; }
|
|
944
|
-
if (!KEY_RE.test(key)) { errlog(`REFUSING: "${key}" is not a module key (lowercase kebab-case).`); return 2; }
|
|
945
|
-
const { parseVersion, satisfies } = require('../../src/module-loader/semver');
|
|
946
|
-
if (wanted && !parseVersion(wanted)) { errlog(`REFUSING: --version must be exact X.Y.Z, not "${wanted}".`); return 2; }
|
|
1018
|
+
const parsed = parseStoreArgs(args, 'install', errlog);
|
|
1019
|
+
if (parsed.code) return parsed.code;
|
|
1020
|
+
const { key, wanted } = parsed;
|
|
947
1021
|
|
|
948
1022
|
const dir = modulesDir || instanceModulesDir();
|
|
949
1023
|
const target = path.join(dir, key);
|
|
950
|
-
|
|
951
|
-
|
|
1024
|
+
const taken = [dir, ...(roots || discoveryRoots())].map((r) => path.join(r, key)).find((p) => fs.existsSync(p));
|
|
1025
|
+
if (taken) {
|
|
1026
|
+
errlog(`REFUSING: "${key}" is already on disk at ${taken}. Install only adds a new module; to move an installed one forward, run \`bongos module update ${key}\`.`);
|
|
952
1027
|
return 1;
|
|
953
1028
|
}
|
|
954
1029
|
|
|
@@ -956,47 +1031,99 @@ async function cmdInstall(args, {
|
|
|
956
1031
|
try { call = request || storeRequest(); }
|
|
957
1032
|
catch (e) { errlog(`bongos module install: ${e.message}`); return 1; }
|
|
958
1033
|
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
try { acq = await call('POST', `/store/modules/${enc}/acquire`, { json: wanted ? { version: wanted } : {} }); }
|
|
962
|
-
catch (e) { errlog(`bongos module install: the store could not be reached — ${e.message}`); return 1; }
|
|
963
|
-
if (!acq.ok) { errlog(`REFUSING: the store would not hand over "${key}" (HTTP ${acq.status}): ${storeError(acq)}`); return 1; }
|
|
964
|
-
const a = acq.data;
|
|
965
|
-
log(`bongos module install — "${key}" ${a.version} (needs core ${a.core_version})`);
|
|
1034
|
+
const got = await fetchVerified(key, { wanted, call, core, log, errlog, verb: 'install' });
|
|
1035
|
+
if (!got.ok) return got.code;
|
|
966
1036
|
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
return 1;
|
|
971
|
-
}
|
|
1037
|
+
try { placeModule(got.verified.files, target); }
|
|
1038
|
+
catch (e) { errlog(`bongos module install: could not place the module — ${e.message}`); return 1; }
|
|
1039
|
+
log(` ✓ placed at ${target}`);
|
|
972
1040
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1041
|
+
if (!(await recordPlaced(key, got.described.version, { call, errlog, verb: 'install' }))) return 1;
|
|
1042
|
+
log(`\n✓ Installed "${key}" ${got.described.version}. It is not switched on: enable it from the hall's Modules tab when you want it.`);
|
|
1043
|
+
return 0;
|
|
1044
|
+
}
|
|
977
1045
|
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1046
|
+
// Replace an installed module's directory with a verified new one. The new files
|
|
1047
|
+
// are written to a staging dir first; the old dir is moved aside, the new one moved
|
|
1048
|
+
// in, and only then is the old one removed — so a failure at any step leaves the
|
|
1049
|
+
// previous version in place, never a half-written or missing module.
|
|
1050
|
+
function replaceModule(files, target) {
|
|
1051
|
+
const aside = `${target}.previous-${process.pid}-${Date.now()}`;
|
|
1052
|
+
const staging = `${target}.installing-${process.pid}-${Date.now()}`;
|
|
1053
|
+
placeModule(files, staging);
|
|
1054
|
+
try {
|
|
1055
|
+
fs.renameSync(target, aside);
|
|
1056
|
+
} catch (e) {
|
|
1057
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
1058
|
+
throw e;
|
|
1059
|
+
}
|
|
1060
|
+
try {
|
|
1061
|
+
fs.renameSync(staging, target);
|
|
1062
|
+
} catch (e) {
|
|
1063
|
+
fs.renameSync(aside, target);
|
|
1064
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
1065
|
+
throw e;
|
|
984
1066
|
}
|
|
985
|
-
|
|
1067
|
+
fs.rmSync(aside, { recursive: true, force: true });
|
|
1068
|
+
}
|
|
986
1069
|
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1070
|
+
// `bongos module update <key> [--version X.Y.Z]` — task 1003786. Moves an installed
|
|
1071
|
+
// store module forward, through exactly the acquisition path install uses
|
|
1072
|
+
// (fetchVerified): the same calls whether the module was free or paid, and a module
|
|
1073
|
+
// the caller already holds is never charged again (the store's acquire route only
|
|
1074
|
+
// looks at price for a caller with no entitlement). Only forward: with no --version
|
|
1075
|
+
// it takes the newest, and says so without downloading when that is not newer; an
|
|
1076
|
+
// older --version is refused, since rolling back is a different act. Like install it
|
|
1077
|
+
// never enables or disables — the module keeps whatever switch it had.
|
|
1078
|
+
async function cmdUpdate(args, {
|
|
1079
|
+
log = console.log, errlog = console.error,
|
|
1080
|
+
modulesDir, roots, request, core,
|
|
1081
|
+
} = {}) {
|
|
1082
|
+
const parsed = parseStoreArgs(args, 'update', errlog);
|
|
1083
|
+
if (parsed.code) return parsed.code;
|
|
1084
|
+
const { key, wanted } = parsed;
|
|
990
1085
|
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
errlog(
|
|
1086
|
+
const dir = modulesDir || instanceModulesDir();
|
|
1087
|
+
const target = path.join(dir, key);
|
|
1088
|
+
if (!fs.existsSync(path.join(target, 'module.json'))) {
|
|
1089
|
+
const bundled = (roots || discoveryRoots()).map((r) => path.join(r, key)).find((p) => p !== target && fs.existsSync(p));
|
|
1090
|
+
errlog(bundled
|
|
1091
|
+
? `REFUSING: "${key}" at ${bundled} comes with the core, not the store. It updates when the core does (\`bongos upgrade\`).`
|
|
1092
|
+
: `REFUSING: "${key}" is not installed here. Run \`bongos module install ${key}\` first.`);
|
|
996
1093
|
return 1;
|
|
997
1094
|
}
|
|
1095
|
+
const { parseVersion, cmp } = require('../../src/module-loader/semver');
|
|
1096
|
+
let installed;
|
|
1097
|
+
try { installed = JSON.parse(fs.readFileSync(path.join(target, 'module.json'), 'utf8')).version; }
|
|
1098
|
+
catch (e) { errlog(`REFUSING: could not read the installed ${key}/module.json — ${e.message}`); return 1; }
|
|
1099
|
+
const have = parseVersion(String(installed));
|
|
1100
|
+
if (!have) { errlog(`REFUSING: the installed "${key}" has no exact version in module.json, so there is nothing to compare against.`); return 1; }
|
|
1101
|
+
if (wanted && cmp(parseVersion(wanted), have) < 0) {
|
|
1102
|
+
errlog(`REFUSING: ${wanted} is older than the installed ${installed}. Update only moves forward.`);
|
|
1103
|
+
return 1;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
let call;
|
|
1107
|
+
try { call = request || storeRequest(); }
|
|
1108
|
+
catch (e) { errlog(`bongos module update: ${e.message}`); return 1; }
|
|
1109
|
+
|
|
1110
|
+
const got = await fetchVerified(key, {
|
|
1111
|
+
wanted, call, core, log, errlog, verb: 'update',
|
|
1112
|
+
shouldDownload: (d) => {
|
|
1113
|
+
const next = parseVersion(String(d.version));
|
|
1114
|
+
if (next && cmp(next, have) > 0) return true;
|
|
1115
|
+
log(`\n✓ "${key}" is up to date at ${installed}.`);
|
|
1116
|
+
return { code: 0, upToDate: true };
|
|
1117
|
+
},
|
|
1118
|
+
});
|
|
1119
|
+
if (!got.ok) return got.code;
|
|
1120
|
+
|
|
1121
|
+
try { replaceModule(got.verified.files, target); }
|
|
1122
|
+
catch (e) { errlog(`bongos module update: could not replace the module, ${installed} is still in place — ${e.message}`); return 1; }
|
|
1123
|
+
log(` ✓ replaced ${installed} with ${got.described.version} at ${target}`);
|
|
998
1124
|
|
|
999
|
-
|
|
1125
|
+
if (!(await recordPlaced(key, got.described.version, { call, errlog, verb: 'update' }))) return 1;
|
|
1126
|
+
log(`\n✓ Updated "${key}" ${installed} → ${got.described.version}. Its on/off switch is unchanged.`);
|
|
1000
1127
|
return 0;
|
|
1001
1128
|
}
|
|
1002
1129
|
|
|
@@ -1015,6 +1142,8 @@ function printHelp(out = console.log) {
|
|
|
1015
1142
|
out(' Pack a module version (tarball + hash manifest) and upload it to the module store');
|
|
1016
1143
|
out(' bongos module install <key> [--version X.Y.Z]');
|
|
1017
1144
|
out(' Acquire a module from the store, verify it and place it under modules/ (not enabled)');
|
|
1145
|
+
out(' bongos module update <key> [--version X.Y.Z]');
|
|
1146
|
+
out(' Move an installed store module to a newer version (same path, paid or free)');
|
|
1018
1147
|
}
|
|
1019
1148
|
|
|
1020
1149
|
async function main(argv) {
|
|
@@ -1027,6 +1156,7 @@ async function main(argv) {
|
|
|
1027
1156
|
if (sub === 'submit') return cmdSubmit(args.slice(1));
|
|
1028
1157
|
if (sub === 'publish') return cmdPublish(args.slice(1));
|
|
1029
1158
|
if (sub === 'install') return cmdInstall(args.slice(1));
|
|
1159
|
+
if (sub === 'update') return cmdUpdate(args.slice(1));
|
|
1030
1160
|
if (!sub || sub === 'help' || sub === '--help' || sub === '-h') {
|
|
1031
1161
|
printHelp();
|
|
1032
1162
|
return sub ? 0 : 2;
|
|
@@ -1037,7 +1167,7 @@ async function main(argv) {
|
|
|
1037
1167
|
}
|
|
1038
1168
|
|
|
1039
1169
|
module.exports = {
|
|
1040
|
-
main, cmdNew, cmdList, cmdUpgrade, cmdCheck, cmdSubmit, cmdPublish, cmdInstall, printHelp,
|
|
1170
|
+
main, cmdNew, cmdList, cmdUpgrade, cmdCheck, cmdSubmit, cmdPublish, cmdInstall, cmdUpdate, printHelp,
|
|
1041
1171
|
gatherCatalog, formatCatalog, ownProjectName,
|
|
1042
1172
|
scaffoldModule, gatherUpgrade, evaluateUpgrade, buildManifest,
|
|
1043
1173
|
formatMaintenanceHint, isPastRemoveAfter,
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.19.
|
|
78
|
+
const CORE_VERSION = '1.19.1080'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// tests/module_update.mjs — `bongos module update <key>` (task 1003786, ADR 0338 D1,
|
|
2
|
+
// criterion wa5-module-economy). No network, no database: cmdUpdate runs against a
|
|
3
|
+
// fake store serving REAL packed tarballs, so verification is the real
|
|
4
|
+
// verifyModuleArtifact.
|
|
5
|
+
//
|
|
6
|
+
// The criterion's teeth are the parity tests: an update to a paid module the caller
|
|
7
|
+
// holds and to a free one make the identical calls with the identical outcome, and
|
|
8
|
+
// the shared acquisition path carries no price-conditional code at all.
|
|
9
|
+
//
|
|
10
|
+
// Run: node tests/module_update.mjs
|
|
11
|
+
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import { strict as assert } from 'node:assert';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const { packModule, verifyModuleArtifact } = require('../scripts/gds/module-artifact.js');
|
|
23
|
+
const { readTar, buildTar } = require('../scripts/gds/artifact-format.js');
|
|
24
|
+
const { cmdUpdate, cmdInstall } = require('../scripts/gds/module.js');
|
|
25
|
+
|
|
26
|
+
const NOW = () => new Date('2026-09-29T00:00:00Z');
|
|
27
|
+
|
|
28
|
+
// A real packed tarball for weather@version, with `extra` files beside module.json.
|
|
29
|
+
async function packed(version, extra = {}, mj = {}) {
|
|
30
|
+
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-update-src-'));
|
|
31
|
+
const manifest = { key: 'weather', title: 'Weather', description: 'Shows the weather.', version, coreVersion: '^1.0.0', contributes: {}, ...mj };
|
|
32
|
+
const dir = path.join(src, 'weather');
|
|
33
|
+
const files = { 'module.json': JSON.stringify(manifest, null, 2), 'routes/weather.js': `module.exports = () => '${version}';\n`, ...extra };
|
|
34
|
+
for (const [rel, body] of Object.entries(files)) {
|
|
35
|
+
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
|
|
36
|
+
fs.writeFileSync(path.join(dir, rel), body);
|
|
37
|
+
}
|
|
38
|
+
const { tgz } = packModule('weather', { modulesDir: src, now: NOW, modeOf: () => '644' });
|
|
39
|
+
const v = await verifyModuleArtifact(tgz, { key: 'weather' });
|
|
40
|
+
assert.ok(v.ok);
|
|
41
|
+
return { tgz, v };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// An installed weather@version in a fresh modules dir.
|
|
45
|
+
function installedAt(version, extra = {}) {
|
|
46
|
+
const modulesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-update-dest-'));
|
|
47
|
+
const dir = path.join(modulesDir, 'weather');
|
|
48
|
+
const files = { 'module.json': JSON.stringify({ key: 'weather', version }), 'routes/weather.js': `module.exports = () => '${version}';\n`, ...extra };
|
|
49
|
+
for (const [rel, body] of Object.entries(files)) {
|
|
50
|
+
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
|
|
51
|
+
fs.writeFileSync(path.join(dir, rel), body);
|
|
52
|
+
}
|
|
53
|
+
return modulesDir;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A fake store. `source` is how the caller came to hold the module; the store's own
|
|
57
|
+
// acquire route never looks at price for a holder, so nothing else differs.
|
|
58
|
+
function fakeStore({ tgz, v }, { source = 'free', over = {} } = {}) {
|
|
59
|
+
const calls = [];
|
|
60
|
+
const request = async (method, urlPath, opts = {}) => {
|
|
61
|
+
calls.push({ method, urlPath, json: opts.json });
|
|
62
|
+
if (urlPath.endsWith('/acquire')) {
|
|
63
|
+
return over.acquire || { ok: true, status: 200, data: {
|
|
64
|
+
ok: true, module_key: 'weather', version: v.version, core_version: v.coreVersion,
|
|
65
|
+
tarball_sha256: v.tarballSha256, tree_sha256: v.treeSha256, tarball_bytes: v.bytes, channel: 'general',
|
|
66
|
+
entitlement: { module_key: 'weather', holder_kind: 'builder', holder_ref: '42', source, version: '1.2.0' },
|
|
67
|
+
} };
|
|
68
|
+
}
|
|
69
|
+
if (urlPath.endsWith('/tarball')) return { ok: true, status: 200, buf: over.buf || tgz };
|
|
70
|
+
if (urlPath.endsWith('/acquired')) return { ok: true, status: 200, data: { ok: true } };
|
|
71
|
+
throw new Error(`unexpected ${method} ${urlPath}`);
|
|
72
|
+
};
|
|
73
|
+
return { calls, request };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const quiet = () => { const out = []; return { out, log: (m) => out.push(m), errlog: (m) => out.push(m) }; };
|
|
77
|
+
const opts = (modulesDir, store, q = quiet()) => ({ ...q, modulesDir, roots: [], request: store.request, core: '1.19.0' });
|
|
78
|
+
const route = (modulesDir) => fs.readFileSync(path.join(modulesDir, 'weather', 'routes', 'weather.js'), 'utf8');
|
|
79
|
+
|
|
80
|
+
// ---- update ----
|
|
81
|
+
|
|
82
|
+
test('updates an installed module to the newest version: files replaced, old-only files gone, version recorded', async () => {
|
|
83
|
+
const modulesDir = installedAt('1.2.0', { 'old-only.js': 'x' });
|
|
84
|
+
const store = fakeStore(await packed('1.3.0'));
|
|
85
|
+
const q = quiet();
|
|
86
|
+
assert.equal(await cmdUpdate(['weather'], opts(modulesDir, store, q)), 0, q.out.join('\n'));
|
|
87
|
+
assert.equal(route(modulesDir), "module.exports = () => '1.3.0';\n");
|
|
88
|
+
assert.ok(!fs.existsSync(path.join(modulesDir, 'weather', 'old-only.js')), 'the new version is exactly what was published');
|
|
89
|
+
assert.deepEqual(fs.readdirSync(modulesDir), ['weather'], 'no staging or previous dir left behind');
|
|
90
|
+
assert.deepEqual(store.calls.map((c) => `${c.method} ${c.urlPath}`), [
|
|
91
|
+
'POST /store/modules/weather/acquire',
|
|
92
|
+
'GET /store/modules/weather/versions/1.3.0/tarball',
|
|
93
|
+
'POST /store/modules/weather/acquired',
|
|
94
|
+
]);
|
|
95
|
+
assert.deepEqual(store.calls[2].json, { version: '1.3.0' });
|
|
96
|
+
assert.match(q.out.join('\n'), /1\.2\.0 → 1\.3\.0\. Its on\/off switch is unchanged/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('already on the newest version: says so and downloads nothing', async () => {
|
|
100
|
+
const modulesDir = installedAt('1.3.0');
|
|
101
|
+
const store = fakeStore(await packed('1.3.0'));
|
|
102
|
+
const q = quiet();
|
|
103
|
+
assert.equal(await cmdUpdate(['weather'], opts(modulesDir, store, q)), 0);
|
|
104
|
+
assert.deepEqual(store.calls.map((c) => c.urlPath), ['/store/modules/weather/acquire']);
|
|
105
|
+
assert.match(q.out.join('\n'), /up to date at 1\.3\.0/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('an older --version is refused before the store is asked: update only moves forward', async () => {
|
|
109
|
+
const modulesDir = installedAt('1.3.0');
|
|
110
|
+
const store = fakeStore(await packed('1.2.0'));
|
|
111
|
+
assert.equal(await cmdUpdate(['weather', '--version', '1.2.0'], opts(modulesDir, store)), 1);
|
|
112
|
+
assert.equal(store.calls.length, 0);
|
|
113
|
+
assert.equal(route(modulesDir), "module.exports = () => '1.3.0';\n");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('--version asks the store for that exact version', async () => {
|
|
117
|
+
const modulesDir = installedAt('1.2.0');
|
|
118
|
+
const store = fakeStore(await packed('1.4.0'));
|
|
119
|
+
assert.equal(await cmdUpdate(['weather', '--version', '1.4.0'], opts(modulesDir, store)), 0);
|
|
120
|
+
assert.deepEqual(store.calls[0].json, { version: '1.4.0' });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('a module that is not installed is refused, pointing at install', async () => {
|
|
124
|
+
const modulesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-update-empty-'));
|
|
125
|
+
const store = fakeStore(await packed('1.3.0'));
|
|
126
|
+
const q = quiet();
|
|
127
|
+
assert.equal(await cmdUpdate(['weather'], opts(modulesDir, store, q)), 1);
|
|
128
|
+
assert.match(q.out.join('\n'), /not installed here\. Run `bongos module install weather`/);
|
|
129
|
+
assert.equal(store.calls.length, 0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('a module bundled with the core is refused: it updates with the core', async () => {
|
|
133
|
+
const modulesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-update-empty-'));
|
|
134
|
+
const coreRoot = installedAt('1.0.0');
|
|
135
|
+
const store = fakeStore(await packed('1.3.0'));
|
|
136
|
+
const q = quiet();
|
|
137
|
+
assert.equal(await cmdUpdate(['weather'], { ...opts(modulesDir, store, q), roots: [coreRoot] }), 1);
|
|
138
|
+
assert.match(q.out.join('\n'), /comes with the core, not the store/);
|
|
139
|
+
assert.equal(store.calls.length, 0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('a tampered download leaves the installed version untouched', async () => {
|
|
143
|
+
const modulesDir = installedAt('1.2.0');
|
|
144
|
+
const next = await packed('1.3.0');
|
|
145
|
+
const entries = readTar(next.tgz);
|
|
146
|
+
entries.find((e) => e.name.endsWith('routes/weather.js')).buf = Buffer.from('evil();\n');
|
|
147
|
+
const store = fakeStore(next, { over: { buf: buildTar(entries) } });
|
|
148
|
+
const q = quiet();
|
|
149
|
+
assert.equal(await cmdUpdate(['weather'], opts(modulesDir, store, q)), 1);
|
|
150
|
+
assert.match(q.out.join('\n'), /failed verification \(hash_mismatch\)/);
|
|
151
|
+
assert.equal(route(modulesDir), "module.exports = () => '1.2.0';\n");
|
|
152
|
+
assert.deepEqual(fs.readdirSync(modulesDir), ['weather']);
|
|
153
|
+
assert.ok(!store.calls.some((c) => c.urlPath.endsWith('/acquired')));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('a version this core cannot mount is refused before download, the installed one kept', async () => {
|
|
157
|
+
const modulesDir = installedAt('1.2.0');
|
|
158
|
+
const store = fakeStore(await packed('2.0.0', {}, { coreVersion: '^2.0.0' }));
|
|
159
|
+
assert.equal(await cmdUpdate(['weather'], opts(modulesDir, store)), 1);
|
|
160
|
+
assert.equal(store.calls.length, 1);
|
|
161
|
+
assert.equal(route(modulesDir), "module.exports = () => '1.2.0';\n");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('a store refusal (e.g. a delisted module) keeps the installed version', async () => {
|
|
165
|
+
const modulesDir = installedAt('1.2.0');
|
|
166
|
+
const store = fakeStore(await packed('1.3.0'), { over: { acquire: { ok: false, status: 409, data: { error: { code: 'module_delisted', message: 'delisted' } } } } });
|
|
167
|
+
const q = quiet();
|
|
168
|
+
assert.equal(await cmdUpdate(['weather'], opts(modulesDir, store, q)), 1);
|
|
169
|
+
assert.match(q.out.join('\n'), /HTTP 409\): delisted/);
|
|
170
|
+
assert.equal(route(modulesDir), "module.exports = () => '1.2.0';\n");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ---- parity: paid and free take one path (criterion wa5-module-economy) ----
|
|
174
|
+
|
|
175
|
+
test('a paid module the caller holds and a free one update through identical calls with an identical outcome', async () => {
|
|
176
|
+
const next = await packed('1.3.0');
|
|
177
|
+
const runs = {};
|
|
178
|
+
for (const source of ['free', 'purchase', 'grant', 'core']) {
|
|
179
|
+
const modulesDir = installedAt('1.2.0');
|
|
180
|
+
const store = fakeStore(next, { source });
|
|
181
|
+
const q = quiet();
|
|
182
|
+
const code = await cmdUpdate(['weather'], opts(modulesDir, store, q));
|
|
183
|
+
runs[source] = { code, calls: store.calls, out: q.out.join('\n').replaceAll(modulesDir, '<dir>'), route: route(modulesDir) };
|
|
184
|
+
}
|
|
185
|
+
for (const source of ['purchase', 'grant', 'core']) {
|
|
186
|
+
assert.deepEqual(runs[source], runs.free, `an update of a module held as '${source}' must not differ from a free one`);
|
|
187
|
+
}
|
|
188
|
+
assert.equal(runs.free.code, 0);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Strip comments so only code is scanned; a word in an explanation is not a branch.
|
|
192
|
+
function codeOf(src, fnName) {
|
|
193
|
+
const start = src.indexOf(`function ${fnName}(`);
|
|
194
|
+
assert.ok(start !== -1, `${fnName} exists`);
|
|
195
|
+
const next = src.indexOf('\nfunction ', start + 1);
|
|
196
|
+
const nextAsync = src.indexOf('\nasync function ', start + 1);
|
|
197
|
+
const end = [next, nextAsync].filter((i) => i > start).sort((a, b) => a - b)[0] ?? src.length;
|
|
198
|
+
return src.slice(start, end).replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
test('the shared acquisition path, install and update carry no price-conditional code', () => {
|
|
202
|
+
const src = fs.readFileSync(path.join(ROOT, 'scripts/gds/module.js'), 'utf8');
|
|
203
|
+
for (const fn of ['fetchVerified', 'recordPlaced', 'cmdInstall', 'cmdUpdate', 'replaceModule', 'placeModule']) {
|
|
204
|
+
assert.doesNotMatch(codeOf(src, fn), /\b(price|priced|paid|cents|credits|free|purchase|source)\b/i, `${fn} must not read how the module was obtained`);
|
|
205
|
+
}
|
|
206
|
+
// Both commands go through the one path, not two copies of it.
|
|
207
|
+
assert.match(codeOf(src, 'cmdInstall'), /await fetchVerified\(key,/);
|
|
208
|
+
assert.match(codeOf(src, 'cmdUpdate'), /await fetchVerified\(key,/);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('the store only looks at price for a caller who holds no entitlement, so a holder is never charged again', () => {
|
|
212
|
+
const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/modules.js'), 'utf8');
|
|
213
|
+
const start = src.indexOf("router.post('/store/modules/:key/acquire'");
|
|
214
|
+
const block = src.slice(start, src.indexOf('router.get(', start));
|
|
215
|
+
const priceAt = block.indexOf('r.free');
|
|
216
|
+
const guardAt = block.indexOf('if (!held.entitled) {');
|
|
217
|
+
assert.ok(guardAt !== -1 && priceAt > guardAt, 'the price check sits inside the not-entitled branch');
|
|
218
|
+
assert.equal(block.split('r.free').length - 1, 1, 'price is read in exactly one place');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ---- install: the grader's finding on task 1003785 ----
|
|
222
|
+
|
|
223
|
+
test('install refuses a key already present in another discovery root (a module bundled with the core)', async () => {
|
|
224
|
+
const modulesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-install-empty-'));
|
|
225
|
+
const coreRoot = installedAt('1.0.0');
|
|
226
|
+
const store = fakeStore(await packed('1.3.0'));
|
|
227
|
+
const q = quiet();
|
|
228
|
+
assert.equal(await cmdInstall(['weather'], { ...opts(modulesDir, store, q), roots: [coreRoot] }), 1);
|
|
229
|
+
assert.match(q.out.join('\n'), /already on disk at .*run `bongos module update weather`/);
|
|
230
|
+
assert.equal(store.calls.length, 0);
|
|
231
|
+
assert.deepEqual(fs.readdirSync(modulesDir), []);
|
|
232
|
+
});
|