create-pmos 0.132.0 → 0.133.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/package.json +1 -1
- 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 +18 -2
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/welcome.md +38 -0
- package/workspace/lib/import-overlay.js +32 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-onboarding.js +215 -0
- package/workspace/lib/vendor/scripts/anchor-resolve.js +351 -0
- package/workspace/lib/vendor/scripts/delivery-index.js +356 -0
- package/workspace/lib/vendor/scripts/intake.js +78 -0
- package/workspace/lib/vendor/scripts/lane-defs.js +226 -0
- package/workspace/lib/vendor/scripts/migrate-layout.js +775 -0
- package/workspace/lib/vendor/scripts/nine-stages.js +81 -0
- package/workspace/lib/vendor/scripts/onboarding/apply.js +110 -0
- package/workspace/lib/vendor/scripts/onboarding/brief.js +154 -0
- package/workspace/lib/vendor/scripts/onboarding/first-briefing.js +41 -0
- package/workspace/lib/vendor/scripts/onboarding/first-look.js +84 -0
- package/workspace/lib/vendor/scripts/onboarding/infer-brief.js +70 -0
- package/workspace/lib/vendor/scripts/onboarding/proposals.js +98 -0
- package/workspace/lib/vendor/scripts/onboarding/scan-worker.js +27 -0
- package/workspace/lib/vendor/scripts/onboarding/scan.js +859 -0
- package/workspace/lib/vendor/scripts/onboarding/structure.js +217 -0
- package/workspace/lib/vendor/scripts/plan-items-stage.js +271 -0
- package/workspace/lib/vendor/scripts/read-jsonl.js +53 -0
- package/workspace/lib/vendor/scripts/signals/layout-paths.js +43 -0
- package/workspace/lib/vendor/scripts/status-facts.js +214 -0
- package/workspace/lib/vendor/scripts/trust/import-guard.js +36 -0
- package/workspace/lib/vendor/scripts/trust/ledger.js +53 -0
- package/workspace/lib/vendor/scripts/workflow-state.js +1888 -0
- package/workspace/lib/vendor/workspace/lib/actor.js +91 -0
- package/workspace/lib/vendor/workspace/lib/board-cache.js +156 -0
- package/workspace/lib/vendor/workspace/lib/console-state.js +295 -0
- package/workspace/lib/vendor/workspace/lib/doc-category.js +227 -0
- package/workspace/lib/vendor/workspace/lib/factory-join.js +158 -0
- package/workspace/lib/vendor/workspace/lib/git-info.js +306 -0
- package/workspace/lib/vendor/workspace/lib/plan-items.js +922 -0
- package/workspace/lib/vendor/workspace/lib/plan-tree.js +419 -0
- package/workspace/lib/vendor/workspace/lib/review-gate.js +39 -0
- package/workspace/lib/vendor/workspace/lib/sink.js +321 -0
- package/workspace/lib/vendor/workspace/lib/state-root.js +587 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +280 -0
- package/workspace/public/pm/welcome.js +283 -0
- package/workspace/public/welcome.html +20 -0
- package/workspace/server.js +2 -2
- package/workspace/test-lists/onboarding-firstrun.json +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.133.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-11T04:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-11T04:26:11Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-11T04:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-11T04:26:11Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-11T04:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-11T04:26:11Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/bin/pmos.js
CHANGED
|
@@ -188,6 +188,7 @@ function ensureInstalled() {
|
|
|
188
188
|
// Register the target directory and open the browser ON IT. The `focus` broadcast matters:
|
|
189
189
|
// the OS may refocus an already-open tab instead of opening our URL, and without it the PM
|
|
190
190
|
// would be looking at the previously-opened repo — the defect this replaces.
|
|
191
|
+
let FORCE_WELCOME = false; // `pmos welcome [path]`
|
|
191
192
|
async function openWorkspace(dir, port, { open = true } = {}) {
|
|
192
193
|
const root = repoRootOf(dir);
|
|
193
194
|
const reg = await api(port, 'POST', '/api/workspaces', { root, name: path.basename(root) });
|
|
@@ -199,7 +200,17 @@ async function openWorkspace(dir, port, { open = true } = {}) {
|
|
|
199
200
|
const w = reg.workspace;
|
|
200
201
|
say(`✓ workspace “${w.name}” — ${urlOf(port)}?ws=${w.id}`);
|
|
201
202
|
say(` ${root}`);
|
|
202
|
-
|
|
203
|
+
// First run (onboarding-scan-and-map): a plain folder PMOS has never imported opens on Welcome (the route's first_run);
|
|
204
|
+
// `pmos welcome` opens it on purpose. Any failure keeps the old URL.
|
|
205
|
+
let first = FORCE_WELCOME;
|
|
206
|
+
if (!first) {
|
|
207
|
+
try {
|
|
208
|
+
const st = await api(port, 'GET', `/api/onboarding/status?ws=${encodeURIComponent(w.id)}`);
|
|
209
|
+
first = !!(st && st.ok && st.first_run);
|
|
210
|
+
} catch { first = false; }
|
|
211
|
+
}
|
|
212
|
+
if (first) say(FORCE_WELCOME ? '✓ opening Welcome' : '✓ first run — opening Welcome');
|
|
213
|
+
if (open) openBrowser(first ? `${urlOf(port)}/welcome?ws=${w.id}` : `${urlOf(port)}?ws=${w.id}`);
|
|
203
214
|
return 0;
|
|
204
215
|
}
|
|
205
216
|
|
|
@@ -511,9 +522,10 @@ function doctor() {
|
|
|
511
522
|
if (r.error) { say('✗ could not run ' + npx + ' — ' + r.error.message); code = 1; }
|
|
512
523
|
else code = r.status === null ? 1 : r.status;
|
|
513
524
|
} else if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
514
|
-
say('usage: pmos [<path>|status|ls|stop|doctor|phone|upgrade|schedule|skill|proactive|kg|item|' + require('../lib/pmos-verbs.js').names().join('|') + ']');
|
|
525
|
+
say('usage: pmos [<path>|welcome|status|ls|stop|doctor|phone|upgrade|schedule|skill|proactive|kg|item|' + require('../lib/pmos-verbs.js').names().join('|') + ']');
|
|
515
526
|
say(' pmos open the current repo as a workspace (starts the server if needed)');
|
|
516
527
|
say(' pmos <path> open another repo as a workspace');
|
|
528
|
+
say(' pmos welcome [<path>] scan a folder and bring its products and initiatives into PMOS (undoable)');
|
|
517
529
|
say(' pmos phone turn on phone access over Tailscale and show the QR code to scan');
|
|
518
530
|
say(' pmos schedule … create, edit and run scheduled tasks (pmos schedule --help)');
|
|
519
531
|
say(' pmos skill … list, read, create and edit skills');
|
|
@@ -523,6 +535,10 @@ function doctor() {
|
|
|
523
535
|
for (const line of require('../lib/pmos-verbs.js').helpLines()) say(line);
|
|
524
536
|
say(' pmos upgrade reinstall the workspace app from the latest published create-pmos');
|
|
525
537
|
code = 0;
|
|
538
|
+
} else if (cmd === 'welcome') {
|
|
539
|
+
// the first-run page on purpose: scan this folder, import it, or undo an import (onboarding-scan-and-map)
|
|
540
|
+
FORCE_WELCOME = true;
|
|
541
|
+
code = await up(process.argv[3] && !process.argv[3].startsWith('-') ? path.resolve(process.argv[3]) : process.cwd());
|
|
526
542
|
} else if (cmd === 'up' || cmd === 'start' || cmd === 'open') {
|
|
527
543
|
// `pmos open ../other-repo` — the path is the argument AFTER the verb
|
|
528
544
|
code = await up(process.argv[3] && !process.argv[3].startsWith('-') ? path.resolve(process.argv[3]) : process.cwd());
|
package/workspace/guide/index.md
CHANGED
|
@@ -39,6 +39,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
|
|
|
39
39
|
|
|
40
40
|
## Where to read next
|
|
41
41
|
|
|
42
|
+
- [Welcome (first run)](welcome.md): scan a new folder, review the proposed portfolio, import it and undo it.
|
|
42
43
|
- [The Briefing](briefing.md): the console's first page: Today's meetings, Needs you (Done, Not yet, Drop) and the Portfolio pulse.
|
|
43
44
|
- [Start the workspace without a terminal](launcher.md): the PMOS app, the login item and `pmos launcher`.
|
|
44
45
|
- [Jira tickets](jira.md): Jira tickets drafted from the PRD, pushed when you select them, and their status.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Guide
|
|
3
|
+
title: "Guide: Welcome (first run)"
|
|
4
|
+
timestamp: 2026-10-11
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Welcome (first run)
|
|
8
|
+
|
|
9
|
+
The Welcome page is the first screen of a new folder. It reads your folder, shows what it found, and sets up your portfolio from it. You do not need to answer questions first.
|
|
10
|
+
|
|
11
|
+
## How you get there
|
|
12
|
+
|
|
13
|
+
Run `pmos` in a plain folder that PMOS has not imported. The terminal says "first run — opening Welcome" and the browser opens `/welcome`. A folder that already has its own PMOS state folder opens the console as before. After an import, `pmos` opens the console.
|
|
14
|
+
|
|
15
|
+
Run `pmos welcome` to open the page on purpose, for example to scan a folder again or to undo an import. `pmos welcome <path>` opens it for another folder.
|
|
16
|
+
|
|
17
|
+
Choose **Not now** on the first screen to go to the console. Welcome then stops opening on its own for that folder. `pmos welcome` still opens it.
|
|
18
|
+
|
|
19
|
+
## The promise
|
|
20
|
+
|
|
21
|
+
The page says it before the scan: PMOS reads only, writes nothing in your folder, and nothing leaves this computer. The scan runs on your machine and uses no model. The import is stored in the PMOS state folder, never in your folder.
|
|
22
|
+
|
|
23
|
+
## The steps
|
|
24
|
+
|
|
25
|
+
1. **Welcome.** The page shows the folder name and path. Choose **Add another folder** to open the system folder chooser and add a second folder. Choose **Scan** to start. A spinner shows while the scan runs. A large folder can take a minute or two, and the rest of PMOS keeps working.
|
|
26
|
+
2. **First look.** One sentence gives the counts: initiatives, documents, deadlines, stale documents, duplicates, people and risks. Up to ten findings follow. Each finding shows the file and line it comes from. Choose **Continue**.
|
|
27
|
+
3. **Your portfolio.** PMOS proposes one board for each product folder (`products/<name>/`). If there is no product folder, it proposes one board for the whole folder. Each board lists its initiatives. Each row has a checkbox, a title you can edit, a lane, an anchor, a stage and a document count. Choose the document count to see the documents. Use the board list on a row to move it to another board. Each board shows how many documents PMOS could not place. High and medium confidence rows start checked. The button reads **Import N initiatives into M boards**. The numbers change as you check, uncheck and move rows.
|
|
28
|
+
4. **Questions.** This step appears only when the scan could not decide something. There are three questions at most. Each question shows its default already chosen. Choose **Import** to accept what is chosen. Choose **Skip** to leave a question open: for the anchor question, the proposed anchors then stay marked for you to confirm later.
|
|
29
|
+
5. **First Briefing.** After the import, the page lists up to five things that need you first. An item that belongs to an imported initiative names the initiative and its board. Choose **Continue**. Choose **Undo this import** to take the import back and return to your portfolio.
|
|
30
|
+
6. **More sources.** This step is optional. It offers Calendar (your meetings today and for the next 14 days) and Jira (the issues assigned to you). PMOS reads nothing from them until you choose **Bring in**. Bring in starts the source's read-only skill as an agent session in the workspace, with your own connection. The skill writes nothing to the tool. Choose **Skip** to leave a source out. You can bring it in later from Settings. Inbox and Slack have no import yet. Choose **Open PMOS** to go to the console.
|
|
31
|
+
|
|
32
|
+
## Undo
|
|
33
|
+
|
|
34
|
+
**Undo this import** retracts the import. Nothing is deleted: the import and its undo stay in the record. You can then change the portfolio and import again. When you open Welcome after an import, the page shows the first Briefing with the Undo link.
|
|
35
|
+
|
|
36
|
+
## What the page does not do
|
|
37
|
+
|
|
38
|
+
The page does not change your folder. It does not open the files that the exclusion list names, such as `.git`, `node_modules` and files that look like secrets. It does not read Calendar or Jira until you choose **Bring in** on More sources, and the PMOS server itself calls nothing outside this computer. It does not import from Drive, Inbox, Slack or other tools.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* import-overlay.js: where a workspace's first-run import lives, and the fold of it (slice 2 of onboarding-scan-and-map).
|
|
3
|
+
* overlayDir(stateRoot, wsId) = <stateRoot>/imports/<wsId> overlayDirFor is the same function
|
|
4
|
+
* importedFor(stateRoot, wsId) -> { boards, initiatives, imports } from scripts/onboarding/apply.js; empty shapes when nothing is there.
|
|
5
|
+
* Initiative rows carry import_id and source: 'import'; board rows too. Never throws on a missing folder or file. */
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
/** Where scripts/onboarding/ is: the checkout's own, else the copy an installed package vendors under lib/vendor/
|
|
10
|
+
* (scripts/vendor-workspace-engine.js vendorTree). Null when neither is there. */
|
|
11
|
+
function onboardingDir() {
|
|
12
|
+
for (const d of [path.join(__dirname, '..', '..', 'scripts', 'onboarding'), path.join(__dirname, 'vendor', 'scripts', 'onboarding')]) {
|
|
13
|
+
if (fs.existsSync(path.join(d, 'scan.js'))) return d;
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const WS_ID = /^[A-Za-z0-9_-]{1,64}$/; // one safe path segment (server ids are 10 hex characters)
|
|
19
|
+
const empty = () => ({ boards: [], initiatives: [], imports: [] });
|
|
20
|
+
|
|
21
|
+
function overlayDir(stateRoot, wsId) {
|
|
22
|
+
if (!WS_ID.test(String(wsId))) throw new Error('bad workspace id');
|
|
23
|
+
return path.join(stateRoot, 'imports', String(wsId));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function importedFor(stateRoot, wsId) {
|
|
27
|
+
let apply;
|
|
28
|
+
try { apply = require(path.join(onboardingDir(), 'apply.js')); } catch { return empty(); }
|
|
29
|
+
try { return apply.importedFor(overlayDir(stateRoot, wsId)); } catch { return empty(); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { overlayDir, overlayDirFor: overlayDir, importedFor, onboardingDir };
|
|
@@ -60,6 +60,7 @@ const MODULES = Object.freeze([
|
|
|
60
60
|
'routes-c3-life', // console3 Lifecycle lens panels: newest evaluation per initiative, corrections per run, evidence sources (read-only)
|
|
61
61
|
'routes-c3-strategy', // console3 Calendar Quarter and Year panes: outcomes, north star, signals, synergy, bets (read-only)
|
|
62
62
|
'routes-c3-reports', // console3 Reports dashboards: library, per-scope data, page-press layouts (layouts are the only write)
|
|
63
|
+
'routes-onboarding', // onboarding-scan-and-map slice 2: first run (scan read-only, structure, import into the overlay, undo, first Briefing)
|
|
63
64
|
'routes-hooks', // planning-flows run 7: CLI hook endpoints (session token, per-run JSONL)
|
|
64
65
|
'routes-sources', // workspace-data-freshness: how old each source is, Refresh now, the change detector switch
|
|
65
66
|
]);
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-onboarding.js — the first run: scan a folder read-only, show the proposed portfolio, import it into the overlay
|
|
3
|
+
* (onboarding-scan-and-map, slice 2). Every read takes ?ws. Every POST passes pm-guard requirePagePress.
|
|
4
|
+
*
|
|
5
|
+
* GET /api/onboarding/status?ws → { ok, state: 'new'|'scanned'|'imported', first_run, dismissed, scanning, scan_id, imported: { boards, initiatives }, imports }
|
|
6
|
+
* POST /api/onboarding/scan { ws, extra_roots? } → { ok, scan_id, first_look: { sentence, numbers, findings } }
|
|
7
|
+
* GET /api/onboarding/structure?ws → { ok, ...structure }
|
|
8
|
+
* POST /api/onboarding/apply { ws, include, revisions, answers } → { ok, import_id, boards, initiatives }
|
|
9
|
+
* POST /api/onboarding/undo { ws, import_id } → { ok, retracted }
|
|
10
|
+
* GET /api/onboarding/briefing?ws → { ok, items }
|
|
11
|
+
* POST /api/onboarding/dismiss { ws } → { ok } "Not now": status.first_run turns false for this folder
|
|
12
|
+
*
|
|
13
|
+
* STATE: <STATE>/imports/<wsId>/ (brief.json, scan.json, firstlook.json, proposals.jsonl, imports.jsonl), in the per-user
|
|
14
|
+
* state folder and never in the scanned folder. The scan reads ws.root (and the root of each extra_roots entry that is a
|
|
15
|
+
* registered workspace) and writes nothing there. Apply writes to the overlay only.
|
|
16
|
+
*
|
|
17
|
+
* WHO WRITES: the press gives the page's actor ({ by: 'pm:<slug>', via: 'ui' }). The onboarding ledger wants a human actor,
|
|
18
|
+
* so humanOf() maps 'pm:<slug>' to 'human:<slug>'; an actor that is not a PM press is refused. Modules under
|
|
19
|
+
* scripts/onboarding/ are required inside the handlers, so a module that is not built yet answers 500 for its route only.
|
|
20
|
+
*
|
|
21
|
+
* EXTRA ROOTS. scan() reads one root. An extra root must be a registered workspace (never an arbitrary path); it is scanned
|
|
22
|
+
* on its own into its own overlay, so its own Welcome page shows it. The answer lists them in `extra`.
|
|
23
|
+
*/
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const guard = require('./pm-guard.js');
|
|
27
|
+
|
|
28
|
+
// the checkout's scripts/onboarding/, or the copy an installed package vendors under lib/vendor/ (import-overlay onboardingDir)
|
|
29
|
+
const obDir = () => { const d = require('./import-overlay.js').onboardingDir(); if (!d) throw new Error('the onboarding modules are not installed'); return d; };
|
|
30
|
+
const ob = (name) => require(path.join(obDir(), name));
|
|
31
|
+
const stateRootOf = (ctx) => (ctx && (ctx.runtimeDir || ctx.stateRoot)) || require('./state-root.js').defaultRoot();
|
|
32
|
+
const overlayOf = (ctx, w) => require('./import-overlay.js').overlayDir(stateRootOf(ctx), w.id);
|
|
33
|
+
const today = () => new Date().toISOString().slice(0, 10);
|
|
34
|
+
const bad = (status, error) => ({ status, body: { ok: false, error } });
|
|
35
|
+
const len = (v) => (Array.isArray(v) ? v.length : Number(v) || 0);
|
|
36
|
+
|
|
37
|
+
/** pm:<slug> from the press becomes human:<slug> for the onboarding ledger. Null for anything else. */
|
|
38
|
+
function humanOf(actor) {
|
|
39
|
+
const by = actor && typeof actor.by === 'string' ? actor.by : '';
|
|
40
|
+
return /^pm:[a-z0-9_-]{1,40}$/.test(by) ? { by: 'human:' + by.slice(3), via: actor.via || 'ui' } : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function wsOf(ctx, id) {
|
|
44
|
+
if (typeof id !== 'string' || !id) return null;
|
|
45
|
+
const w = ctx.getWs(id);
|
|
46
|
+
return w && w.id === id ? w : null;
|
|
47
|
+
}
|
|
48
|
+
const readJsonFile = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } };
|
|
49
|
+
|
|
50
|
+
function loadScan(dir) {
|
|
51
|
+
const scan = readJsonFile(path.join(dir, 'scan.json'));
|
|
52
|
+
return scan ? { scan, brief: readJsonFile(path.join(dir, 'brief.json')) } : null;
|
|
53
|
+
}
|
|
54
|
+
function imported(dir) {
|
|
55
|
+
const r = ob('apply.js').importedFor(dir) || {};
|
|
56
|
+
const imports = Array.isArray(r.imports) ? r.imports : [];
|
|
57
|
+
return { boards: len(r.boards), initiatives: len(r.initiatives), imports, live: imports.some((i) => !i.retracted) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function status(ctx, w) {
|
|
61
|
+
const dir = overlayOf(ctx, w);
|
|
62
|
+
const have = loadScan(dir);
|
|
63
|
+
const imp = have ? imported(dir) : { boards: 0, initiatives: 0, imports: [], live: false };
|
|
64
|
+
const state = !have ? 'new' : imp.live ? 'imported' : 'scanned';
|
|
65
|
+
// first_run: `pmos` opens Welcome on its own only for a plain folder (no PMOS state folder of its own) that is not imported and
|
|
66
|
+
// where the PM did not choose "Not now". A PMOS repository, or a kit tenant, opens the console as before; `pmos welcome` still works.
|
|
67
|
+
let ownState = false; try { ownState = typeof ctx.stateDirFor === 'function' && !!ctx.stateDirFor(w); } catch { ownState = false; }
|
|
68
|
+
const dismissed = fs.existsSync(path.join(dir, 'dismissed.json'));
|
|
69
|
+
const first_run = state !== 'imported' && !dismissed && !ownState;
|
|
70
|
+
return { status: 200, body: { ok: true, state, first_run, dismissed, scanning: running.has(w.id), scan_id: have ? have.scan.scan_id : null, imported: { boards: imp.boards, initiatives: imp.initiatives }, imports: imp.imports } };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* The scan runs in a child process (scripts/onboarding/scan-worker.js): a large folder takes a minute or more, and the server
|
|
74
|
+
* holds the PM's terminals, so it must never wait on it. A second press for the same workspace joins the running scan. */
|
|
75
|
+
const running = new Map();
|
|
76
|
+
function scanOne(ctx, w, now) {
|
|
77
|
+
if (running.has(w.id)) return running.get(w.id);
|
|
78
|
+
const dir = overlayOf(ctx, w);
|
|
79
|
+
const p = new Promise((resolve, reject) => {
|
|
80
|
+
const cp = require('child_process');
|
|
81
|
+
const child = cp.spawn(process.execPath, [path.join(obDir(), 'scan-worker.js'), w.root, dir, now], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
82
|
+
let out = ''; let err = '';
|
|
83
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
84
|
+
child.stderr.on('data', (d) => { err += d; });
|
|
85
|
+
child.on('error', reject);
|
|
86
|
+
child.on('close', (code) => {
|
|
87
|
+
if (code !== 0) return reject(new Error(err.trim() || 'the scan stopped with code ' + code));
|
|
88
|
+
const look = readJsonFile(path.join(dir, 'firstlook.json'));
|
|
89
|
+
let id = null; try { id = JSON.parse(out.trim().split('\n').pop()).scan_id; } catch { /* read below */ }
|
|
90
|
+
resolve({ scan_id: id || (readJsonFile(path.join(dir, 'scan.json')) || {}).scan_id, first_look: look });
|
|
91
|
+
});
|
|
92
|
+
}).finally(() => running.delete(w.id));
|
|
93
|
+
running.set(w.id, p);
|
|
94
|
+
return p;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function scanRoute(ctx, w, body) {
|
|
98
|
+
const extra = body.extra_roots == null ? [] : body.extra_roots;
|
|
99
|
+
if (!Array.isArray(extra) || extra.some((x) => typeof x !== 'string')) return bad(400, 'extra_roots must be a list of folder paths');
|
|
100
|
+
const known = typeof ctx.workspaceList === 'function' ? ctx.workspaceList() : [];
|
|
101
|
+
const extras = [];
|
|
102
|
+
for (const r of extra) {
|
|
103
|
+
const root = path.resolve(r);
|
|
104
|
+
const hit = known.find((k) => k && k.root && path.resolve(k.root) === root);
|
|
105
|
+
if (!hit) return bad(400, 'extra_roots may name registered workspaces only: ' + r);
|
|
106
|
+
if (hit.id !== w.id) extras.push(hit);
|
|
107
|
+
}
|
|
108
|
+
const now = today();
|
|
109
|
+
let out;
|
|
110
|
+
try { out = await scanOne(ctx, w, now); } catch (e) { return bad(500, 'the scan failed: ' + String((e && e.message) || e).slice(0, 200)); }
|
|
111
|
+
out.extra = [];
|
|
112
|
+
for (const x of extras) {
|
|
113
|
+
try { out.extra.push({ ws: x.id, scan_id: (await scanOne(ctx, x, now)).scan_id }); } catch (e) { out.extra.push({ ws: x.id, error: String((e && e.message) || e).slice(0, 120) }); }
|
|
114
|
+
}
|
|
115
|
+
return { status: 200, body: { ok: true, ...out } };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function structureOf(dir) {
|
|
119
|
+
const have = loadScan(dir);
|
|
120
|
+
if (!have) return null;
|
|
121
|
+
return ob('structure.js').structure(have.scan, { brief: have.brief });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function structureRoute(ctx, w) {
|
|
125
|
+
const dir = overlayOf(ctx, w);
|
|
126
|
+
let s;
|
|
127
|
+
try { s = structureOf(dir); } catch (e) { return bad(500, 'the structure could not be built: ' + String((e && e.message) || e).slice(0, 200)); }
|
|
128
|
+
if (!s) return bad(409, 'scan first: there is no scan for this folder yet');
|
|
129
|
+
return { status: 200, body: { ok: true, ...s } };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function applyRoute(ctx, w, body, actor) {
|
|
133
|
+
const dir = overlayOf(ctx, w);
|
|
134
|
+
let s;
|
|
135
|
+
try { s = structureOf(dir); } catch (e) { return bad(500, 'the structure could not be built: ' + String((e && e.message) || e).slice(0, 200)); }
|
|
136
|
+
if (!s) return bad(409, 'scan first: there is no scan for this folder yet');
|
|
137
|
+
const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
|
|
138
|
+
if (body.include != null && (!Array.isArray(body.include) || body.include.some((x) => typeof x !== 'string'))) return bad(400, 'include must be a list of proposal ids');
|
|
139
|
+
if (body.revisions != null && !isObj(body.revisions)) return bad(400, 'revisions must be an object keyed by proposal id');
|
|
140
|
+
if (body.answers != null && !isObj(body.answers)) return bad(400, 'answers must be an object keyed by question id');
|
|
141
|
+
const ids = new Set(s.boards.flatMap((b) => b.initiatives.map((r) => r.proposal_id)));
|
|
142
|
+
const unknown = (body.include || []).find((x) => !ids.has(x));
|
|
143
|
+
if (unknown) return bad(400, 'include names an unknown proposal: ' + unknown);
|
|
144
|
+
const include = body.include == null ? s.boards.flatMap((b) => b.initiatives.filter((r) => r.checked).map((r) => r.proposal_id)) : body.include;
|
|
145
|
+
try {
|
|
146
|
+
const r = ob('apply.js').apply(dir, s, { include, revisions: body.revisions || {}, answers: body.answers || {}, actor, now: new Date().toISOString(), root: w.root });
|
|
147
|
+
return { status: 200, body: { ok: true, import_id: r.import_id, boards: len(r.boards), initiatives: len(r.initiatives) } };
|
|
148
|
+
} catch (e) { return bad(400, String((e && e.message) || e).slice(0, 240)); }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function undoRoute(ctx, w, body, actor) {
|
|
152
|
+
const dir = overlayOf(ctx, w);
|
|
153
|
+
if (typeof body.import_id !== 'string' || !body.import_id) return bad(400, 'import_id is required');
|
|
154
|
+
const hit = imported(dir).imports.find((i) => i.import_id === body.import_id);
|
|
155
|
+
if (!hit) return bad(404, 'no such import');
|
|
156
|
+
if (hit.retracted) return bad(409, 'this import is already undone');
|
|
157
|
+
try {
|
|
158
|
+
const r = ob('apply.js').undo(dir, body.import_id, { actor, now: new Date().toISOString() });
|
|
159
|
+
return { status: 200, body: { ok: true, retracted: len(r.retracted) } };
|
|
160
|
+
} catch (e) { return bad(400, String((e && e.message) || e).slice(0, 240)); }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function briefingRoute(ctx, w) {
|
|
164
|
+
const dir = overlayOf(ctx, w);
|
|
165
|
+
const have = loadScan(dir);
|
|
166
|
+
if (!have) return bad(409, 'scan first: there is no scan for this folder yet');
|
|
167
|
+
const imp = imported(dir);
|
|
168
|
+
if (!imp.live) return bad(409, 'nothing is imported yet');
|
|
169
|
+
try {
|
|
170
|
+
const r = ob('first-briefing.js').firstBriefing(have.scan, ob('apply.js').importedFor(dir), { today: today() });
|
|
171
|
+
return { status: 200, body: { ok: true, items: r.items || [] } };
|
|
172
|
+
} catch (e) { return bad(500, 'the briefing could not be built: ' + String((e && e.message) || e).slice(0, 200)); }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** "Not now": Welcome stops opening on its own for this folder. One file in the overlay; `pmos welcome` still opens it. */
|
|
176
|
+
function dismissRoute(ctx, w, body, actor) {
|
|
177
|
+
const dir = overlayOf(ctx, w);
|
|
178
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
179
|
+
fs.writeFileSync(path.join(dir, 'dismissed.json'), JSON.stringify({ at: new Date().toISOString(), by: actor.by }) + '\n');
|
|
180
|
+
return { status: 200, body: { ok: true } };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const GETS = { status: status, structure: structureRoute, briefing: briefingRoute };
|
|
184
|
+
const POSTS = { scan: scanRoute, apply: applyRoute, undo: undoRoute, dismiss: dismissRoute };
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
prefixes: ['/api/onboarding'],
|
|
188
|
+
humanOf, status, dismissRoute, scanRoute, structureRoute, applyRoute, undoRoute, briefingRoute,
|
|
189
|
+
handle: async (req, res, url, ctx) => {
|
|
190
|
+
const m = url.pathname.match(/^\/api\/onboarding\/([a-z]+)$/);
|
|
191
|
+
if (!m) return false;
|
|
192
|
+
const verb = m[1];
|
|
193
|
+
const isGet = Object.prototype.hasOwnProperty.call(GETS, verb);
|
|
194
|
+
const isPost = Object.prototype.hasOwnProperty.call(POSTS, verb);
|
|
195
|
+
if (!isGet && !isPost) return false;
|
|
196
|
+
const send = (r) => { ctx.json(res, r.body, r.status); return true; };
|
|
197
|
+
if (isGet) {
|
|
198
|
+
if (req.method !== 'GET') return send(bad(405, 'GET only'));
|
|
199
|
+
const w = wsOf(ctx, url.searchParams.get('ws'));
|
|
200
|
+
if (!w) return send(bad(404, 'no such workspace'));
|
|
201
|
+
return send(GETS[verb](ctx, w));
|
|
202
|
+
}
|
|
203
|
+
if (req.method !== 'POST') return send(bad(405, 'POST only'));
|
|
204
|
+
const got = await ctx.readJson(req);
|
|
205
|
+
if (!got.ok) return send(bad(400, 'invalid JSON body'));
|
|
206
|
+
const body = got.body || {};
|
|
207
|
+
const g = guard.requirePagePress(req, body);
|
|
208
|
+
if (!g.ok) return send(bad(g.status, g.error));
|
|
209
|
+
const w = wsOf(ctx, body.ws);
|
|
210
|
+
if (!w) return send(bad(404, 'no such workspace'));
|
|
211
|
+
const actor = humanOf(g.actor);
|
|
212
|
+
if (!actor) return send(bad(403, 'only the PM can do this, from the page'));
|
|
213
|
+
return send(await POSTS[verb](ctx, w, body, actor));
|
|
214
|
+
},
|
|
215
|
+
};
|