@bongos/core 1.23.5 → 1.23.7
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 +66 -36
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0356-a-recruit-invite-expires-30-days-after-it-is-sent-read-time.md +2 -1
- package/docs/api/openapi.json +96 -5
- package/docs/api-reference.md +3 -2
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +3 -0
- package/modules/onboarding/routes/access-requests.js +10 -9
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/bongos/login.js +13 -14
- package/scripts/bongos/module-remove.js +122 -0
- package/scripts/bongos/module-store-cli.js +182 -0
- package/scripts/bongos/module.js +42 -127
- package/scripts/gds/module-remove.js +6 -0
- package/scripts/gds/module-store-cli.js +6 -0
- package/src/bongos/auth-admission.js +3 -3
- package/src/bongos/middleware/rate-limit.js +1 -1
- package/src/bongos/module-listing.js +12 -2
- package/src/bongos/module-store.js +49 -1
- package/src/bongos/route-rank-check.js +3 -0
- package/src/bongos/routes/auth.js +7 -11
- package/src/bongos/routes/modules.js +32 -1
- package/src/module-api.js +1 -1
- package/tests/account_existence_rate_limit.mjs +7 -3
- package/tests/application_lifecycle.mjs +4 -3
- package/tests/bongos_login.mjs +15 -4
- package/tests/invite_expiry_db.mjs +1 -1
- package/tests/module_remove.mjs +121 -0
- package/tests/module_store_cli.mjs +1 -1
- package/tests/module_store_price.mjs +207 -0
|
@@ -357,9 +357,12 @@ module.exports = function buildAccessRequestsRouter() {
|
|
|
357
357
|
// (the device_code is spent), so the CLI can't re-poll the flow — it polls this
|
|
358
358
|
// instead, then restarts sign-in once admitted. Exposes nothing the public POST
|
|
359
359
|
// above doesn't already reveal through its 409s (already_member / already_approved).
|
|
360
|
-
// status ∈ member | invited |
|
|
361
|
-
//
|
|
362
|
-
//
|
|
360
|
+
// status ∈ member | invited | not_admitted; admitted = member||invited (ADR 0217 D4,
|
|
361
|
+
// task 1003359). `not_admitted` covers pending, declined, a lapsed invite and no
|
|
362
|
+
// request at all, so a caller who is not the subject cannot learn that someone
|
|
363
|
+
// applied here or was turned down. It does NOT close the membership oracle —
|
|
364
|
+
// `admitted` still answers that, and the limiter below is what bounds enumeration.
|
|
365
|
+
// A lapsed invite answering `not_admitted` rather than `expired` amends ADR 0356 D4.
|
|
363
366
|
// rank: public — a would-be builder is by definition outside the system.
|
|
364
367
|
// Declared BEFORE `GET /access-requests` so the literal `/status` path is matched
|
|
365
368
|
// as its own route (not swallowed as a query on the list route).
|
|
@@ -370,9 +373,7 @@ module.exports = function buildAccessRequestsRouter() {
|
|
|
370
373
|
// per-IP budget: core's GET /auth/web/admission-status (the boolean twin of
|
|
371
374
|
// this answer) mounts the very same instance, so the two cannot be alternated
|
|
372
375
|
// for double the rate. The limiter runs before the queries, so a refused probe
|
|
373
|
-
// costs no DB work.
|
|
374
|
-
// is not the subject cannot tell pending/dismissed/none apart — is the owner's
|
|
375
|
-
// call and is filed as a blocker, not decided here.
|
|
376
|
+
// costs no DB work.
|
|
376
377
|
router.get('/access-requests/status', accountExistenceReadRateLimit, async (req, res) => {
|
|
377
378
|
const login = normalizeLogin(req.query.github_login);
|
|
378
379
|
if (!GITHUB_LOGIN_RE.test(login)) {
|
|
@@ -386,7 +387,7 @@ module.exports = function buildAccessRequestsRouter() {
|
|
|
386
387
|
if (member.rows.length > 0) {
|
|
387
388
|
return res.json({ github_login: login, status: 'member', admitted: true });
|
|
388
389
|
}
|
|
389
|
-
//
|
|
390
|
+
// A live invite sorts first, so the top row admits only if it is one.
|
|
390
391
|
const ar = await pool.query(
|
|
391
392
|
`SELECT status, (status = 'invited' AND ${inviteExpiredSql()}) AS expired
|
|
392
393
|
FROM access_requests
|
|
@@ -396,8 +397,8 @@ module.exports = function buildAccessRequestsRouter() {
|
|
|
396
397
|
[login]
|
|
397
398
|
);
|
|
398
399
|
const top = ar.rows[0];
|
|
399
|
-
const
|
|
400
|
-
return res.json({ github_login: login, status
|
|
400
|
+
const invited = !!top && top.status === 'invited' && !top.expired;
|
|
401
|
+
return res.json({ github_login: login, status: invited ? 'invited' : 'not_admitted', admitted: invited });
|
|
401
402
|
} catch (err) {
|
|
402
403
|
console.error('[gds] GET /access-requests/status', err);
|
|
403
404
|
return res.fail('status_failed', { status: 500, message: 'internal error' });
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.23.
|
|
3
|
+
"version": "1.23.7",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.23.
|
|
9
|
+
"version": "1.23.7",
|
|
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.23.
|
|
3
|
+
"version": "1.23.7",
|
|
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
|
@@ -10413,5 +10413,21 @@
|
|
|
10413
10413
|
"id": "1004895",
|
|
10414
10414
|
"text": "A broken module version can now be pulled back from the store, so new installs and updates stop getting it while projects that already have it are told."
|
|
10415
10415
|
}
|
|
10416
|
+
],
|
|
10417
|
+
"1.23.6": [
|
|
10418
|
+
{
|
|
10419
|
+
"id": "1004894",
|
|
10420
|
+
"text": "Module authors can now set a price for their module, in money or credits; paid modules show their price and are marked not yet buyable until buying opens."
|
|
10421
|
+
},
|
|
10422
|
+
{
|
|
10423
|
+
"id": "1003359",
|
|
10424
|
+
"text": "People checking whether someone has applied to a project can no longer tell a pending, declined or never-filed request apart, and the sign-in tool no longer promises an approval that may never come."
|
|
10425
|
+
}
|
|
10426
|
+
],
|
|
10427
|
+
"1.23.7": [
|
|
10428
|
+
{
|
|
10429
|
+
"id": "1004896",
|
|
10430
|
+
"text": "Builders can now take a module they installed from the store back off their project with one command; its folder is kept aside so nothing is lost."
|
|
10431
|
+
}
|
|
10416
10432
|
]
|
|
10417
10433
|
}
|
package/scripts/bongos/login.js
CHANGED
|
@@ -43,17 +43,24 @@ function normalizeInstanceBase(raw) {
|
|
|
43
43
|
return s;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
// Decide what an /access-requests/status poll means for the sign-in loop.
|
|
46
|
+
// Decide what an /access-requests/status poll means for the sign-in loop. The probe
|
|
47
|
+
// answers only admitted or not (ADR 0217 D4), so anything else is a wait — even an
|
|
48
|
+
// older instance's `dismissed`/`expired`, which the loop no longer reads.
|
|
47
49
|
function pollDecision(body) {
|
|
48
50
|
if (!body || typeof body !== 'object') return 'wait';
|
|
49
51
|
if (body.admitted === true) return 'admitted';
|
|
50
52
|
if (body.status === 'member' || body.status === 'invited') return 'admitted';
|
|
51
|
-
if (body.status === 'dismissed') return 'dismissed';
|
|
52
|
-
// An invite past its 30 days (task 1002969): the door is shut until they invite again.
|
|
53
|
-
if (body.status === 'expired') return 'expired';
|
|
54
53
|
return 'wait';
|
|
55
54
|
}
|
|
56
55
|
|
|
56
|
+
// What the loop says when it stops waiting. It cannot know whether the request is
|
|
57
|
+
// still pending, was declined, or rode a lapsed invite, so it promises nothing.
|
|
58
|
+
function waitTimeoutMessage(base) {
|
|
59
|
+
return "✖ Stopped waiting after 30 minutes — you haven't been let in yet. If your request was turned down "
|
|
60
|
+
+ "or your invite has run out, waiting longer won't change that. Ask the project's owner, "
|
|
61
|
+
+ `then re-run \`bongos login ${base}\`.`;
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
// --- I/O helpers -------------------------------------------------------------
|
|
58
65
|
|
|
59
66
|
async function fetchJson(url, opts = {}) {
|
|
@@ -300,16 +307,8 @@ async function main() {
|
|
|
300
307
|
log('\n ✓ Approved! Finishing sign-in — approve once more in your browser.');
|
|
301
308
|
break;
|
|
302
309
|
}
|
|
303
|
-
if (decision === 'dismissed') {
|
|
304
|
-
console.error('\n✖ your access request was declined.');
|
|
305
|
-
process.exit(1);
|
|
306
|
-
}
|
|
307
|
-
if (decision === 'expired') {
|
|
308
|
-
console.error('\n✖ your invite has expired — ask the project to invite you again.');
|
|
309
|
-
process.exit(1);
|
|
310
|
-
}
|
|
311
310
|
if (Date.now() > admitDeadline) {
|
|
312
|
-
console.error(
|
|
311
|
+
console.error(`\n${waitTimeoutMessage(base)}`);
|
|
313
312
|
process.exit(1);
|
|
314
313
|
}
|
|
315
314
|
process.stdout.write('.');
|
|
@@ -357,4 +356,4 @@ if (require.main === module) {
|
|
|
357
356
|
});
|
|
358
357
|
}
|
|
359
358
|
|
|
360
|
-
module.exports = { normalizeInstanceBase, pollDecision, runDeviceFlow, logOtherInstances };
|
|
359
|
+
module.exports = { normalizeInstanceBase, pollDecision, waitTimeoutMessage, runDeviceFlow, logOtherInstances };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
//
|
|
3
|
+
// scripts/bongos/module-remove.js — `bongos module remove <key>`: take a module this
|
|
4
|
+
// project installed from the store back off it (task 1004896, goal 1000091).
|
|
5
|
+
//
|
|
6
|
+
// WHY it moves the folder aside instead of deleting it. Install places a store
|
|
7
|
+
// module under the instance's own modules/, which is also where a project keeps the
|
|
8
|
+
// modules it builds itself; the only thing that tells the two apart is whether the
|
|
9
|
+
// store knows the key. A wrong guess must cost nothing, so the folder is MOVED to
|
|
10
|
+
// var/removed-modules/<key>-<version>-<stamp>/ and the command says where: putting it
|
|
11
|
+
// back is one move, and deleting it for good is a separate, deliberate step.
|
|
12
|
+
//
|
|
13
|
+
// What it refuses, before touching anything:
|
|
14
|
+
// - a module the core ships (it is the core's, not this project's);
|
|
15
|
+
// - a module that is switched on — in config/env here, or in the hall's on/off
|
|
16
|
+
// switch when the server answers — because the loader would mount a module whose
|
|
17
|
+
// files are gone;
|
|
18
|
+
// - a key the store does not know: that is this project's own code, not a store copy.
|
|
19
|
+
// What it leaves alone, on purpose (owner, 2026-10-26): the module's database tables
|
|
20
|
+
// (reinstalling brings the data back; the output says so), and the entitlement (a
|
|
21
|
+
// paid module can be installed again without paying twice). Nothing on the server is
|
|
22
|
+
// written: update checks and install reports read the modules on disk, so once the
|
|
23
|
+
// folder is gone they stop on their own.
|
|
24
|
+
//
|
|
25
|
+
// It lives in its own file because scripts/bongos/module.js sits near the 1,500-line
|
|
26
|
+
// ratchet; module.js dispatches to it and re-exports cmdRemove.
|
|
27
|
+
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
const { KEY_RE } = require('../../src/module-loader/manifest-schema');
|
|
31
|
+
|
|
32
|
+
const REMOVE_USAGE = 'usage: bongos module remove <key>';
|
|
33
|
+
|
|
34
|
+
// Where removed folders go: <instance root>/var/removed-modules (beside the other
|
|
35
|
+
// instance-local state the core keeps under var/, never inside the core package).
|
|
36
|
+
function removedModulesDir(instanceRoot) {
|
|
37
|
+
return path.join(instanceRoot, 'var', 'removed-modules');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A filesystem-safe timestamp for the parked folder's name. Pure.
|
|
41
|
+
function stamp(now) {
|
|
42
|
+
return now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Is the key switched on in the hall's switch? true / false, or null when the
|
|
46
|
+
// server could not say (offline, older core) — then the local verdict stands.
|
|
47
|
+
async function hallEnabled(call, key) {
|
|
48
|
+
try {
|
|
49
|
+
const res = await call('GET', '/modules');
|
|
50
|
+
if (!res.ok || !res.data || !Array.isArray(res.data.modules)) return null;
|
|
51
|
+
const row = res.data.modules.find((m) => m.key === key);
|
|
52
|
+
return row ? row.enabled === true : null;
|
|
53
|
+
} catch { return null; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function cmdRemove(args, {
|
|
57
|
+
log = console.log, errlog = console.error,
|
|
58
|
+
gather, request, storeRequest, storeError,
|
|
59
|
+
instanceRoot = require('../../src/instance-config').resolveInstanceRoot(),
|
|
60
|
+
now = () => new Date(),
|
|
61
|
+
} = {}) {
|
|
62
|
+
const key = args[0];
|
|
63
|
+
if (!key || key.startsWith('--') || !KEY_RE.test(key)) {
|
|
64
|
+
errlog('bongos module remove: name the module — a lowercase kebab-case key');
|
|
65
|
+
errlog(REMOVE_USAGE);
|
|
66
|
+
return 2;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const dir = path.join(instanceRoot, 'modules', key);
|
|
70
|
+
let rows = [];
|
|
71
|
+
try { rows = gather().rows; } catch (e) { errlog(`bongos module remove: ${e.message}`); return 1; }
|
|
72
|
+
const row = rows.find((r) => r.key === key);
|
|
73
|
+
if (row && row.source === 'core') {
|
|
74
|
+
errlog(`REFUSING: "${key}" ships with the core, not from the store. Switch it off instead if you do not want it.`);
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
if (!fs.existsSync(dir)) {
|
|
78
|
+
errlog(`REFUSING: "${key}" is not installed in this project (no modules/${key}/ folder).`);
|
|
79
|
+
return 1;
|
|
80
|
+
}
|
|
81
|
+
if (row && row.enabled) {
|
|
82
|
+
errlog(`REFUSING: "${key}" is switched on. Switch it off first — remove "${key}": true from config/modules.json (or its <PREFIX>_MODULE_ setting) — then run this again.`);
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let call;
|
|
87
|
+
try { call = request || storeRequest(); }
|
|
88
|
+
catch (e) { errlog(`bongos module remove: ${e.message}`); return 1; }
|
|
89
|
+
if (await hallEnabled(call, key) === true) {
|
|
90
|
+
errlog(`REFUSING: "${key}" is switched on in the hall's Modules tab. Switch it off there first, then run this again.`);
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
let res;
|
|
94
|
+
try { res = await call('GET', `/store/modules/${encodeURIComponent(key)}/listing`); }
|
|
95
|
+
catch (e) { errlog(`bongos module remove: the store could not be reached — ${e.message}`); return 1; }
|
|
96
|
+
if (res.status === 404) {
|
|
97
|
+
errlog(`REFUSING: the store has no module "${key}", so modules/${key}/ is this project's own code, not a store copy. Nothing was moved; delete it by hand if you mean to.`);
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
if (!res.ok) { errlog(`bongos module remove: the store answered ${storeError(res)}`); return 1; }
|
|
101
|
+
|
|
102
|
+
const version = (row && row.version) || 'unknown';
|
|
103
|
+
const parked = path.join(removedModulesDir(instanceRoot), `${key}-${version}-${stamp(now())}`);
|
|
104
|
+
try {
|
|
105
|
+
fs.mkdirSync(path.dirname(parked), { recursive: true });
|
|
106
|
+
// A `*` .gitignore, so a parked folder never dirties the instance tree that
|
|
107
|
+
// `bongos upgrade` requires clean (the var/task-visuals precedent, task 1004381).
|
|
108
|
+
const ignore = path.join(path.dirname(parked), '.gitignore');
|
|
109
|
+
if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, '*\n');
|
|
110
|
+
fs.renameSync(dir, parked);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
errlog(`bongos module remove: could not move modules/${key}/ aside — ${e.message}. Nothing was removed.`);
|
|
113
|
+
return 1;
|
|
114
|
+
}
|
|
115
|
+
log(`✓ Removed "${key}" ${version} from this project.`);
|
|
116
|
+
log(` Its folder was moved to ${path.relative(instanceRoot, parked)} — move it back to modules/${key}/ to undo, or delete it when you are sure.`);
|
|
117
|
+
log(' Its database tables were left in place, so reinstalling brings its data back.');
|
|
118
|
+
log(` You still own it: \`bongos module install ${key}\` puts it back without buying it again.`);
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { cmdRemove };
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
//
|
|
3
|
+
// scripts/bongos/module-store-cli.js — the module STORE from the terminal: the store
|
|
4
|
+
// client every store verb calls, and `bongos module store` / `bongos module price`
|
|
5
|
+
// (tasks 1003811, 1004894). Carved out of scripts/bongos/module.js, which requires
|
|
6
|
+
// and re-exports all of it, so `bongos module <verb>` and every importer are
|
|
7
|
+
// unchanged.
|
|
8
|
+
//
|
|
9
|
+
// WHY its own file. module.js crossed the 1,500-line ratchet (fitness.js) when the
|
|
10
|
+
// price command landed, and these pieces share one concern — talking to the store —
|
|
11
|
+
// while the rest of module.js works on the checkout's own modules/ tree.
|
|
12
|
+
//
|
|
13
|
+
// Like module.js it may be required by the server process, so cli-lib's
|
|
14
|
+
// session/fetch plumbing is required lazily inside storeRequest, never at load.
|
|
15
|
+
|
|
16
|
+
const { KEY_RE } = require('../../src/module-loader/manifest-schema');
|
|
17
|
+
|
|
18
|
+
// The store half of the CLI, over three calls (src/bongos/routes/modules.js):
|
|
19
|
+
// acquire (take the entitlement, learn the version), download the tarball, and
|
|
20
|
+
// once the files are placed, record the version acquired. Injected in tests.
|
|
21
|
+
function storeRequest() {
|
|
22
|
+
// Lazy require — cli-lib's session/fetch plumbing must never load in the server
|
|
23
|
+
// process, which requires this file too (the same reason cmdPublish gives).
|
|
24
|
+
const { readSessionToken, dispatchedFetch, noKeepAliveDispatcher, CLI_USER_AGENT } = require('./cli-lib');
|
|
25
|
+
const session = readSessionToken();
|
|
26
|
+
if (!session) throw new Error('no Bongos session — run `bongos setup` first');
|
|
27
|
+
const call = async (method, urlPath, { json, binary = false } = {}) => {
|
|
28
|
+
const res = await dispatchedFetch(`${session.apiBase}/api/bongos${urlPath}`, {
|
|
29
|
+
method,
|
|
30
|
+
headers: {
|
|
31
|
+
Authorization: `Bearer ${session.token}`, 'User-Agent': CLI_USER_AGENT,
|
|
32
|
+
...(json ? { 'Content-Type': 'application/json' } : {}),
|
|
33
|
+
},
|
|
34
|
+
body: json ? JSON.stringify(json) : undefined,
|
|
35
|
+
dispatcher: noKeepAliveDispatcher(),
|
|
36
|
+
});
|
|
37
|
+
if (binary && res.ok) return { ok: true, status: res.status, buf: Buffer.from(await res.arrayBuffer()) };
|
|
38
|
+
const text = await res.text();
|
|
39
|
+
let data;
|
|
40
|
+
try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
|
|
41
|
+
return { ok: res.ok, status: res.status, data };
|
|
42
|
+
};
|
|
43
|
+
// Where the store lives, so install can print a full link to the how-to page.
|
|
44
|
+
call.apiBase = session.apiBase;
|
|
45
|
+
return call;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The hall page that shows a version's HOWTO.md (task 1004366) — the Modules tab,
|
|
49
|
+
// opened on one version. null when the version shipped without one (published
|
|
50
|
+
// before ADR 0347's gate), so install never prints a link that would 404.
|
|
51
|
+
function howtoLink(key, version, files, apiBase) {
|
|
52
|
+
if (!files.some((f) => f.path === 'HOWTO.md')) return null;
|
|
53
|
+
const p = `/builders/modules?howto=${encodeURIComponent(key)}&version=${encodeURIComponent(version)}`;
|
|
54
|
+
return apiBase ? `${String(apiBase).replace(/\/+$/, '')}${p}` : p;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function storeError(res) {
|
|
58
|
+
const err = res.data && (res.data.error || res.data);
|
|
59
|
+
return (err && (err.message || err.code)) || `HTTP ${res.status}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// bongos module store — what the store lists (task 1003811)
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
const STORE_USAGE = 'usage: bongos module store [--sort <part>] [--max-cents N] [--max-credits N] [--json]';
|
|
67
|
+
|
|
68
|
+
// The query GET /store/modules takes, from argv. Returns { query } or { error }.
|
|
69
|
+
// The server checks the values too; this only refuses what is not a value at all.
|
|
70
|
+
function storeQuery(args) {
|
|
71
|
+
const params = [];
|
|
72
|
+
for (const [flag, param] of [['--sort', 'sort'], ['--max-cents', 'max_cents'], ['--max-credits', 'max_credits']]) {
|
|
73
|
+
const i = args.indexOf(flag);
|
|
74
|
+
if (i === -1) continue;
|
|
75
|
+
const v = args[i + 1];
|
|
76
|
+
if (!v || v.startsWith('--')) return { error: `${flag} needs a value` };
|
|
77
|
+
params.push(`${param}=${encodeURIComponent(v)}`);
|
|
78
|
+
}
|
|
79
|
+
return { query: params.length ? `?${params.join('&')}` : '' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function formatPrice(price) {
|
|
83
|
+
if (!price || price.free) return 'free';
|
|
84
|
+
const parts = [];
|
|
85
|
+
if (price.cents != null) parts.push(`$${(price.cents / 100).toFixed(2)}`);
|
|
86
|
+
if (price.credits != null) parts.push(`${price.credits} credits`);
|
|
87
|
+
return parts.join(' or ') || 'free';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatPlace(category) {
|
|
91
|
+
if (!category) return '';
|
|
92
|
+
return category.label ? `${category.parent_title} › ${category.label}` : category.parent_title;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Pure renderer over the GET /store/modules body. It keeps the server's order and
|
|
96
|
+
// adds nothing to it: listed in rank, then New, never ranked.
|
|
97
|
+
function formatStore(body) {
|
|
98
|
+
const out = [];
|
|
99
|
+
out.push('bongos module store — what the store lists, best first');
|
|
100
|
+
out.push(` floor ${body.floor} out of 100 · at most ${body.cap} per sub-category`);
|
|
101
|
+
out.push(` ${body.ranking}`);
|
|
102
|
+
if (body.sort) {
|
|
103
|
+
const part = (body.sort_parts || []).find((x) => x.part === body.sort);
|
|
104
|
+
out.push(` sorted by ${part ? part.title : body.sort}`);
|
|
105
|
+
}
|
|
106
|
+
const max = body.max_price;
|
|
107
|
+
if (max) out.push(` at most ${[max.cents != null ? `$${(max.cents / 100).toFixed(2)}` : null, max.credits != null ? `${max.credits} credits` : null].filter(Boolean).join(' or ')}`);
|
|
108
|
+
out.push('');
|
|
109
|
+
const listed = body.listed || [];
|
|
110
|
+
if (!listed.length) out.push(' Nothing is listed yet.');
|
|
111
|
+
listed.forEach((m, i) => {
|
|
112
|
+
out.push(` ${String(i + 1).padStart(2)}. ${m.key}@${m.version} ${m.overall}/100 ${formatPrice(m.price)}${m.buyable === false ? ' (not yet buyable)' : ''}${m.category ? ` ${formatPlace(m.category)}` : ''}`);
|
|
113
|
+
});
|
|
114
|
+
const fresh = body.new || [];
|
|
115
|
+
if (fresh.length) {
|
|
116
|
+
out.push('');
|
|
117
|
+
out.push(' New — not ranked until projects have installed and used them:');
|
|
118
|
+
for (const m of fresh) out.push(` ${m.key}@${m.version} ${formatPrice(m.price)}${m.buyable === false ? ' (not yet buyable)' : ''}${m.category ? ` ${formatPlace(m.category)}` : ''}`);
|
|
119
|
+
}
|
|
120
|
+
out.push('');
|
|
121
|
+
out.push(' Why a module is not here: GET /store/modules/<key>/listing');
|
|
122
|
+
return out.join('\n');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function cmdStore(args, { log = console.log, errlog = console.error, request } = {}) {
|
|
126
|
+
const q = storeQuery(args);
|
|
127
|
+
if (q.error) { errlog(`bongos module store: ${q.error}`); errlog(STORE_USAGE); return 2; }
|
|
128
|
+
let call;
|
|
129
|
+
try { call = request || storeRequest(); }
|
|
130
|
+
catch (e) { errlog(`bongos module store: ${e.message}`); return 1; }
|
|
131
|
+
let res;
|
|
132
|
+
try { res = await call('GET', `/store/modules${q.query}`); }
|
|
133
|
+
catch (e) { errlog(`bongos module store: the store could not be reached — ${e.message}`); return 1; }
|
|
134
|
+
if (!res.ok) { errlog(`bongos module store: the store answered ${storeError(res)}`); return res.status === 400 ? 2 : 1; }
|
|
135
|
+
if (args.includes('--json')) { log(JSON.stringify(res.data, null, 2)); return 0; }
|
|
136
|
+
log(formatStore(res.data || {}));
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// bongos module price — the author sets a module's price (task 1004894)
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
const PRICE_USAGE = 'usage: bongos module price <key> [--cents N] [--credits N] (0 in either makes it free)';
|
|
145
|
+
|
|
146
|
+
// argv -> { key, cents, credits } or { error }. Pure.
|
|
147
|
+
function priceArgs(args) {
|
|
148
|
+
const key = args[0];
|
|
149
|
+
if (!key || key.startsWith('--') || !KEY_RE.test(key)) return { error: 'name the module: a lowercase kebab-case key' };
|
|
150
|
+
const out = { key, cents: null, credits: null };
|
|
151
|
+
for (const [flag, field] of [['--cents', 'cents'], ['--credits', 'credits']]) {
|
|
152
|
+
const i = args.indexOf(flag);
|
|
153
|
+
if (i === -1) continue;
|
|
154
|
+
const v = args[i + 1];
|
|
155
|
+
if (!v || !/^\d{1,9}$/.test(v)) return { error: `${flag} needs a whole number, 0 or more` };
|
|
156
|
+
out[field] = Number(v);
|
|
157
|
+
}
|
|
158
|
+
if (out.cents === null && out.credits === null) return { error: 'give --cents, --credits or both' };
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function cmdPrice(args, { log = console.log, errlog = console.error, request } = {}) {
|
|
163
|
+
const p = priceArgs(args);
|
|
164
|
+
if (p.error) { errlog(`bongos module price: ${p.error}`); errlog(PRICE_USAGE); return 2; }
|
|
165
|
+
let call;
|
|
166
|
+
try { call = request || storeRequest(); }
|
|
167
|
+
catch (e) { errlog(`bongos module price: ${e.message}`); return 1; }
|
|
168
|
+
let res;
|
|
169
|
+
try { res = await call('POST', `/store/modules/${encodeURIComponent(p.key)}/price`, { json: { cents: p.cents, credits: p.credits } }); }
|
|
170
|
+
catch (e) { errlog(`bongos module price: the store could not be reached — ${e.message}`); return 1; }
|
|
171
|
+
if (!res.ok) { errlog(`bongos module price: the store answered ${storeError(res)}`); return res.status === 400 ? 2 : 1; }
|
|
172
|
+
const withFree = (p) => ({ ...p, free: p.cents === 0 || p.credits === 0 });
|
|
173
|
+
const shown = formatPrice(withFree(res.data.price));
|
|
174
|
+
const was = res.data.previous ? formatPrice(withFree(res.data.previous)) : 'free (no price set)';
|
|
175
|
+
log(`✓ "${p.key}" now costs ${shown} (was ${was}).`);
|
|
176
|
+
if (shown !== 'free') log(' Buying is not open yet, so the store lists it as not yet buyable until it is.');
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = {
|
|
181
|
+
storeRequest, storeError, howtoLink, formatStore, cmdStore, priceArgs, cmdPrice,
|
|
182
|
+
};
|