@bongos/core 1.20.43 → 1.20.45
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 +118 -73
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +5 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +57 -4
- package/docs/api-reference.md +7 -1
- package/docs/copy-inventory.md +46 -16
- package/docs/copy-registry.json +351 -20
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +3 -0
- package/docs/onboarding/slash-commands.md +12 -56
- package/docs/page-inventory.json +33 -4
- package/docs/page-readings.json +997 -962
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/collab.css +8 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands-lib.js +142 -0
- package/modules/hall-ui/public/commands.css +98 -0
- package/modules/hall-ui/public/commands.html +65 -0
- package/modules/hall-ui/public/commands.js +52 -0
- package/modules/hall-ui/public/commands.states.json +14 -0
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/oversight.css +0 -3
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +3 -0
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/commands.md +11 -0
- package/modules/specialities/skills.js +6 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/module-artifact.js +52 -2
- package/scripts/gds/module.js +57 -4
- package/src/bongos/routes/cli-commands.js +49 -0
- package/src/bongos/routes/modules.js +5 -4
- package/src/bongos/routes.js +3 -0
- package/src/bongos/serve-internal.js +4 -1
- package/src/module-api.js +1 -1
- package/src/module-loader/manifest-schema.js +28 -0
- package/tests/hall_commands.mjs +162 -0
- package/tests/hall_lead_row_home.mjs +21 -0
- package/tests/hall_nav.mjs +3 -1
- package/tests/hall_page_gate_map.mjs +3 -0
- package/tests/hall_tweak_editor.mjs +1 -1
- package/tests/module_manifest.mjs +22 -0
- package/tests/module_store_publish.mjs +88 -4
- package/tests/module_store_publish_route.mjs +20 -1
- package/tests/nav_permission_atoms.mjs +1 -1
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// tests/hall_commands.mjs — the hall's Commands page (task 1004473): one table
|
|
2
|
+
// of every skill and one of every bongos CLI verb, read from live sources.
|
|
3
|
+
//
|
|
4
|
+
// The done-when pins: every SKILL.md the instance has, and every bin/bongos.js
|
|
5
|
+
// VERBS entry, appears on the page EXACTLY once. They are checked against the
|
|
6
|
+
// real tree — the same installedSkills()+explainSkill() the
|
|
7
|
+
// /specialities/installed-skills route serves, and the same cliCommands() the
|
|
8
|
+
// /cli/commands route serves — rendered through the page's own lib.
|
|
9
|
+
//
|
|
10
|
+
// Run: node --test tests/hall_commands.mjs
|
|
11
|
+
|
|
12
|
+
import { strict as assert } from 'node:assert';
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import http from 'node:http';
|
|
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 lib = require('../modules/hall-ui/public/commands-lib.js');
|
|
23
|
+
const kit = require('../modules/hall-ui/public/hall-kit.js');
|
|
24
|
+
const skills = require('../modules/specialities/skills.js');
|
|
25
|
+
const { VERBS, GROUPS } = require('../bin/bongos.js');
|
|
26
|
+
const esc = kit.esc;
|
|
27
|
+
|
|
28
|
+
// Every SKILL.md on disk, by name: core .claude/skills/ plus every module's skills/.
|
|
29
|
+
function skillDirsOnDisk({ modules = () => true } = {}) {
|
|
30
|
+
const out = [];
|
|
31
|
+
const scan = (dir) => {
|
|
32
|
+
let names = [];
|
|
33
|
+
try { names = fs.readdirSync(dir); } catch { return; }
|
|
34
|
+
for (const n of names) if (fs.existsSync(path.join(dir, n, 'SKILL.md'))) out.push({ name: n, md: path.join(dir, n, 'SKILL.md') });
|
|
35
|
+
};
|
|
36
|
+
scan(path.join(ROOT, '.claude', 'skills'));
|
|
37
|
+
for (const key of fs.readdirSync(path.join(ROOT, 'modules'))) if (modules(key)) scan(path.join(ROOT, 'modules', key, 'skills'));
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// What the route hands the page: installedSkills() explained, exactly as
|
|
42
|
+
// GET /specialities/installed-skills builds it.
|
|
43
|
+
function servedSkills(isModuleEnabled) {
|
|
44
|
+
return [...skills.installedSkills({ coreRoot: ROOT, isModuleEnabled })]
|
|
45
|
+
.map(([name, entry]) => skills.explainSkill({ name, ...entry }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const count = (html, attr, value) => html.split(`${attr}="${value}"`).length - 1;
|
|
49
|
+
|
|
50
|
+
test('every SKILL.md in the tree appears on the page exactly once (every module on)', () => {
|
|
51
|
+
const html = lib.skillsTableHtml(lib.groupSkills(servedSkills(() => true)), { esc });
|
|
52
|
+
const onDisk = skillDirsOnDisk();
|
|
53
|
+
assert.ok(onDisk.length >= 48, `expected the real skill tree, found ${onDisk.length}`);
|
|
54
|
+
for (const { name } of onDisk) assert.equal(count(html, 'data-skill', name), 1, `/${name} should appear exactly once`);
|
|
55
|
+
assert.equal(count(html, 'data-skill', ''), 0, 'no nameless row');
|
|
56
|
+
assert.equal(html.split('data-skill="').length - 1, new Set(onDisk.map((s) => s.name)).size, 'no row that is not a SKILL.md');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('a default instance shows its enabled modules\' skills and NOT a disabled module\'s', () => {
|
|
60
|
+
const defaults = (key) => {
|
|
61
|
+
try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', key, 'module.json'), 'utf8')).default === true; } catch { return false; }
|
|
62
|
+
};
|
|
63
|
+
const html = lib.skillsTableHtml(lib.groupSkills(servedSkills(defaults)), { esc });
|
|
64
|
+
for (const { name } of skillDirsOnDisk({ modules: defaults })) assert.equal(count(html, 'data-skill', name), 1, `/${name} should appear exactly once`);
|
|
65
|
+
for (const { name } of skillDirsOnDisk({ modules: (k) => k === 'pixel-art' || k === 'design-styles' })) {
|
|
66
|
+
if (fs.existsSync(path.join(ROOT, '.claude', 'skills', name, 'SKILL.md'))) continue;
|
|
67
|
+
assert.equal(count(html, 'data-skill', name), 0, `/${name} ships in a default-off module, so it must not be listed`);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('each row carries the SKILL.md plain / reach-for / cost words', () => {
|
|
72
|
+
const served = servedSkills(() => true);
|
|
73
|
+
const html = lib.skillsTableHtml(lib.groupSkills(served), { esc });
|
|
74
|
+
const ship = served.find((s) => s.name === 'builder-ship');
|
|
75
|
+
const fm = skills.parseFrontmatter(fs.readFileSync(path.join(ROOT, '.claude/skills/builder-ship/SKILL.md'), 'utf8'));
|
|
76
|
+
assert.equal(ship.does, fm.plain);
|
|
77
|
+
assert.equal(ship.reachFor, fm['reach-for']);
|
|
78
|
+
assert.equal(ship.cost, fm.cost);
|
|
79
|
+
for (const v of [fm.plain, fm['reach-for'], fm.cost]) assert.ok(html.includes(esc(v)), `page shows: ${v}`);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('the "type it yourself" marker follows disable-model-invocation, skill by skill', () => {
|
|
83
|
+
const served = servedSkills(() => true);
|
|
84
|
+
const html = lib.skillsTableHtml(lib.groupSkills(served), { esc });
|
|
85
|
+
let typed = 0;
|
|
86
|
+
for (const { name, md } of skillDirsOnDisk()) {
|
|
87
|
+
const hidden = skills.parseFrontmatter(fs.readFileSync(md, 'utf8'))['disable-model-invocation'] === 'true';
|
|
88
|
+
if (hidden) typed++;
|
|
89
|
+
const row = html.slice(html.indexOf(`data-skill="${name}"`)).split('</tr>')[0];
|
|
90
|
+
assert.match(row, hidden ? /cmd-how--typed/ : /cmd-how--listed/, `/${name}: ${hidden ? 'typed-only' : 'shown to Claude'}`);
|
|
91
|
+
}
|
|
92
|
+
assert.ok(typed > 0, 'the tree has typed-only skills (task 1004471), so the marker is exercised');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('a skill no group names still gets a row, under Everything else', () => {
|
|
96
|
+
const groups = lib.groupSkills([{ name: 'brand-new-skill', source: 'core', does: 'x' }, { name: 'builder-ship', source: 'core' }]);
|
|
97
|
+
assert.deepEqual(groups.map((g) => g.key), ['loop', 'other']);
|
|
98
|
+
assert.equal(lib.groupKeyOf({ name: 'otb-tile-generate', source: 'module:pixel-art' }), 'design', 'a module\'s skills land in its group');
|
|
99
|
+
const loop = lib.groupSkills([{ name: 'builder-ship' }, { name: 'builder-claim' }, { name: 'builder-start' }].map((x) => ({ ...x, source: 'core' })));
|
|
100
|
+
assert.deepEqual(loop[0].skills.map((x) => x.name), ['builder-start', 'builder-claim', 'builder-ship'], 'the loop reads in the order you work it');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('row text is escaped — a skill cannot inject markup into the hall', () => {
|
|
104
|
+
const html = lib.skillsTableHtml(lib.groupSkills([{ name: 'x', source: 'core', does: '<img src=x onerror=alert(1)>' }]), { esc });
|
|
105
|
+
assert.ok(!html.includes('<img'), html);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const { cliCommands } = require('../src/bongos/routes/cli-commands.js');
|
|
109
|
+
|
|
110
|
+
test('every bin/bongos.js VERBS entry appears exactly once, grouped like `bongos --help`', () => {
|
|
111
|
+
const groups = cliCommands();
|
|
112
|
+
const html = lib.cliTableHtml(groups, { esc });
|
|
113
|
+
for (const verb of Object.keys(VERBS)) assert.equal(count(html, 'data-verb', verb), 1, `bongos ${verb} should appear exactly once`);
|
|
114
|
+
assert.equal(html.split('data-verb="').length - 1, Object.keys(VERBS).length, 'no row that is not a verb');
|
|
115
|
+
assert.deepEqual(groups.map((g) => g.label), GROUPS.map(([, label]) => label), 'the help\'s group order');
|
|
116
|
+
for (const g of groups) for (const c of g.commands) assert.equal(c.summary, VERBS[c.verb].summary, `${c.verb}: the help line, verbatim`);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('a verb in a group GROUPS does not name is still listed', () => {
|
|
120
|
+
const groups = cliCommands({ GROUPS: [['work', 'Work']], VERBS: { a: { group: 'work', summary: 'A' }, b: { group: 'new', summary: 'B' } } });
|
|
121
|
+
assert.deepEqual(groups.map((g) => [g.key, g.commands.map((c) => c.verb)]), [['work', ['a']], ['other', ['b']]]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('GET /cli/commands serves the verbs to a signed-in builder and refuses a stranger', async () => {
|
|
125
|
+
const auth = require('../src/bongos/auth.js');
|
|
126
|
+
const express = require('express');
|
|
127
|
+
const build = require('../src/bongos/routes/cli-commands.js');
|
|
128
|
+
const serve = async (app, fn) => {
|
|
129
|
+
const server = http.createServer(app);
|
|
130
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
131
|
+
try { return await fn(`http://127.0.0.1:${server.address().port}`); } finally { server.close(); }
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const saved = auth.requireBuilder;
|
|
135
|
+
auth.requireBuilder = (req, _res, next) => { req.builder = { id: 1, rank: 'xenos' }; next(); };
|
|
136
|
+
let signedIn;
|
|
137
|
+
try {
|
|
138
|
+
const app = express();
|
|
139
|
+
app.use(build());
|
|
140
|
+
signedIn = await serve(app, async (base) => { const r = await fetch(`${base}/cli/commands`); return { status: r.status, body: await r.json() }; });
|
|
141
|
+
} finally { auth.requireBuilder = saved; }
|
|
142
|
+
assert.equal(signedIn.status, 200);
|
|
143
|
+
assert.deepEqual(signedIn.body.groups.flatMap((g) => g.commands.map((c) => c.verb)).sort(), Object.keys(VERBS).sort());
|
|
144
|
+
|
|
145
|
+
const gated = express();
|
|
146
|
+
gated.use((req, res, next) => { res.fail = (code, st) => res.status(typeof st === 'number' ? st : 401).json({ error: code }); next(); });
|
|
147
|
+
gated.use(build());
|
|
148
|
+
const stranger = await serve(gated, async (base) => (await fetch(`${base}/cli/commands`)).status);
|
|
149
|
+
assert.ok(stranger === 401 || stranger === 403, `no session → refused (got ${stranger})`);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('the page is wired: nav entry in Docs, sign-in gated, both reads, no hand-typed command', () => {
|
|
153
|
+
const page = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/commands.html'), 'utf8');
|
|
154
|
+
const js = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/commands.js'), 'utf8');
|
|
155
|
+
assert.match(js, /\/api\/bongos\/specialities\/installed-skills/);
|
|
156
|
+
assert.match(js, /\/api\/bongos\/cli\/commands/);
|
|
157
|
+
assert.ok(!/data-skill=|data-verb=|\/builder-ship|bongos claim/.test(page), 'the HTML names no command — the rows are generated');
|
|
158
|
+
const shell = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/shell.js'), 'utf8');
|
|
159
|
+
assert.match(shell, /id: 'commands', label: 'Commands', icon: 'docs', href: '\/commands', gate: 'signed'/);
|
|
160
|
+
const serveInternal = fs.readFileSync(path.join(ROOT, 'src/bongos/serve-internal.js'), 'utf8');
|
|
161
|
+
assert.match(serveInternal.match(/const SIGNEDIN_BUILDERS_PAGE_RE =\s*(\/.*\/);/)[1], /\|commands\)/);
|
|
162
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// .ov-row--lead is rendered only by the Collab page, so its grid rule lives in
|
|
2
|
+
// collab.css, not oversight.css (task 1003637).
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
|
|
7
|
+
const read = (f) => readFileSync(new URL(`../modules/hall-ui/public/${f}`, import.meta.url), 'utf8');
|
|
8
|
+
|
|
9
|
+
test('oversight.css no longer defines .ov-row--lead', () => {
|
|
10
|
+
assert.doesNotMatch(read('oversight.css'), /\.ov-row--lead/);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('collab.css owns the base rule and its 640px companion', () => {
|
|
14
|
+
const css = read('collab.css');
|
|
15
|
+
assert.match(css, /^\.ov-row--lead \{ grid-template-columns: auto minmax\(0, 1fr\) auto; \}/m);
|
|
16
|
+
assert.match(css, /@media \(max-width: 640px\) \{\s*\.ov-row--lead \{[^}]*\}\s*\.ov-row--lead > \.ov-row__right \{ grid-column: 2; \}/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('collab.js still renders the class, so the rule is not dead', () => {
|
|
20
|
+
assert.match(read('collab.js'), /ov-row--lead/);
|
|
21
|
+
});
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -285,8 +285,10 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
285
285
|
// what made this group read as one real page plus a link into the home page.
|
|
286
286
|
assert.deepEqual(nav[2].items.map((i) => i.id), ['collab', 'roster'], 'Community = everyone else, in this project');
|
|
287
287
|
assert.ok(!nav.flatMap((g) => g.items).some((i) => /^\/people\b/.test(i.href)), 'no nav item points at the deleted /people');
|
|
288
|
+
// task 1004473: 'commands' — every skill and CLI verb, read live — is reference
|
|
289
|
+
// material that reads the same for every builder, so it joins Docs.
|
|
288
290
|
assert.deepEqual(nav[3].items.map((i) => i.id),
|
|
289
|
-
['primer', 'ranks', 'economy', 'diagrams', 'atlas', 'modules', 'apidocs'], 'Docs = reference material');
|
|
291
|
+
['primer', 'ranks', 'economy', 'commands', 'diagrams', 'atlas', 'modules', 'apidocs'], 'Docs = reference material');
|
|
290
292
|
// 'government' (the Permissions tab, BV1.R113) is the group's one archon-gated item.
|
|
291
293
|
// 'project-settings' joins the Government group at the metic floor (task 1003280,
|
|
292
294
|
// goal 1000072) — it sits before 'government', which is the one archon-only item.
|
|
@@ -46,6 +46,9 @@ const ORACLE = {
|
|
|
46
46
|
'/builders/settings': 'requireBuilderPage',
|
|
47
47
|
'/builders/atlas': 'requireBuilderPage',
|
|
48
48
|
'/builders/modules': 'requireBuilderPage',
|
|
49
|
+
// task 1004473: the Commands page — its two reads (installed skills, CLI verbs)
|
|
50
|
+
// are requireBuilder, so sign-in is the whole gate.
|
|
51
|
+
'/builders/commands': 'requireBuilderPage',
|
|
49
52
|
'/builders/goals': 'requireBuilderPage',
|
|
50
53
|
// task 1003479: the goal map, on the goal board's tier. task 1004232 (sky part 4):
|
|
51
54
|
// the URL now serves THE PROJECT SKY (the same page as /thinking, in its whole-project
|
|
@@ -635,7 +635,7 @@ try {
|
|
|
635
635
|
assert.match(HTML, /<body data-page="tweak-editor"[^>]*data-fullscreen-switch/);
|
|
636
636
|
assert.equal((HTML.match(/<h1\b/g) || []).length, 1);
|
|
637
637
|
assert.ok(HTML.indexOf('tweak-editor-lib.js') < HTML.indexOf('tweak-editor.js?'), 'the lib loads first');
|
|
638
|
-
assert.match(HTML, /shell\.js\?v=2026-09-
|
|
638
|
+
assert.match(HTML, /shell\.js\?v=2026-09-30-commands/);
|
|
639
639
|
assert.equal(/fonts\.googleapis\.com\/css/.test(HTML), false);
|
|
640
640
|
for (const id of ['tw-bar', 'tw-counter', 'tw-open', 'tw-open-more', 'tw-divider', 'tw-openwrap', 'tw-open-menu', 'tw-open-tab', 'tw-open-split', 'tw-frame', 'tw-frame-view', 'tw-split-close', 'tw-download', 'tw-upload', 'tw-file', 'tw-submit', 'tw-paper', 'tw-doc', 'tw-notice', 'tw-done', 'tw-take', 'tw-pick', 'tw-picks', 'tw-refused', 'tw-refused-title', 'tw-refused-list', 'tw-refused-next', 'tw-refused-download', 'tw-refused-close']) {
|
|
641
641
|
assert.ok(new RegExp(`id="${id}"`).test(HTML), `#${id} is in the markup`);
|
|
@@ -370,3 +370,25 @@ test('every bundled module with a declarativeSeams block validates against its o
|
|
|
370
370
|
}
|
|
371
371
|
assert.deepEqual(bad, [], bad.join('\n'));
|
|
372
372
|
});
|
|
373
|
+
|
|
374
|
+
// --- howto.artifactUrl (ADR 0347 D3, task 1004365) ---
|
|
375
|
+
|
|
376
|
+
test('howto.artifactUrl: optional, and an https claude.ai link is accepted', () => {
|
|
377
|
+
assert.equal(validateManifest(baseManifest()).valid, true);
|
|
378
|
+
for (const url of ['https://claude.ai/artifact/abc123', 'https://claude.ai/code/artifact/0b1c']) {
|
|
379
|
+
const r = validateManifest({ ...baseManifest(), howto: { artifactUrl: url } });
|
|
380
|
+
assert.equal(r.valid, true, `${url}: ${r.errors.join('; ')}`);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('howto.artifactUrl: anything but an https claude.ai link is refused, as is an unknown key', () => {
|
|
385
|
+
for (const url of ['http://claude.ai/artifact/x', 'https://evil.example/claude.ai', 'https://claude.ai.evil.example/x',
|
|
386
|
+
'https://user:pw@claude.ai/x', 'javascript:alert(1)', '', 42]) {
|
|
387
|
+
const r = validateManifest({ ...baseManifest(), howto: { artifactUrl: url } });
|
|
388
|
+
assert.equal(r.valid, false, String(url));
|
|
389
|
+
assert.ok(r.errors.some((e) => e.startsWith('howto.artifactUrl')), r.errors.join('; '));
|
|
390
|
+
}
|
|
391
|
+
assert.ok(validateManifest({ ...baseManifest(), howto: { artifactURL: 'https://claude.ai/x' } }).errors
|
|
392
|
+
.some((e) => e.startsWith('howto.artifactURL: unknown key')));
|
|
393
|
+
assert.equal(validateManifest({ ...baseManifest(), howto: 'https://claude.ai/x' }).valid, false);
|
|
394
|
+
});
|
|
@@ -27,12 +27,21 @@ function moduleJson(over = {}) {
|
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
// A
|
|
30
|
+
// A how-to with all five required sections (ADR 0347 D2).
|
|
31
|
+
const HOWTO = [
|
|
32
|
+
'# Weather', '', '## What it does', 'Shows the weather.', '', '## Install and enable',
|
|
33
|
+
'`bongos module install weather`, then switch it on.', '', '## How to use it', 'Open /weather.', '',
|
|
34
|
+
'## Configuration', 'None.', '', '## Limits and known issues', 'None known.', '',
|
|
35
|
+
].join('\n');
|
|
36
|
+
|
|
37
|
+
// A temp modules/ root holding one module. files: { relPath: content }; a null
|
|
38
|
+
// content leaves that file out.
|
|
31
39
|
function fixture(files = {}, mj = moduleJson()) {
|
|
32
40
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-publish-'));
|
|
33
41
|
const dir = path.join(root, mj.key);
|
|
34
|
-
const all = { 'module.json': JSON.stringify(mj, null, 2), 'routes/weather.js': 'module.exports = () => null;\n', ...files };
|
|
42
|
+
const all = { 'module.json': JSON.stringify(mj, null, 2), 'routes/weather.js': 'module.exports = () => null;\n', 'HOWTO.md': HOWTO, ...files };
|
|
35
43
|
for (const [rel, body] of Object.entries(all)) {
|
|
44
|
+
if (body === null) continue;
|
|
36
45
|
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
|
|
37
46
|
fs.writeFileSync(path.join(dir, rel), body);
|
|
38
47
|
}
|
|
@@ -54,12 +63,12 @@ function retar(tgz, edit) {
|
|
|
54
63
|
test('pack: the tarball is the core release format — package/ root, inner manifest, a tree pin', async () => {
|
|
55
64
|
const { tgz, manifest, sidecar } = pack(fixture());
|
|
56
65
|
const names = format.readTar(tgz).map((e) => e.name).sort();
|
|
57
|
-
assert.deepEqual(names, ['package/.bongos-module.json', 'package/module.json', 'package/routes/weather.js']);
|
|
66
|
+
assert.deepEqual(names, ['package/.bongos-module.json', 'package/HOWTO.md', 'package/module.json', 'package/routes/weather.js']);
|
|
58
67
|
assert.equal(manifest.artifact, 'bongos-module');
|
|
59
68
|
assert.equal(manifest.module_key, 'weather');
|
|
60
69
|
assert.equal(manifest.module_version, '1.2.0');
|
|
61
70
|
assert.equal(manifest.core_version, '^1.0.0');
|
|
62
|
-
assert.equal(manifest.file_count,
|
|
71
|
+
assert.equal(manifest.file_count, 3);
|
|
63
72
|
assert.equal(manifest.tree_sha256, format.canonicalTreeHash(manifest.files));
|
|
64
73
|
assert.equal(sidecar.tarball.sha256, format.sha256hex(tgz));
|
|
65
74
|
assert.equal(sidecar.tarball.name, 'bongos-module-weather-1.2.0.tgz');
|
|
@@ -302,6 +311,81 @@ test('cli: uploads the packed bytes, and a store refusal is a failure exit with
|
|
|
302
311
|
assert.ok(out.lines.some((l) => /HTTP 409/.test(l) && /already published/.test(l)));
|
|
303
312
|
});
|
|
304
313
|
|
|
314
|
+
// ---- the how-to gate (ADR 0347, task 1004365) -----------------------------------
|
|
315
|
+
|
|
316
|
+
test('howto: a complete how-to passes, headings in any order and any case', () => {
|
|
317
|
+
assert.deepEqual(artifact.checkHowto(HOWTO), { ok: true, problems: [] });
|
|
318
|
+
const reordered = HOWTO.replace('## Configuration\nNone.\n', '').replace('# Weather', '# Weather\n## CONFIGURATION\nNone.');
|
|
319
|
+
assert.equal(artifact.checkHowto(reordered).ok, true);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('howto: a missing file, a missing section and an empty section are each refused by name', () => {
|
|
323
|
+
assert.deepEqual(artifact.checkHowto(null).problems, ['HOWTO.md is missing — every store module ships one (ADR 0347)']);
|
|
324
|
+
assert.deepEqual(artifact.checkHowto(HOWTO.replace('## Configuration\nNone.\n', '')).problems,
|
|
325
|
+
['HOWTO.md: section "Configuration" is missing']);
|
|
326
|
+
assert.deepEqual(artifact.checkHowto(HOWTO.replace('Open /weather.', '')).problems,
|
|
327
|
+
['HOWTO.md: section "How to use it" is empty']);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('howto: a prompt in a comment, a sub-heading alone, or a heading inside a code fence does not count', () => {
|
|
331
|
+
assert.deepEqual(artifact.checkHowto(HOWTO.replace('None known.', '<!-- say what goes wrong -->')).problems,
|
|
332
|
+
['HOWTO.md: section "Limits and known issues" is empty']);
|
|
333
|
+
assert.deepEqual(artifact.checkHowto(HOWTO.replace('None known.', '### Later')).problems,
|
|
334
|
+
['HOWTO.md: section "Limits and known issues" is empty']);
|
|
335
|
+
const fenced = HOWTO.replace('## Configuration\nNone.\n', '').replace('Open /weather.', 'Open /weather.\n```\n## Configuration\n```');
|
|
336
|
+
assert.deepEqual(artifact.checkHowto(fenced).problems, ['HOWTO.md: section "Configuration" is missing']);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('howto: the scaffold\'s template has every section but fails the gate until it is written', () => {
|
|
340
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-howto-'));
|
|
341
|
+
moduleCli.scaffoldModule({ key: 'weather', modulesDir: root, core: '1.0.0' });
|
|
342
|
+
const text = fs.readFileSync(path.join(root, 'weather', 'HOWTO.md'), 'utf8');
|
|
343
|
+
const res = artifact.checkHowto(text);
|
|
344
|
+
assert.deepEqual(res.problems, artifact.HOWTO_SECTIONS.map((s) => `HOWTO.md: section "${s}" is empty`));
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test('howto: the file is packed into the tarball, so its hash pins it to the version', async () => {
|
|
348
|
+
const v = await artifact.verifyModuleArtifact(pack(fixture()).tgz, { key: 'weather', requireHowto: true });
|
|
349
|
+
assert.equal(v.ok, true, v.message);
|
|
350
|
+
assert.ok(v.manifest.files.some((f) => f.path === 'HOWTO.md'));
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('howto: verify refuses an incomplete how-to only when asked, so older versions still install', async () => {
|
|
354
|
+
const tgz = pack(fixture({ 'HOWTO.md': null })).tgz;
|
|
355
|
+
assert.equal((await artifact.verifyModuleArtifact(tgz, { key: 'weather' })).ok, true, 'install/update verify is unchanged');
|
|
356
|
+
const v = await artifact.verifyModuleArtifact(tgz, { key: 'weather', requireHowto: true });
|
|
357
|
+
assert.equal(v.code, 'howto_incomplete');
|
|
358
|
+
assert.match(v.message, /HOWTO\.md is missing/);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('cli: publish refuses a missing or incomplete how-to by name, before anything is uploaded', async () => {
|
|
362
|
+
for (const [files, re] of [
|
|
363
|
+
[{ 'HOWTO.md': null }, /HOWTO\.md is missing/],
|
|
364
|
+
[{ 'HOWTO.md': HOWTO.replace('## Configuration\nNone.\n', '') }, /section "Configuration" is missing/],
|
|
365
|
+
[{ 'HOWTO.md': HOWTO.replace('Shows the weather.', '') }, /section "What it does" is empty/],
|
|
366
|
+
]) {
|
|
367
|
+
const out = sink();
|
|
368
|
+
let uploaded = false;
|
|
369
|
+
const code = await moduleCli.cmdPublish(['weather'], {
|
|
370
|
+
log: out.log, errlog: out.log, modulesDir: fixture(files), upload: async () => { uploaded = true; },
|
|
371
|
+
});
|
|
372
|
+
assert.equal(code, 1);
|
|
373
|
+
assert.equal(uploaded, false);
|
|
374
|
+
assert.ok(out.lines.some((l) => re.test(l)), out.lines.join('\n'));
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('cli: module check reports the how-to as advice and never fails an upstream check on it', () => {
|
|
379
|
+
const out = sink();
|
|
380
|
+
const code = moduleCli.cmdCheck(['weather'], {
|
|
381
|
+
log: out.log, errlog: out.log,
|
|
382
|
+
check: () => ({ ok: true, findings: [] }),
|
|
383
|
+
howtoCheck: () => artifact.checkHowto(null),
|
|
384
|
+
});
|
|
385
|
+
assert.equal(code, 0);
|
|
386
|
+
assert.ok(out.lines.some((l) => /HOWTO\.md is missing/.test(l) && /bongos module publish/.test(l)));
|
|
387
|
+
});
|
|
388
|
+
|
|
305
389
|
test('cli: no key is a usage error', async () => {
|
|
306
390
|
assert.equal(await moduleCli.cmdPublish([], { log: () => {}, errlog: () => {} }), 2);
|
|
307
391
|
});
|
|
@@ -66,13 +66,17 @@ async function invoke(handle, req) {
|
|
|
66
66
|
return res;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
// A how-to with all five required sections (ADR 0347 D2).
|
|
70
|
+
const HOWTO = artifact.HOWTO_SECTIONS.map((h) => `## ${h}\nSome text.\n`).join('\n');
|
|
71
|
+
|
|
72
|
+
function packed(version = '1.0.0', { howto = HOWTO } = {}) {
|
|
70
73
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-src-'));
|
|
71
74
|
const dir = path.join(root, 'weather');
|
|
72
75
|
fs.mkdirSync(dir);
|
|
73
76
|
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({
|
|
74
77
|
key: 'weather', title: 'Weather', description: 'd', version, coreVersion: '^1.0.0', contributes: {},
|
|
75
78
|
}));
|
|
79
|
+
if (howto !== null) fs.writeFileSync(path.join(dir, 'HOWTO.md'), howto);
|
|
76
80
|
return artifact.packModule('weather', { modulesDir: root, modeOf: () => '644' }).tgz;
|
|
77
81
|
}
|
|
78
82
|
|
|
@@ -119,3 +123,18 @@ test('a tarball for another key, garbage, or no body is refused before the regis
|
|
|
119
123
|
assert.equal((await invoke(route.handle, { params: { key: 'Bad_Key' }, body: packed(), builder: { id: 42 } })).body.error, 'bad_module_key');
|
|
120
124
|
assert.equal(state.versions.length, before);
|
|
121
125
|
});
|
|
126
|
+
|
|
127
|
+
test('an upload that skipped the CLI\'s how-to check is refused by the store, and nothing is kept', async () => {
|
|
128
|
+
const before = state.versions.length;
|
|
129
|
+
const missing = await invoke(route.handle, { params: { key: 'weather' }, body: packed('3.0.0', { howto: null }), builder: { id: 42 } });
|
|
130
|
+
assert.equal(missing.statusCode, 422);
|
|
131
|
+
assert.equal(missing.body.error, 'howto_incomplete');
|
|
132
|
+
assert.match(missing.body.message, /HOWTO\.md is missing/);
|
|
133
|
+
const empty = await invoke(route.handle, {
|
|
134
|
+
params: { key: 'weather' }, body: packed('3.0.0', { howto: HOWTO.replace('## Configuration\nSome text.', '## Configuration\n') }), builder: { id: 42 },
|
|
135
|
+
});
|
|
136
|
+
assert.equal(empty.body.error, 'howto_incomplete');
|
|
137
|
+
assert.match(empty.body.message, /section "Configuration" is empty/);
|
|
138
|
+
assert.equal(state.versions.length, before);
|
|
139
|
+
assert.ok(!fs.existsSync(path.join(STORE, 'weather', 'weather-3.0.0.tgz')));
|
|
140
|
+
});
|
|
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
|
|
|
87
87
|
// the file and left the query alone, so a returning browser kept the cached copy —
|
|
88
88
|
// a fix that reaches nobody looks identical to a fix that does not work.
|
|
89
89
|
|
|
90
|
-
const SHELL_SHA = '
|
|
90
|
+
const SHELL_SHA = '4e14115a3737db9a';
|
|
91
91
|
|
|
92
92
|
test('shell.js has not changed without its cache-bust being bumped', () => {
|
|
93
93
|
const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
|