create-pmos 0.521.0 → 0.523.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-pmos.js +106 -42
- package/package.json +39 -47
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/bin/pmos.js +2 -1
- package/workspace/board-publish-ui-test.js +1 -1
- package/workspace/changelog.json +38 -0
- package/workspace/guide/home.md +2 -2
- package/workspace/guide/published-board.md +15 -3
- package/workspace/lib/board-pack.js +44 -1
- package/workspace/lib/board-publish-choices.js +22 -6
- package/workspace/lib/board-publish-parts.js +95 -0
- package/workspace/lib/boards-host.js +3 -3
- package/workspace/lib/boards-received.js +105 -0
- package/workspace/lib/connector-read.js +19 -0
- package/workspace/lib/headless.js +2 -2
- package/workspace/lib/kit-update.js +51 -21
- package/workspace/lib/project-onboard.js +7 -16
- package/workspace/lib/refresh-run.js +2 -1
- package/workspace/lib/routes-board-publish.js +42 -12
- package/workspace/lib/routes-onboard.js +17 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/bd-publish.css +3 -0
- package/workspace/public/pm/bd-publish.js +39 -9
- package/workspace/public/pm/bd-shared.css +5 -0
- package/workspace/public/pm/bd-shared.js +72 -10
- package/workspace/public/pm/chat-term.js +22 -0
- package/workspace/public/pm/cozy-board.css +10 -0
- package/workspace/public/pm/cozy-home.css +3 -0
- package/workspace/public/pm/home-chat.js +3 -0
- package/workspace/public/pm/premium-board.js +8 -2
- package/workspace/public/pm/premium-shell.js +2 -1
- package/workspace/public/pm/readonly-boot.js +4 -1
- package/workspace/public/pm/readonly.js +40 -5
- package/workspace/public/pm/term-view.js +39 -8
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* board-publish-parts.js — a publish of several products and programs in one link (batch 27 item 1, ShA; initiative board-share-several).
|
|
3
|
+
*
|
|
4
|
+
* A PART is one pick of the Share sheet, in pick order: { kind: 'product', ws, board } · { kind: 'program', id } · { kind: 'initiative', id, ws, board }.
|
|
5
|
+
* A program can reach several products: it is picked by id, and its products come from its members (GET /api/programs).
|
|
6
|
+
*
|
|
7
|
+
* groupsOf(parts, programs) -> { groups: [{ ws, board, select: 'kind:id,…' }], tabs: [{ label?, ws, board, program?, kind, id }] } | { error }
|
|
8
|
+
* one group per product the picks touch (its selection is the union of the picks there), one tab per pick. Pure.
|
|
9
|
+
* packOf(parts, { programs, boards, name, make }) -> { ok, pack, kind, label } | { ok: false, status, error }
|
|
10
|
+
* make(group) -> { ok, pack } is lib/routes-board-publish.js makePack: each product's pack is made and swept as today,
|
|
11
|
+
* then lib/board-pack.js combinePacks joins them. A product that cannot be read stops the publish with its sentence.
|
|
12
|
+
* parseParts(raw) -> [part] | null the parts of a request, checked; null when the request is not one of parts */
|
|
13
|
+
const Pack = require('./board-pack.js');
|
|
14
|
+
|
|
15
|
+
const MAX_PARTS = 12;
|
|
16
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/;
|
|
17
|
+
|
|
18
|
+
function parseParts(raw) {
|
|
19
|
+
if (!Array.isArray(raw) || !raw.length) return null;
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const p of raw.slice(0, MAX_PARTS + 1)) {
|
|
22
|
+
if (!p || typeof p !== 'object') return null;
|
|
23
|
+
const kind = String(p.kind || '');
|
|
24
|
+
if (kind === 'program') { if (!ID.test(String(p.id || ''))) return null; out.push({ kind, id: String(p.id) }); continue; }
|
|
25
|
+
if (kind !== 'product' && kind !== 'initiative') return null;
|
|
26
|
+
const ws = String(p.ws || ''); const board = String(p.board || ws);
|
|
27
|
+
if (!ID.test(ws) || !ID.test(board)) return null;
|
|
28
|
+
if (kind === 'initiative' && !ID.test(String(p.id || ''))) return null;
|
|
29
|
+
out.push(kind === 'product' ? { kind, ws, board } : { kind, id: String(p.id), ws, board });
|
|
30
|
+
}
|
|
31
|
+
return out.length > MAX_PARTS ? null : out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function groupsOf(parts, programs, boards) {
|
|
35
|
+
const G = new Map(); const tabs = [];
|
|
36
|
+
const boardOf = (ws) => ((boards || []).find((b) => b && b.ws === ws) || {}).id || ws;
|
|
37
|
+
const add = (ws, board, sel) => { const g = G.get(ws) || { ws, board, select: [] }; if (!g.select.includes(sel)) g.select.push(sel); G.set(ws, g); return g; };
|
|
38
|
+
for (const p of parts) {
|
|
39
|
+
if (p.kind === 'product') { add(p.ws, p.board, 'product:' + p.board); tabs.push({ kind: 'product', ws: p.ws, board: p.board }); continue; }
|
|
40
|
+
if (p.kind === 'initiative') { add(p.ws, p.board, 'initiative:' + p.id); tabs.push({ kind: 'initiative', id: p.id, ws: p.ws, board: p.board }); continue; }
|
|
41
|
+
const prog = (programs || []).find((x) => x && x.id === p.id);
|
|
42
|
+
if (!prog) return { error: 'The program "' + p.id + '" is not in this workspace.' };
|
|
43
|
+
const wss = [...new Set((prog.members || []).map((m) => m && m.ws).filter(Boolean))];
|
|
44
|
+
if (!wss.length) return { error: prog.title ? prog.title + ' has no initiatives to share yet.' : 'This program has no initiatives to share yet.' };
|
|
45
|
+
for (const ws of wss) add(ws, boardOf(ws), 'program:' + p.id);
|
|
46
|
+
const home = prog.home && wss.includes(prog.home) ? prog.home : wss[0];
|
|
47
|
+
tabs.push({ kind: 'program', id: p.id, ws: home, board: boardOf(home), program: p.id, label: prog.title || p.id });
|
|
48
|
+
}
|
|
49
|
+
return { groups: [...G.values()].map((g) => Object.assign({}, g, { select: g.select.join(',') })), tabs };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The label of a product or initiative tab, read from that product's own pack: the board's name, or the initiative's title. */
|
|
53
|
+
function labelFrom(pack, tab) {
|
|
54
|
+
const key = Object.keys((pack && pack.responses) || {}).find((k) => k.startsWith('/api/board-premium?') && new URLSearchParams(k.split('?')[1]).get('ws') === tab.ws);
|
|
55
|
+
const model = key && pack.responses[key] && pack.responses[key].model;
|
|
56
|
+
if (tab.kind === 'initiative') { const r = ((model && model.rows) || []).find((x) => x && x.id === tab.id); return (r && r.title) || tab.id; }
|
|
57
|
+
return (model && model.board && (model.board.product || model.board.name)) || tab.ws;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function packOf(parts, o) {
|
|
61
|
+
const g = groupsOf(parts, o.programs, o.boards);
|
|
62
|
+
if (g.error) return { ok: false, status: 400, error: g.error };
|
|
63
|
+
const packs = new Map();
|
|
64
|
+
for (const grp of g.groups) {
|
|
65
|
+
const r = await o.make(grp);
|
|
66
|
+
if (!r.ok) {
|
|
67
|
+
// a product a program only reaches, with nothing of the program on it now: left out, the program's other products still go
|
|
68
|
+
if (r.status === 400 && /not on this board/.test(r.error) && !parts.some((p) => p.ws === grp.ws)) continue;
|
|
69
|
+
return r;
|
|
70
|
+
}
|
|
71
|
+
packs.set(grp.ws, r.pack);
|
|
72
|
+
}
|
|
73
|
+
if (!packs.size) return { ok: false, status: 400, error: 'Nothing in these picks can be shared yet.' };
|
|
74
|
+
// one tab per pick; an initiative joins its product's tab when that product is picked too, and initiatives of one product share a tab
|
|
75
|
+
const tabs = []; const seen = new Map();
|
|
76
|
+
for (const t of g.tabs) {
|
|
77
|
+
if (!packs.has(t.ws)) continue;
|
|
78
|
+
const key = t.ws + '|' + (t.program || '');
|
|
79
|
+
const label = t.label || labelFrom(packs.get(t.ws), t);
|
|
80
|
+
if (seen.has(key)) { const was = seen.get(key); if (was.kind === 'initiative' && t.kind === 'product') { was.kind = 'product'; was.label = label; } continue; }
|
|
81
|
+
const tab = { kind: t.kind, label, ws: t.ws, board: t.board }; if (t.program) tab.program = t.program;
|
|
82
|
+
seen.set(key, tab); tabs.push(tab);
|
|
83
|
+
}
|
|
84
|
+
if (packs.size === 1 && tabs.length === 1 && !tabs[0].program) { // one pick on one product: the pack as it is today, renamed when the PM did
|
|
85
|
+
const pack = [...packs.values()][0]; if (String(o.name || '').trim()) pack.scope = Object.assign({}, pack.scope, { label: String(o.name).trim().slice(0, 120) });
|
|
86
|
+
return { ok: true, pack, kind: (pack.scope && pack.scope.kind) || tabs[0].kind, label: pack.scope.label };
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const pack = Pack.combinePacks([...packs.values()], tabs, o.name);
|
|
90
|
+
const kind = tabs.length > 1 ? 'multi' : (tabs[0] && tabs[0].kind) || 'multi';
|
|
91
|
+
return { ok: true, pack, kind, label: pack.scope.label };
|
|
92
|
+
} catch (e) { return { ok: false, status: 400, error: String((e && e.message) || e) }; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { MAX_PARTS, parseParts, groupsOf, packOf };
|
|
@@ -51,8 +51,8 @@ function hostOrigin(env = process.env) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
function readState(dir) {
|
|
54
|
-
try { const s = JSON.parse(fs.readFileSync(fileOf(dir), 'utf8')); return { key: typeof s.key === 'string' ? s.key : null, host: typeof s.host === 'string' ? s.host : '', teams: Array.isArray(s.teams) ? s.teams : [], links: Array.isArray(s.links) ? s.links : [] }; }
|
|
55
|
-
catch { return { key: null, host: '', teams: [], links: [] }; }
|
|
54
|
+
try { const s = JSON.parse(fs.readFileSync(fileOf(dir), 'utf8')); return { key: typeof s.key === 'string' ? s.key : null, host: typeof s.host === 'string' ? s.host : '', teams: Array.isArray(s.teams) ? s.teams : [], links: Array.isArray(s.links) ? s.links : [], received: Array.isArray(s.received) ? s.received : [] }; }
|
|
55
|
+
catch { return { key: null, host: '', teams: [], links: [], received: [] }; }
|
|
56
56
|
}
|
|
57
57
|
/** The address the PM saved on the Publish popover ('' when none). The environment variable PMOS_BOARDS_HOST wins over it (see addressOf). */
|
|
58
58
|
function savedHost(dir) {
|
|
@@ -124,7 +124,7 @@ async function publish(dir, o, opts = {}) {
|
|
|
124
124
|
const now = new Date(opts.now || Date.now()).toISOString();
|
|
125
125
|
const cur = readState(dir);
|
|
126
126
|
cur.key = st.key;
|
|
127
|
-
cur.links = cur.links.filter((l) => l.id !== b.id).concat([{ id: b.id, url: b.url, team, name: String(o.name || ''), kind: o.kind, selection: o.selection || [], ws: o.ws || '', board: o.board || '', by: String(o.by || ''), made: now, ends: b.ends || null, views: 0 }]);
|
|
127
|
+
cur.links = cur.links.filter((l) => l.id !== b.id).concat([{ id: b.id, url: b.url, team, name: String(o.name || ''), kind: o.kind, selection: o.selection || [], parts: Array.isArray(o.parts) ? o.parts : undefined, ws: o.ws || '', board: o.board || '', by: String(o.by || ''), made: now, ends: b.ends || null, views: 0 }]);
|
|
128
128
|
writeState(dir, cur);
|
|
129
129
|
return { ok: true, id: b.id, url: b.url, ends: b.ends || null };
|
|
130
130
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* boards-received.js — boards other people shared with you, kept in your PMOS (batch 27 item 3, ShX X2 and X3; initiative board-share-several).
|
|
3
|
+
*
|
|
4
|
+
* A RECEIVED board is { id, host, url, password, name, by, kind, ends, added }, kept in <runtime state>/board-publish/state.json under `received`
|
|
5
|
+
* (lib/boards-host.js writeState: mode 0600). The password is a secret on this computer: never returned to the page, never logged (D08), like a
|
|
6
|
+
* team password. Every function takes the state folder.
|
|
7
|
+
*
|
|
8
|
+
* THE CONTRACT with the host (board-host/src/core.js):
|
|
9
|
+
* GET <host>/api/links/<id>/about X-Link-Password -> { ok, id, name, kind, by, made, ends }
|
|
10
|
+
* POST <host>/api/links/<id>/visit X-Link-Password -> { ok, url } one-time, 5 minutes
|
|
11
|
+
* 403 is a wrong password, 429 a lockout (the sentence names the minutes), 404 a link that ended or was turned off.
|
|
12
|
+
*
|
|
13
|
+
* parseLink(text) -> { host, id, url } | { error } "<host>/b/<id>" (a copy of the address bar works too)
|
|
14
|
+
* add(dir, { link, password }, { fetch }) -> { ok, board } | { ok: false, kind, error } checked with the host before anything is kept
|
|
15
|
+
* forget(dir, id) -> { ok }
|
|
16
|
+
* readAll(dir, { fetch, now }) -> [{ id, host, url, name, by, kind, ends, state }] state: open | ended | off | password | locked | unreachable
|
|
17
|
+
* visit(dir, id, { fetch }) -> { ok, url } | { ok: false, error }
|
|
18
|
+
* Ended and turned-off links stay as one quiet row until the PM forgets them: the host deletes both, so the end date kept here tells them apart. */
|
|
19
|
+
const Host = require('./boards-host.js');
|
|
20
|
+
|
|
21
|
+
const TIMEOUT_MS = 15000;
|
|
22
|
+
const MAX = 50;
|
|
23
|
+
const LINK_ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
24
|
+
|
|
25
|
+
function parseLink(text) {
|
|
26
|
+
const bad = { error: 'This is not a shared board link. It looks like https://boards.pm-os.app/b/…' };
|
|
27
|
+
let u; try { u = new URL(String(text || '').trim()); } catch { return bad; }
|
|
28
|
+
if (u.username || u.password) return bad;
|
|
29
|
+
const host = Host.hostOrigin({ PMOS_BOARDS_HOST: u.origin });
|
|
30
|
+
const m = /^\/b\/([A-Za-z0-9_-]+)\/?$/.exec(u.pathname);
|
|
31
|
+
if (!host || !m || !LINK_ID.test(m[1])) return bad;
|
|
32
|
+
return { host, id: m[1], url: host + '/b/' + m[1] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function call(host, method, p, password, fetchFn) {
|
|
36
|
+
const f = fetchFn || (typeof fetch === 'function' ? fetch : null);
|
|
37
|
+
if (!f) return { ok: false, kind: 'unreachable', error: 'This computer cannot reach the host (no network client).' };
|
|
38
|
+
const ctl = typeof AbortController === 'function' ? new AbortController() : null; const timer = ctl ? setTimeout(() => ctl.abort(), TIMEOUT_MS) : null;
|
|
39
|
+
try {
|
|
40
|
+
const headers = { Accept: 'application/json' }; if (password) headers['X-Link-Password'] = password;
|
|
41
|
+
const res = await f(host + p, { method, redirect: 'error', signal: ctl ? ctl.signal : undefined, headers });
|
|
42
|
+
let j = null; try { j = await res.json(); } catch { /* not JSON */ }
|
|
43
|
+
if (res.ok && j && j.ok !== false) return { ok: true, body: j };
|
|
44
|
+
const say = (j && typeof j.error === 'string' && j.error.slice(0, 240)) || '';
|
|
45
|
+
if (res.status === 403) return { ok: false, kind: 'password', error: 'That password does not work. Ask the person who shared it.' };
|
|
46
|
+
if (res.status === 429) return { ok: false, kind: 'locked', error: say || 'Too many wrong tries. Try again in a few minutes.' };
|
|
47
|
+
if (res.status === 404) return { ok: false, kind: 'gone', error: 'This link has ended or was turned off.' };
|
|
48
|
+
return { ok: false, kind: 'other', error: say || 'The host refused the request (' + res.status + ').' };
|
|
49
|
+
} catch (e) {
|
|
50
|
+
return { ok: false, kind: 'unreachable', error: e && e.name === 'AbortError' ? 'The host did not answer in time. Try again.' : 'The host could not be reached. Check the connection.' };
|
|
51
|
+
} finally { if (timer) clearTimeout(timer); }
|
|
52
|
+
}
|
|
53
|
+
const path = (id, what) => '/api/links/' + encodeURIComponent(id) + '/' + what;
|
|
54
|
+
const listOf = (st) => (Array.isArray(st.received) ? st.received : []);
|
|
55
|
+
const str = (v, n) => String(v == null ? '' : v).slice(0, n);
|
|
56
|
+
const about = (b) => ({ name: str(b.name, 120), by: str(b.by, 60), kind: str(b.kind, 20), ends: str(b.ends, 40) });
|
|
57
|
+
|
|
58
|
+
async function add(dir, o, opts = {}) {
|
|
59
|
+
const L = parseLink(o.link); if (L.error) return { ok: false, kind: 'link', error: L.error };
|
|
60
|
+
const password = o.password == null ? '' : String(o.password);
|
|
61
|
+
if (password.length > 100) return { ok: false, kind: 'password', error: 'A password is at most 100 characters.' };
|
|
62
|
+
const st = Host.readState(dir);
|
|
63
|
+
if (!listOf(st).some((r) => r.id === L.id) && listOf(st).length >= MAX) return { ok: false, kind: 'other', error: 'This computer keeps ' + MAX + ' shared boards. Forget one first.' };
|
|
64
|
+
const kept = listOf(st).find((r) => r.id === L.id && r.host === L.host);
|
|
65
|
+
const r = await call(L.host, 'GET', path(L.id, 'about'), password || (kept && kept.password) || '', opts.fetch);
|
|
66
|
+
if (!r.ok) return { ok: false, kind: r.kind, error: r.kind === 'password' && !password ? 'This board needs its password.' : r.error };
|
|
67
|
+
const cur = Host.readState(dir);
|
|
68
|
+
const row = Object.assign({ id: L.id, host: L.host, url: L.url, password: password || (kept && kept.password) || '', added: new Date(opts.now || Date.now()).toISOString() }, about(r.body));
|
|
69
|
+
cur.received = listOf(cur).filter((x) => x.id !== L.id).concat([row]);
|
|
70
|
+
Host.writeState(dir, cur);
|
|
71
|
+
return { ok: true, board: { id: row.id, name: row.name, by: row.by, ends: row.ends } };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function forget(dir, id) {
|
|
75
|
+
const st = Host.readState(dir); const had = listOf(st).some((r) => r.id === id);
|
|
76
|
+
st.received = listOf(st).filter((r) => r.id !== id); Host.writeState(dir, st); return { ok: had };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function readAll(dir, opts = {}) {
|
|
80
|
+
const now = opts.now || Date.now();
|
|
81
|
+
const list = listOf(Host.readState(dir));
|
|
82
|
+
const rows = await Promise.all(list.map(async (x) => {
|
|
83
|
+
const base = { id: x.id, host: new URL(x.host).host, url: x.url, name: x.name, by: x.by, kind: x.kind, ends: x.ends };
|
|
84
|
+
const r = await call(x.host, 'GET', path(x.id, 'about'), x.password, opts.fetch);
|
|
85
|
+
if (r.ok) return Object.assign(base, about(r.body), { state: 'open' });
|
|
86
|
+
if (r.kind === 'gone') return Object.assign(base, { state: x.ends && Date.parse(x.ends) <= now ? 'ended' : 'off' });
|
|
87
|
+
return Object.assign(base, { state: r.kind === 'password' || r.kind === 'locked' ? r.kind : 'unreachable', error: r.error });
|
|
88
|
+
}));
|
|
89
|
+
// what the host said is kept (the name and end date can change on a republish), so an ended row still says when it ended
|
|
90
|
+
const cur = Host.readState(dir); let changed = false;
|
|
91
|
+
for (const row of rows) { const k = listOf(cur).find((x) => x.id === row.id); if (k && row.state === 'open' && (k.name !== row.name || k.ends !== row.ends || k.by !== row.by)) { Object.assign(k, { name: row.name, ends: row.ends, by: row.by }); changed = true; } }
|
|
92
|
+
if (changed) Host.writeState(dir, cur);
|
|
93
|
+
return rows;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function visit(dir, id, opts = {}) {
|
|
97
|
+
const x = listOf(Host.readState(dir)).find((r) => r.id === id);
|
|
98
|
+
if (!x) return { ok: false, error: 'This board is not kept on this computer.' };
|
|
99
|
+
const r = await call(x.host, 'POST', path(x.id, 'visit'), x.password, opts.fetch);
|
|
100
|
+
if (!r.ok) return { ok: false, kind: r.kind, error: r.error };
|
|
101
|
+
const url = r.body.url; if (typeof url !== 'string' || !url.startsWith(x.host + '/b/' + x.id)) return { ok: false, error: 'The host sent an address this workspace does not accept.' };
|
|
102
|
+
return { ok: true, url };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { MAX, parseLink, add, forget, readAll, visit };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* connector-read.js — which connector tools are read-shaped. Shared by project setup (lib/project-onboard.js) and background runs
|
|
3
|
+
* (lib/headless.js connectorReads). Kept in its own file so a change to project setup does not reach every suite that uses headless runs. */
|
|
4
|
+
const READ_VERBS = new Set(['search', 'get', 'list', 'read', 'fetch', 'find', 'query', 'lookup', 'view', 'describe', 'retrieve', 'browse', 'resolve']);
|
|
5
|
+
const WRITE_VERBS = new Set(['create', 'update', 'delete', 'add', 'post', 'send', 'write', 'edit', 'move', 'transition', 'comment', 'assign', 'upload',
|
|
6
|
+
'remove', 'set', 'merge', 'close', 'archive', 'share', 'copy', 'trash', 'label', 'mark', 'apply', 'schedule', 'reply', 'forward', 'draft', 'respond',
|
|
7
|
+
'invite', 'publish', 'run', 'execute', 'trigger', 'link', 'unlink', 'approve', 'submit', 'save', 'log', 'record', 'register', 'insert', 'append',
|
|
8
|
+
'clear', 'fork', 'enable', 'disable', 'untrash', 'unmark', 'unlabel', 'cancel', 'install', 'import', 'export', 'generate', 'use', 'push', 'request', 'rename']);
|
|
9
|
+
const words = (s) => String(s).replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w.toLowerCase());
|
|
10
|
+
|
|
11
|
+
/** A tool may be granted only when its own name (after the server prefix) leads with a read verb and holds no write verb. */
|
|
12
|
+
function readTool(toolName, prefix) {
|
|
13
|
+
const own = prefix && toolName.startsWith(prefix) ? toolName.slice(prefix.length) : toolName.replace(/^mcp__[^_]+(?:_[^_]+)*?__/, '');
|
|
14
|
+
const w = words(own);
|
|
15
|
+
if (!w.length || w.some((x) => WRITE_VERBS.has(x))) return false;
|
|
16
|
+
return w.slice(0, 3).some((x) => READ_VERBS.has(x));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { readTool, READ_VERBS, WRITE_VERBS };
|
|
@@ -215,7 +215,7 @@ const TOOLS = Object.freeze({
|
|
|
215
215
|
// without a prompt. The kinds below read the PM's sources through connectors (Jira, Confluence, Slack, the calendar, mail, Drive), and before
|
|
216
216
|
// dontAsk they relied on the PM's default mode letting a read-only connector tool run. Now the server names those tools: the read-shaped tools
|
|
217
217
|
// of the PM's connected servers (CONNECTOR_READ_SEED, plus what each run's init line reports, server.js), filtered by the same read-verb rule the
|
|
218
|
-
// onboarding search uses (lib/
|
|
218
|
+
// onboarding search uses (lib/connector-read.js readTool). A connector WRITE is never named, so it is still denied. The other kinds read no
|
|
219
219
|
// connector and get none.
|
|
220
220
|
const CONNECTOR_READ_KINDS = Object.freeze(['skill', 'report-setup', 'report-find', 'ms-review', 'propose', 'metric-draft', 'impact', 'metric-read',
|
|
221
221
|
'report-sample-fetch', 'report-structure', 'report-structure-dry', 'report-structure-pull']);
|
|
@@ -235,7 +235,7 @@ const CONNECTOR_READ_SEED = Object.freeze([
|
|
|
235
235
|
const MCP_TOOL_RE = /^mcp__[\w-]+__[\w.-]+$/; // one exact tool: Claude Code's rule syntax, no server-wide or wildcard rule
|
|
236
236
|
/** The read-shaped connector tools among `names` (a run's init line, or a cached list): exact names only, never a write verb. */
|
|
237
237
|
function connectorReads(names) {
|
|
238
|
-
const { readTool } = require('./
|
|
238
|
+
const { readTool } = require('./connector-read.js');
|
|
239
239
|
const out = [];
|
|
240
240
|
for (const n of Array.isArray(names) ? names : []) {
|
|
241
241
|
const t = String(n || '');
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
*
|
|
16
16
|
* The page does what the terminal does, and nothing more. Each action runs the command the README tells the PM to type:
|
|
17
17
|
* check npm view create-pmos version
|
|
18
|
-
* kit npx -y create-pmos@<version> --update <repo>
|
|
19
|
-
* app npx -y create-pmos@<version> workspace --reinstall <repo> (what `pmos upgrade` runs)
|
|
18
|
+
* kit npx -y --prefer-online create-pmos@<version> --update <repo> (the kernel only; state/ and planning/ untouched)
|
|
19
|
+
* app npx -y --prefer-online create-pmos@<version> workspace --reinstall <repo> (what `pmos upgrade` runs)
|
|
20
20
|
* LOCAL SOURCE. PMOS_UPDATE_SOURCE names a built create-pmos package on this computer: its folder (with package.json, bin/
|
|
21
21
|
* and payload/ or workspace/) or its .tgz from `npm pack`. The check then reads that package's version instead of asking
|
|
22
22
|
* the registry, and the update runs `npx -y --package=<that path> -- create-pmos ...` in place of `npx -y create-pmos@<version> ...`. Nothing
|
|
@@ -116,11 +116,12 @@ function source(env = process.env) {
|
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
118
|
* The npx arguments that run create-pmos: the registry package at a version, or the local path. A bare path is not
|
|
119
|
-
* enough: npx would try to run it as a command, so a local package goes in with --package.
|
|
119
|
+
* enough: npx would try to run it as a command, so a local package goes in with --package. --prefer-online makes npm ask
|
|
120
|
+
* the registry about the pinned version even when it has an older answer cached, which a cache clean used to force.
|
|
120
121
|
*/
|
|
121
122
|
const npxArgs = (src, version, rest) => (src && src.kind === 'local'
|
|
122
123
|
? ['-y', `--package=${src.path}`, '--', PKG_NAME, ...rest]
|
|
123
|
-
: ['-y', `${PKG_NAME}@${version}`, ...rest]);
|
|
124
|
+
: ['-y', '--prefer-online', `${PKG_NAME}@${version}`, ...rest]);
|
|
124
125
|
|
|
125
126
|
/** One changelog entry as the page may show it, or null: a version, a time, and PRs with a number, a summary, from
|
|
126
127
|
* kit-changelog format 2 a title, and from format 3 a kind (k: new, imp, fix or x), which decides New, Improved or Fixed.
|
|
@@ -266,10 +267,6 @@ function whereFrom({ desktop = false, onlineHost = false, tokenKept = false } =
|
|
|
266
267
|
return { ok: false, error: 'This page signed in with a token that changes when the workspace restarts, so it could not come back. Update the workspace on the computer it runs on, or at its Online address' };
|
|
267
268
|
}
|
|
268
269
|
|
|
269
|
-
/**
|
|
270
|
-
* Start the app reinstall and return at once. The child is detached from this server, because create-pmos stops this
|
|
271
|
-
* server before it reinstalls, and it starts the new one after. Its output goes to `logFile`.
|
|
272
|
-
*/
|
|
273
270
|
/**
|
|
274
271
|
* The environment of the app update. A server started by a login item or a desktop shortcut can have a PATH without
|
|
275
272
|
* the node that runs it, and then neither npx nor the pmos launcher is found. The folder of this node is added at the
|
|
@@ -282,26 +279,59 @@ function updateEnv(env = process.env, nodePath = process.execPath) {
|
|
|
282
279
|
return parts.includes(dir) ? { ...env } : { ...env, [key]: [...parts, dir].join(path.delimiter) };
|
|
283
280
|
}
|
|
284
281
|
|
|
285
|
-
|
|
282
|
+
/**
|
|
283
|
+
* On Windows the update runs through this hop, so no step of it opens a console window (batch 27 item 7).
|
|
284
|
+
* A DETACHED child has no console at all, and then every console program it starts (cmd, npm, node, node-gyp) gets a
|
|
285
|
+
* new, VISIBLE console of its own: npm starts create-pmos without windowsHide, so the server cannot hide those. The hop
|
|
286
|
+
* is a detached node with no console. It starts the update once with windowsHide, which gives that child a console with
|
|
287
|
+
* no window, and every program under it shares that hidden console. The hop waits for the update and passes on its exit
|
|
288
|
+
* code. argv: ['direct', cmd, ...args] runs cmd itself; ['shell', cmd, ...args] runs it through cmd.exe (npx.cmd needs
|
|
289
|
+
* a shell), each argument quoted, so a repository path with a space stays one argument.
|
|
290
|
+
*/
|
|
291
|
+
function windowsHop(argv, spawnSync) {
|
|
292
|
+
const [mode, cmd, ...args] = argv;
|
|
293
|
+
const q = (s) => (/[\s"&|<>^()]/.test(s) ? '"' + String(s).replace(/"/g, '""') + '"' : String(s));
|
|
294
|
+
const r = mode === 'shell'
|
|
295
|
+
? spawnSync([cmd, ...args].map(q).join(' '), { stdio: 'inherit', windowsHide: true, shell: true })
|
|
296
|
+
: spawnSync(cmd, args, { stdio: 'inherit', windowsHide: true });
|
|
297
|
+
if (r.error) console.error('The update could not start: ' + r.error.message);
|
|
298
|
+
return r.status == null ? 1 : r.status;
|
|
299
|
+
}
|
|
300
|
+
const HOP = `process.exit((${windowsHop.toString()})(process.argv.slice(1), require('child_process').spawnSync))`;
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The command that starts the app update: { cmd, args }. On macOS and Linux it is npx. On Windows it is this node
|
|
304
|
+
* running the hop above, and the hop runs npm's own npx-cli.js with this node when it is beside it (the usual Node
|
|
305
|
+
* install and nvm-windows), else npx.cmd through a shell.
|
|
306
|
+
*/
|
|
307
|
+
function appUpdateCommand(npx, { platform = process.platform, execPath = process.execPath, exists = fs.existsSync } = {}) {
|
|
308
|
+
if (platform !== 'win32') return { cmd: 'npx', args: npx };
|
|
309
|
+
const cli = path.win32.join(path.win32.dirname(String(execPath)), 'node_modules', 'npm', 'bin', 'npx-cli.js');
|
|
310
|
+
const run = exists(cli) ? ['direct', String(execPath), cli] : ['shell', 'npx.cmd'];
|
|
311
|
+
return { cmd: String(execPath), args: ['-e', HOP, ...run, ...npx] };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Start the app reinstall and return at once. The child is detached from this server, because create-pmos stops this
|
|
316
|
+
* server before it reinstalls, and it starts the new one after. Its output goes to `logFile`.
|
|
317
|
+
* No npm cache clean: the version is pinned and npx runs with --prefer-online, so npm asks the registry for that
|
|
318
|
+
* version's details again and downloads the version if it is not in the cache. A clean took the whole cache, so every
|
|
319
|
+
* update downloaded every dependency again and built node-pty again (batch 27 item 7).
|
|
320
|
+
*/
|
|
321
|
+
function upgradeApp(root, { version, logFile, spawn = cp.spawn, env = updateEnv(), source: src = source(),
|
|
322
|
+
platform = process.platform, execPath = process.execPath, exists = fs.existsSync } = {}) {
|
|
286
323
|
if (!validVersion(version)) return { ok: false, error: 'Check for updates first' };
|
|
287
324
|
if (src.kind === 'local' && src.error) return { ok: false, error: src.error };
|
|
325
|
+
const npx = npxArgs(src, version, ['workspace', '--reinstall', '--no-open', String(root)]);
|
|
326
|
+
const c = appUpdateCommand(npx, { platform, execPath, exists });
|
|
288
327
|
let fd;
|
|
289
328
|
try {
|
|
290
329
|
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
|
291
330
|
fd = fs.openSync(logFile, 'w');
|
|
292
|
-
fs.writeSync(fd, `${new Date().toISOString()}
|
|
293
|
-
fs.writeSync(fd, `${new Date().toISOString()} ${NPM} cache clean --force\n`);
|
|
294
|
-
fs.writeSync(fd, `${new Date().toISOString()} ${NPX} ${npxArgs(src, version, ['workspace', '--reinstall', '--no-open', String(root)]).join(' ')}\n`);
|
|
331
|
+
fs.writeSync(fd, `${new Date().toISOString()} ${platform === 'win32' ? 'npx.cmd' : 'npx'} ${npx.join(' ')}\n`);
|
|
295
332
|
} catch (e) { return { ok: false, error: `Could not write the log file: ${e.message}` }; }
|
|
296
333
|
try {
|
|
297
|
-
|
|
298
|
-
cp.execSync(`${NPM} cache clean --force`, { cwd: String(root), env, stdio: ['ignore', fd, fd] });
|
|
299
|
-
} catch (e) {
|
|
300
|
-
try { fs.appendFileSync(logFile, `Note: cache clear had an issue, but continuing: ${e.message}\n`); } catch { /* silent */ }
|
|
301
|
-
}
|
|
302
|
-
try {
|
|
303
|
-
const child = spawn(NPX, npxArgs(src, version, ['workspace', '--reinstall', '--no-open', String(root)]),
|
|
304
|
-
{ cwd: String(root), env, detached: true, stdio: ['ignore', fd, fd], windowsHide: true, shell: IS_WIN });
|
|
334
|
+
const child = spawn(c.cmd, c.args, { cwd: String(root), env, detached: true, stdio: ['ignore', fd, fd], windowsHide: true });
|
|
305
335
|
// A child that could not start writes nothing itself: say why in the log, which the page names when it gives up.
|
|
306
336
|
if (child && typeof child.on === 'function') {
|
|
307
337
|
child.on('error', (e) => { try { fs.appendFileSync(logFile, `The update could not start: ${e.message}\n`); } catch { /* the page times out */ } });
|
|
@@ -315,4 +345,4 @@ function upgradeApp(root, { version, logFile, spawn = cp.spawn, env = updateEnv(
|
|
|
315
345
|
}
|
|
316
346
|
}
|
|
317
347
|
|
|
318
|
-
module.exports = { PKG_NAME, cmpVer, readChanges, readLastCheck, writeLastCheck, notServedYet, validVersion, source, npxArgs, readKit, fromCheckout, status, compare, latest, updateKit, updateEnv, upgradeApp, whereFrom };
|
|
348
|
+
module.exports = { PKG_NAME, windowsHop, appUpdateCommand, cmpVer, readChanges, readLastCheck, writeLastCheck, notServedYet, validVersion, source, npxArgs, readKit, fromCheckout, status, compare, latest, updateKit, updateEnv, upgradeApp, whereFrom };
|
|
@@ -24,11 +24,7 @@ const os = require('os');
|
|
|
24
24
|
const crypto = require('crypto');
|
|
25
25
|
const cp = require('child_process');
|
|
26
26
|
|
|
27
|
-
const
|
|
28
|
-
const WRITE_VERBS = new Set(['create', 'update', 'delete', 'add', 'post', 'send', 'write', 'edit', 'move', 'transition', 'comment', 'assign', 'upload',
|
|
29
|
-
'remove', 'set', 'merge', 'close', 'archive', 'share', 'copy', 'trash', 'label', 'mark', 'apply', 'schedule', 'reply', 'forward', 'draft', 'respond',
|
|
30
|
-
'invite', 'publish', 'run', 'execute', 'trigger', 'link', 'unlink', 'approve', 'submit', 'save', 'log', 'record', 'register', 'insert', 'append',
|
|
31
|
-
'clear', 'fork', 'enable', 'disable', 'untrash', 'unmark', 'unlabel', 'cancel', 'install', 'import', 'export', 'generate', 'use', 'push', 'request', 'rename']);
|
|
27
|
+
const { readTool } = require('./connector-read.js');
|
|
32
28
|
const HOOKS_OFF = JSON.stringify({ disableAllHooks: true });
|
|
33
29
|
const MAX_FINDINGS = 40;
|
|
34
30
|
const FIELDS = ['purpose', 'team', 'owner', 'timeline', 'goals'];
|
|
@@ -37,15 +33,6 @@ const clean = (v, n = 300) => String(v == null ? '' : v).replace(/[\u0000-\u001f
|
|
|
37
33
|
const httpsOnly = (u) => { try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.href.slice(0, 500) : null; } catch { return null; } };
|
|
38
34
|
/** Claude Code's tool prefix for a server name: `mcp__<name with every other character as _>__`. */
|
|
39
35
|
const prefixOf = (name) => 'mcp__' + String(name).replace(/[^A-Za-z0-9_-]/g, '_') + '__';
|
|
40
|
-
const words = (s) => String(s).replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w.toLowerCase());
|
|
41
|
-
|
|
42
|
-
/** A tool may be granted only when its own name (after the server prefix) leads with a read verb and holds no write verb. */
|
|
43
|
-
function readTool(toolName, prefix) {
|
|
44
|
-
const own = prefix && toolName.startsWith(prefix) ? toolName.slice(prefix.length) : toolName.replace(/^mcp__[^_]+(?:_[^_]+)*?__/, '');
|
|
45
|
-
const w = words(own);
|
|
46
|
-
if (!w.length || w.some((x) => WRITE_VERBS.has(x))) return false;
|
|
47
|
-
return w.slice(0, 3).some((x) => READ_VERBS.has(x));
|
|
48
|
-
}
|
|
49
36
|
|
|
50
37
|
function parseMcpList(stdout) {
|
|
51
38
|
const out = [];
|
|
@@ -282,7 +269,9 @@ function installKit(root, kit) {
|
|
|
282
269
|
if (fs.existsSync(dest)) throw new Error('A pmos folder is already there. PMOS will not replace it.');
|
|
283
270
|
if (kit.payload) { fs.cpSync(kit.payload, dest, { recursive: true }); }
|
|
284
271
|
else if (kit.buildScript) { cp.execFileSync('bash', [kit.buildScript, root], { stdio: 'ignore', timeout: 300000 }); }
|
|
285
|
-
|
|
272
|
+
// No npm fallback here: only lib/kit-update.js reaches the registry (no-network.test.js). An app installed before the kit shipped with
|
|
273
|
+
// it gets the kit with its next update.
|
|
274
|
+
else throw new Error('This PMOS install has no kit to add. Update PMOS in Settings › Updates, then try again.');
|
|
286
275
|
if (!fs.existsSync(path.join(dest, 'AGENTS.md'))) throw new Error('The kit did not install completely.');
|
|
287
276
|
const profile = path.join(root, '.pmos-profile');
|
|
288
277
|
if (!fs.existsSync(profile)) fs.writeFileSync(profile, 'pm-artifacts\n# PMOS repository profile (D68): pm-artifacts | product-code\n');
|
|
@@ -290,8 +279,10 @@ function installKit(root, kit) {
|
|
|
290
279
|
const cur = fs.existsSync(ga) ? fs.readFileSync(ga, 'utf8') : '';
|
|
291
280
|
if (!cur.split('\n').some((l) => l.trim() === '*.jsonl merge=union')) fs.writeFileSync(ga, `${cur}${cur && !cur.endsWith('\n') ? '\n' : ''}*.jsonl merge=union\n`);
|
|
292
281
|
}
|
|
282
|
+
/** Where the kit comes from: the installed app's own payload/ (create-pmos copies it there), the npx bundle's, a PMOS checkout's
|
|
283
|
+
* build script. */
|
|
293
284
|
function kitSource(here = __dirname, env = process.env) {
|
|
294
|
-
const candidates = [env.PMOS_KIT_PAYLOAD, path.join(here, '..', '..', 'payload'), path.join(here, '..', '..', 'npm-pkg', 'payload')].filter(Boolean);
|
|
285
|
+
const candidates = [env.PMOS_KIT_PAYLOAD, path.join(here, '..', 'payload'), path.join(here, '..', '..', 'payload'), path.join(here, '..', '..', 'npm-pkg', 'payload')].filter(Boolean);
|
|
295
286
|
const payload = candidates.find((p) => fs.existsSync(path.join(p, 'AGENTS.md')));
|
|
296
287
|
if (payload) return { payload };
|
|
297
288
|
const buildScript = path.join(here, '..', '..', 'kit', 'build-kit.sh');
|
|
@@ -236,7 +236,8 @@ function create(host) {
|
|
|
236
236
|
}
|
|
237
237
|
start(src, ws, { trigger: d.trigger, slot: d.slot, note: d.note });
|
|
238
238
|
}
|
|
239
|
-
|
|
239
|
+
// PMOS_SOURCE_HEAL=off stops the repairs (a suite about pressed and scheduled runs sets it, so no repair run races them)
|
|
240
|
+
if (process.env.PMOS_SOURCE_HEAL !== 'off') { try { healTick(t, set); } catch (e) { warn('source repair', e); } }
|
|
240
241
|
if (t - lastPrune > PRUNE_EVERY_MS) {
|
|
241
242
|
lastPrune = t;
|
|
242
243
|
try { const p = R.prune(dir, rules.keep, t); if (p.lines || p.copies) warn('refresh log', { message: `removed ${p.lines} lines and ${p.copies} copies older than ${rules.keep} days` }); } catch (e) { warn('refresh log prune', e); }
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
/* routes-board-publish.js — publish a board to the central host from the workspace (initiative board-publish, slice 2; D103; PRD P1 to P5).
|
|
3
3
|
*
|
|
4
4
|
* GET /api/board-publish -> { ok, host, days, links } a read: whether a host is set, the choices, the open links (never the key)
|
|
5
|
-
* POST /api/board-publish { ws, board, select, team, password, days, shared_by } -> { ok, id, url, ends }
|
|
5
|
+
* POST /api/board-publish { ws, board, select | parts, name?, team, password, days, shared_by } -> { ok, id, url, ends, name }
|
|
6
|
+
* parts (batch 27 item 1): several products and programs in one link, one tab per pick (lib/board-publish-parts.js)
|
|
6
7
|
* POST /api/board-publish/republish { id } -> { ok, ends } the same selection, as the board is now; same link, same password
|
|
7
8
|
* POST /api/board-publish/off { id } -> { ok } the host deletes the copy at once
|
|
8
9
|
* POST /api/board-publish/password { id } -> { ok, password } a new link password from the host, shown once; the old one stops
|
|
@@ -11,6 +12,10 @@
|
|
|
11
12
|
* POST /api/board-publish/join { invite } or { host, team, password } -> { ok, team } verified with the host first; the password stays on this computer
|
|
12
13
|
* POST /api/board-publish/leave { team } -> { ok } forgets the team here
|
|
13
14
|
* POST /api/board-publish/open { team, id } -> { ok, url } a one-time address for a team member, opened in a browser tab
|
|
15
|
+
* POST /api/board-publish/receive { link, password? } -> { ok, board } a board shared with you, kept here (batch 27 item 3; lib/boards-received.js)
|
|
16
|
+
* POST /api/board-publish/forget { id } -> { ok } forgets it here
|
|
17
|
+
* POST /api/board-publish/visit { id } -> { ok, url } its one-time address, opened in a browser tab
|
|
18
|
+
* GET /api/board-publish/shared also answers withYou: [{ id, host, url, name, by, kind, ends, state }] (no password, ever)
|
|
14
19
|
*
|
|
15
20
|
* EVERY POST IS A PAGE PRESS (pm-guard requirePagePress): only a press on the page publishes, not a request an agent session makes (PRD P2, D97 S3).
|
|
16
21
|
* THE SECRET SCAN runs on the finished pack before anything leaves (P3): a hit blocks the publish and the answer names the rule, never the text (D08).
|
|
@@ -24,6 +29,8 @@ const Host = require('./boards-host.js');
|
|
|
24
29
|
const shareStore = require('./share-store.js');
|
|
25
30
|
const secretScan = require('./secret-scan.js');
|
|
26
31
|
const Teams = require('./boards-teams.js');
|
|
32
|
+
const Parts = require('./board-publish-parts.js');
|
|
33
|
+
const Received = require('./boards-received.js');
|
|
27
34
|
|
|
28
35
|
const PUBLIC = path.join(__dirname, '..', 'public');
|
|
29
36
|
let versionMemo = null;
|
|
@@ -74,7 +81,7 @@ module.exports = {
|
|
|
74
81
|
prefixes: ['/api/board-publish'],
|
|
75
82
|
handle: async (req, res, url, ctx) => {
|
|
76
83
|
const p = url.pathname;
|
|
77
|
-
if (!/^\/api\/board-publish(\/(republish|off|password|host|shared|join|leave|open))?$/.test(p)) return false;
|
|
84
|
+
if (!/^\/api\/board-publish(\/(republish|off|password|host|shared|join|leave|open|receive|forget|visit))?$/.test(p)) return false;
|
|
78
85
|
const send = (obj, code) => { ctx.json(res, obj, code); return true; };
|
|
79
86
|
const dir = stateDir(ctx);
|
|
80
87
|
if (!dir) return send({ ok: false, error: 'No state folder.' }, 500);
|
|
@@ -88,8 +95,8 @@ module.exports = {
|
|
|
88
95
|
}
|
|
89
96
|
// the Shared view's read (slice 4): your own links and every joined team's list, read now from each host. Nothing is kept from the read.
|
|
90
97
|
if (req.method === 'GET' && p === '/api/board-publish/shared') {
|
|
91
|
-
const teams = await Teams.readAll(dir);
|
|
92
|
-
return send({ ok: true, readAt: new Date().toISOString(), yours: Host.links(dir).map((l) => ({ id: l.id, url: l.url, team: l.team, name: l.name, kind: l.kind, by: l.by, made: l.made, ends: l.ends, views: l.views })), teams });
|
|
98
|
+
const [teams, withYou] = await Promise.all([Teams.readAll(dir), Received.readAll(dir)]);
|
|
99
|
+
return send({ ok: true, readAt: new Date().toISOString(), withYou, yours: Host.links(dir).map((l) => ({ id: l.id, url: l.url, team: l.team, name: l.name, kind: l.kind, by: l.by, made: l.made, ends: l.ends, views: l.views })), teams });
|
|
93
100
|
}
|
|
94
101
|
if (req.method !== 'POST') return send({ ok: false, error: 'GET or POST only' }, 405);
|
|
95
102
|
|
|
@@ -104,6 +111,16 @@ module.exports = {
|
|
|
104
111
|
const r = await Teams.join(dir, { invite: body.invite, host: body.host, team: body.team, password: body.password });
|
|
105
112
|
return send(r.ok ? { ok: true, team: r.team } : { ok: false, error: r.error, kind: r.kind }, r.ok ? 200 : (r.kind === 'locked' ? 429 : 400));
|
|
106
113
|
}
|
|
114
|
+
// a board someone shared with you (batch 27 item 3, ShX): checked with its host first; the password stays on this computer (D08)
|
|
115
|
+
if (p === '/api/board-publish/receive') {
|
|
116
|
+
const r = await Received.add(dir, { link: body.link, password: body.password });
|
|
117
|
+
return send(r.ok ? { ok: true, board: r.board } : { ok: false, error: r.error, kind: r.kind }, r.ok ? 200 : (r.kind === 'locked' ? 429 : r.kind === 'unreachable' ? 502 : 400));
|
|
118
|
+
}
|
|
119
|
+
if (p === '/api/board-publish/forget') { const r = Received.forget(dir, String(body.id || '')); return send(r.ok ? { ok: true } : { ok: false, error: 'This board is not kept on this computer.' }, r.ok ? 200 : 404); }
|
|
120
|
+
if (p === '/api/board-publish/visit') {
|
|
121
|
+
const r = await Received.visit(dir, String(body.id || ''));
|
|
122
|
+
return send(r.ok ? { ok: true, url: r.url } : { ok: false, error: r.error, kind: r.kind }, r.ok ? 200 : 502);
|
|
123
|
+
}
|
|
107
124
|
if (p === '/api/board-publish/leave') { const r = Teams.leave(dir, String(body.team || '')); return send(r.ok ? { ok: true } : { ok: false, error: 'This computer has not joined that team.' }, r.ok ? 200 : 404); }
|
|
108
125
|
if (p === '/api/board-publish/open') { // the one-time address of a team's board, to open in a browser tab
|
|
109
126
|
const r = await Teams.open(dir, String(body.team || ''), String(body.id || ''));
|
|
@@ -124,28 +141,41 @@ module.exports = {
|
|
|
124
141
|
|
|
125
142
|
// publish and republish both make a pack, scan it, then send it
|
|
126
143
|
const now = Date.now();
|
|
127
|
-
let sel; let meta;
|
|
144
|
+
let sel; let meta; let parts = null;
|
|
128
145
|
if (p === '/api/board-publish/republish') {
|
|
129
146
|
const l = Host.readState(dir).links.find((x) => x.id === String(body.id || ''));
|
|
130
147
|
if (!l) return send({ ok: false, error: 'This link is not one of yours.' }, 404);
|
|
131
148
|
sel = { ws: l.ws, board: l.board, select: (l.selection || []).map((s) => s.kind + ':' + s.id).join(','), by: l.by, ends: l.ends };
|
|
132
|
-
meta = l;
|
|
149
|
+
meta = l; parts = Array.isArray(l.parts) && l.parts.length ? Parts.parseParts(l.parts) : null;
|
|
133
150
|
} else {
|
|
134
151
|
const days = Number(body.days);
|
|
135
152
|
if (!Host.DAYS.includes(days)) return send({ ok: false, error: 'Ends in 1, 7 or 30 days.' }, 400);
|
|
136
153
|
sel = { ws: body.ws, board: body.board || body.ws, select: Array.isArray(body.select) ? body.select.map((s) => s.kind + ':' + s.id).join(',') : String(body.select || ''), by: String(body.shared_by || '').slice(0, 60) || sharedBy(), ends: new Date(now + days * 86400000).toISOString() };
|
|
154
|
+
if (body.parts != null) { parts = Parts.parseParts(body.parts); if (!parts) return send({ ok: false, error: 'Pick up to ' + Parts.MAX_PARTS + ' products, programs or initiatives to publish.' }, 400); }
|
|
155
|
+
}
|
|
156
|
+
const base = { at: new Date(now).toISOString(), viewer: viewerVersion(), view: body.view || (meta && meta.view) };
|
|
157
|
+
let pack; let name; let kind;
|
|
158
|
+
if (parts) {
|
|
159
|
+
// several picks (batch 27 item 1, ShA): a pack per product, each made and swept as today, joined into one pack with a tab per pick
|
|
160
|
+
const c = await require('./board-publish-choices.js').contextOf(req, ctx, sel.ws, sel.board);
|
|
161
|
+
if (!c) return send({ ok: false, error: 'The board is still being read. Try again in a moment.' }, 202);
|
|
162
|
+
const r = await Parts.packOf(parts, { programs: c.programs, boards: c.boards, name: meta ? meta.name : String(body.name || '').slice(0, 120), make: (g) => makePack(req, ctx, Object.assign({ by: sel.by, ends: sel.ends }, base, { ws: g.ws, board: g.board, select: g.select })) });
|
|
163
|
+
if (!r.ok) return send({ ok: false, error: r.error }, r.status || 502);
|
|
164
|
+
pack = r.pack; name = r.label; kind = r.kind;
|
|
165
|
+
} else {
|
|
166
|
+
const pk = await makePack(req, ctx, Object.assign({}, sel, base));
|
|
167
|
+
if (!pk.ok) return send({ ok: false, error: pk.error }, pk.status || 502);
|
|
168
|
+
pack = pk.pack; name = pack.scope.label; kind = kindOf(pack);
|
|
137
169
|
}
|
|
138
|
-
const
|
|
139
|
-
if (!pk.ok) return send({ ok: false, error: pk.error }, pk.status || 502);
|
|
140
|
-
const hits = secretScan.scan(JSON.stringify(pk.pack));
|
|
170
|
+
const hits = secretScan.scan(JSON.stringify(pack));
|
|
141
171
|
if (hits.length) return send({ ok: false, error: 'This board holds what looks like a secret (' + hits.join(', ') + '). Remove it, then publish again.', secrets: hits }, 422);
|
|
142
172
|
|
|
143
173
|
if (p === '/api/board-publish/republish') {
|
|
144
|
-
const r = await Host.republish(dir, meta.id,
|
|
174
|
+
const r = await Host.republish(dir, meta.id, pack, { env });
|
|
145
175
|
return send(r.ok ? { ok: true, ends: r.ends } : { ok: false, error: r.error }, r.ok ? 200 : (r.status || 502));
|
|
146
176
|
}
|
|
147
177
|
const selection = Array.isArray(body.select) ? body.select.map((s) => ({ kind: String(s.kind), id: String(s.id) })) : String(body.select || '').split(',').filter(Boolean).map((s) => ({ kind: s.slice(0, s.indexOf(':')), id: s.slice(s.indexOf(':') + 1) }));
|
|
148
|
-
const r = await Host.publish(dir, { pack
|
|
149
|
-
return send(r.ok ? { ok: true, id: r.id, url: r.url, ends: r.ends } : { ok: false, error: r.error }, r.ok ? 200 : (r.status || 502));
|
|
178
|
+
const r = await Host.publish(dir, { pack, team: body.team, name, kind, by: sel.by, days: Number(body.days), password: body.password == null ? '' : body.password, selection, parts, ws: sel.ws, board: sel.board }, { env });
|
|
179
|
+
return send(r.ok ? { ok: true, id: r.id, url: r.url, ends: r.ends, name } : { ok: false, error: r.error }, r.ok ? 200 : (r.status || 502));
|
|
150
180
|
},
|
|
151
181
|
};
|
|
@@ -62,6 +62,14 @@ function hasWork(root) {
|
|
|
62
62
|
return INITIATIVE_DIRS.some((d) => ls(d).some((n) => n.endsWith('.md') && !/^README|template/i.test(n)))
|
|
63
63
|
|| RECORD_DIRS.some((d) => ls(d).some((n) => n.endsWith('.jsonl') && filled(d, n)));
|
|
64
64
|
}
|
|
65
|
+
/** The installer's home folder while it is still an empty kit (PMOS in it, no work yet), as folder facts; otherwise null. */
|
|
66
|
+
function installerKit(ctx) {
|
|
67
|
+
if (!ctx.homeRoot || !(ctx.workspaceList ? ctx.workspaceList() : []).some((w) => w && w.root && path.resolve(w.root) === path.resolve(ctx.homeRoot))) return null;
|
|
68
|
+
const pm = P.pmosIn(ctx.homeRoot);
|
|
69
|
+
if (!pm || !pm.kit || hasWork(ctx.homeRoot)) return null;
|
|
70
|
+
const facts = P.folderFacts(ctx.homeRoot);
|
|
71
|
+
return facts.ok && facts.exists && !facts.inside_other_git ? facts : null;
|
|
72
|
+
}
|
|
65
73
|
function view(ctx) {
|
|
66
74
|
const { st, bound } = fresh(ctx, read(ctx));
|
|
67
75
|
const ws = st.done && st.done.ws && ctx.getWs(st.done.ws);
|
|
@@ -123,6 +131,15 @@ async function handle(req, res, url, ctx) {
|
|
|
123
131
|
if (r.cancelled || !r.path) return send({ ok: false, cancelled: true });
|
|
124
132
|
if (b.mode === 'new') parent = r.path; else root = r.path;
|
|
125
133
|
}
|
|
134
|
+
// One folder per product: the one-line installer already made ~/PMOS a Git repository with an empty kit and opened it as the home
|
|
135
|
+
// product. The first proposal is that folder, so setup fills it (and renames it to the product) instead of nesting a second
|
|
136
|
+
// repository inside it and leaving an empty "PMOS" product behind (onboarding grill, 2026-11-04). Choosing "Make a new folder"
|
|
137
|
+
// afterwards still proposes a new one.
|
|
138
|
+
const reuse = b.mode === 'new' && !parent && !st.folder && installerKit(ctx);
|
|
139
|
+
if (reuse) {
|
|
140
|
+
write(ctx, { ...st, folder: { mode: 'existing', root: reuse.root, facts: reuse, chosen: false, reused: true } });
|
|
141
|
+
return send(view(ctx));
|
|
142
|
+
}
|
|
126
143
|
if (b.mode === 'new') {
|
|
127
144
|
const pr = P.proposeFolder(parent, st.project.name);
|
|
128
145
|
if (!pr.ok) return send(pr, 400);
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.523.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.523.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/sdk": "^1.3.0",
|