create-pmos 0.52.0 → 0.53.1
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 +2 -2
- package/payload/mods/pmos-cockpit/README.md +13 -0
- package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +2 -1
- package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +41 -11
- package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +228 -8
- package/payload/mods/pmos-cockpit/hooks/register.tsx +11 -3
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/plan-items-stage.js +271 -0
- package/payload/scripts/pmos-log.sh +18 -7
- package/payload/scripts/workflow-state.js +28 -2
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/README.md +2 -0
- package/workspace/REMOTE.md +24 -63
- package/workspace/bin/pmos.js +19 -3
- package/workspace/cut-stub.js +40 -0
- package/workspace/first-paint-audit.js +145 -0
- package/workspace/fixtures/capture/requirements/capture-2026-10-04-1.md +10 -0
- package/workspace/fixtures/initiatives-api/list.json +176 -0
- package/workspace/fixtures/initiatives-api/post-403.json +5 -0
- package/workspace/fixtures/initiatives-api/post-ok.json +10 -0
- package/workspace/fixtures/initiatives-api/roadmap.json +208 -0
- package/workspace/fixtures/initiatives-api/sheet.json +112 -0
- package/workspace/fixtures/report-api/draft.json +177 -0
- package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +97 -0
- package/workspace/fixtures/today-api/provider-rows.json +204 -0
- package/workspace/fixtures/today-api/seam-probe-kinds.json +5 -0
- package/workspace/guide/board.md +81 -0
- package/workspace/guide/breakdown.md +154 -0
- package/workspace/guide/command-palette.md +75 -0
- package/workspace/guide/index.md +28 -17
- package/workspace/guide/initiatives-roadmap.md +130 -0
- package/workspace/guide/initiatives-screen.md +89 -0
- package/workspace/guide/initiatives-timeline.md +57 -0
- package/workspace/guide/integrated-work.md +94 -0
- package/workspace/guide/knowledge-graph.md +3 -68
- package/workspace/guide/launcher.md +51 -0
- package/workspace/guide/layout.md +102 -32
- package/workspace/guide/live-notes.md +3 -3
- package/workspace/guide/pm-only-acts.md +89 -0
- package/workspace/guide/pm-only-lines.md +61 -0
- package/workspace/guide/proactive-engine.md +21 -5
- package/workspace/guide/reports-page.md +80 -0
- package/workspace/guide/reports.md +73 -0
- package/workspace/guide/timeline.md +70 -0
- package/workspace/guide/today.md +85 -0
- package/workspace/guide/validate-with-typesafe.md +1 -1
- package/workspace/lib/actor.js +91 -0
- package/workspace/lib/board-cache.js +32 -2
- package/workspace/lib/board-cli.js +108 -0
- package/workspace/lib/board-fold.js +232 -0
- package/workspace/lib/breakdown-export.js +75 -0
- package/workspace/lib/brief.js +20 -2
- package/workspace/lib/capture.js +98 -0
- package/workspace/lib/engine/plan-items-stage.js +271 -0
- package/workspace/lib/engine/workflow-state.js +28 -2
- package/workspace/lib/factory-join.js +158 -0
- package/workspace/lib/goals.js +165 -0
- package/workspace/lib/grant-mint.js +113 -0
- package/workspace/lib/health-rules-factory.js +92 -0
- package/workspace/lib/health-rules-plan.js +164 -0
- package/workspace/lib/health.js +79 -0
- package/workspace/lib/initiative-cli.js +222 -0
- package/workspace/lib/initiative-display.js +183 -0
- package/workspace/lib/initiative-needs.js +127 -0
- package/workspace/lib/initiative-rows.js +210 -0
- package/workspace/lib/initiative-writer.js +141 -0
- package/workspace/lib/issues.js +206 -0
- package/workspace/lib/item-cli.js +195 -0
- package/workspace/lib/item-spans.js +137 -0
- package/workspace/lib/launcher-cli.js +207 -0
- package/workspace/lib/measure-cli.js +100 -0
- package/workspace/lib/measurements.js +189 -0
- package/workspace/lib/mobile-nav.js +57 -53
- package/workspace/lib/needs-providers.js +120 -0
- package/workspace/lib/placements.js +440 -0
- package/workspace/lib/plan-items.js +922 -0
- package/workspace/lib/plan-ledgers.js +85 -0
- package/workspace/lib/plan-rules.js +72 -0
- package/workspace/lib/plan-tree.js +380 -0
- package/workspace/lib/pm-guard.js +174 -0
- package/workspace/lib/pm-lines-needs.js +331 -0
- package/workspace/lib/pmos-verbs.js +69 -0
- package/workspace/lib/proactive-cli.js +58 -2
- package/workspace/lib/proactive.js +9 -6
- package/workspace/lib/qr.js +293 -0
- package/workspace/lib/remote-access.js +93 -1
- package/workspace/lib/report-cli.js +152 -0
- package/workspace/lib/report-draft.js +578 -0
- package/workspace/lib/report-needs.js +213 -0
- package/workspace/lib/route-modules.js +128 -0
- package/workspace/lib/routes-board.js +91 -0
- package/workspace/lib/routes-capture.js +52 -0
- package/workspace/lib/routes-initiatives.js +371 -0
- package/workspace/lib/routes-reports.js +163 -0
- package/workspace/lib/routes-timeline.js +197 -0
- package/workspace/lib/routes-today.js +32 -0
- package/workspace/lib/today-cli.js +99 -0
- package/workspace/lib/today.js +333 -0
- package/workspace/lib/triage.js +163 -0
- package/workspace/lib/verify-work-cli.js +81 -0
- package/workspace/lib/work-check-needs.js +99 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +4 -5
- package/workspace/public/icons/apple-touch-icon.png +0 -0
- package/workspace/public/icons/icon-192.png +0 -0
- package/workspace/public/icons/icon-512.png +0 -0
- package/workspace/public/index.html +40 -19
- package/workspace/public/pm/app.js +330 -30
- package/workspace/public/pm/bd-board.js +113 -0
- package/workspace/public/pm/bd-drag.js +59 -0
- package/workspace/public/pm/bd-model.js +152 -0
- package/workspace/public/pm/bd-timeline.js +233 -0
- package/workspace/public/pm/bd-tree.js +244 -0
- package/workspace/public/pm/board-copy.js +57 -0
- package/workspace/public/pm/breakdown-api.js +58 -0
- package/workspace/public/pm/breakdown-copy.js +138 -0
- package/workspace/public/pm/components.css +3135 -0
- package/workspace/public/pm/cycle-review.js +125 -0
- package/workspace/public/pm/data.js +40 -187
- package/workspace/public/pm/dest-board.js +438 -0
- package/workspace/public/pm/dest-initiatives.js +134 -0
- package/workspace/public/pm/dest-reports.js +270 -0
- package/workspace/public/pm/dest-settings.js +217 -0
- package/workspace/public/pm/dest-today.js +182 -0
- package/workspace/public/pm/dialogs.js +88 -0
- package/workspace/public/pm/display-word.js +56 -0
- package/workspace/public/pm/dock.js +175 -0
- package/workspace/public/pm/fixture.js +8 -227
- package/workspace/public/pm/frame-copy.js +99 -0
- package/workspace/public/pm/handoff-box.js +82 -0
- package/workspace/public/pm/http.js +88 -0
- package/workspace/public/pm/icons.js +53 -0
- package/workspace/public/pm/initiative-list.js +218 -0
- package/workspace/public/pm/initiative-sheet.js +261 -0
- package/workspace/public/pm/initiatives-api.js +59 -0
- package/workspace/public/pm/initiatives-copy.js +215 -0
- package/workspace/public/pm/initiatives-timeline-copy.js +59 -0
- package/workspace/public/pm/initiatives-timeline.js +410 -0
- package/workspace/public/pm/initiatives-ui.js +123 -0
- package/workspace/public/pm/item-sheet.js +251 -0
- package/workspace/public/pm/keep-card.js +204 -0
- package/workspace/public/pm/lifecycle.js +7 -1
- package/workspace/public/pm/mobile.css +49 -20
- package/workspace/public/pm/mobile.js +157 -29
- package/workspace/public/pm/modes-live.js +7 -883
- package/workspace/public/pm/modes.js +32 -1024
- package/workspace/public/pm/new-initiative-sheet.js +74 -0
- package/workspace/public/pm/next-step-ui.js +3 -2
- package/workspace/public/pm/palette-copy.js +51 -0
- package/workspace/public/pm/palette.js +312 -0
- package/workspace/public/pm/panel-breakdown.js +702 -0
- package/workspace/public/pm/panel-cockpit.js +63 -295
- package/workspace/public/pm/panel-detail.js +25 -172
- package/workspace/public/pm/panel-initiatives.js +1 -19
- package/workspace/public/pm/panel-repos.js +31 -81
- package/workspace/public/pm/panel-tabs.js +200 -111
- package/workspace/public/pm/panel-trees.js +5 -2
- package/workspace/public/pm/portfolio-report.js +61 -0
- package/workspace/public/pm/primitives.js +656 -0
- package/workspace/public/pm/print.css +33 -0
- package/workspace/public/pm/provider-cards.js +136 -0
- package/workspace/public/pm/registry.js +194 -0
- package/workspace/public/pm/release-card.js +110 -0
- package/workspace/public/pm/report-view.js +309 -0
- package/workspace/public/pm/reports-copy.js +114 -0
- package/workspace/public/pm/roadmap.js +271 -0
- package/workspace/public/pm/sig-watch.js +41 -0
- package/workspace/public/pm/state.js +8 -4
- package/workspace/public/pm/status-banner.js +13 -5
- package/workspace/public/pm/tab-doc.js +2 -4
- package/workspace/public/pm/tab-term.js +12 -2
- package/workspace/public/pm/tab-web.js +3 -2
- package/workspace/public/pm/timeline-copy.js +48 -0
- package/workspace/public/pm/today-cards.js +244 -0
- package/workspace/public/pm/today-copy.js +126 -0
- package/workspace/public/pm/today-goals.js +172 -0
- package/workspace/public/pm/tokens.css +16 -18
- package/workspace/public/pm/topbar.js +102 -0
- package/workspace/public/pm/triage-card.js +60 -0
- package/workspace/public/pm/undo-hold.js +103 -0
- package/workspace/public/pm/view-addrepo.js +2 -1
- package/workspace/public/pm/view-livenotes.js +3 -1
- package/workspace/public/pm/view-today.js +7 -162
- package/workspace/public/vendor/Sortable.min.js +2 -0
- package/workspace/public/vendor/lucide-sprite.js +79 -0
- package/workspace/replaced-tests.json +2877 -0
- package/workspace/run-tests.js +56 -5
- package/workspace/server.js +446 -40
- package/workspace/sheet-answer.js +61 -0
- package/workspace/skills/measure.skill +41 -0
- package/workspace/skills/morning-brief.skill +15 -9
- package/workspace/skills/report-biweekly.skill +23 -25
- package/workspace/skills/tracker-refresh.skill +50 -0
- package/workspace/test-files.json +21 -3
- package/workspace/test-lists/base.json +6 -0
- package/workspace/test-lists/run2-a.json +6 -0
- package/workspace/test-lists/run2-b.json +13 -0
- package/workspace/test-lists/run2-c.json +19 -0
- package/workspace/test-lists/run2-d.json +22 -0
- package/workspace/test-lists/run3-0.json +5 -0
- package/workspace/test-lists/run3-a.json +5 -0
- package/workspace/test-lists/run3-b.json +6 -0
- package/workspace/test-lists/run3-c.json +15 -0
- package/workspace/test-lists/run3-d.json +6 -0
- package/workspace/test-lists/run4-a.json +8 -0
- package/workspace/test-lists/run4-b.json +6 -0
- package/workspace/test-lists/run4-c.json +13 -0
- package/workspace/test-lists/run4-d.json +9 -0
- package/workspace/test-lists/run5-a.json +6 -0
- package/workspace/test-lists/run5-b.json +6 -0
- package/workspace/test-lists/run5-c.json +15 -0
- package/workspace/test-lists/workspace-launcher.json +5 -0
- package/workspace/test-lists/workspace-phone-app.json +5 -0
- package/workspace/test-run.js +27 -1
- package/workspace/third-party/README.md +44 -0
- package/workspace/third-party/lucide-static-1.52.0.LICENSE +43 -0
- package/workspace/third-party/sortablejs-1.15.7.LICENSE +21 -0
- package/workspace/design-parity.js +0 -673
- package/workspace/public/pm/tab-task.js +0 -86
- package/workspace/public/pm/view-guide.js +0 -141
- package/workspace/public/pm/view-knowledge.js +0 -225
- package/workspace/public/pm/view-memory.js +0 -122
- package/workspace/public/pm/view-portfolio.js +0 -153
- package/workspace/public/pm/view-reports.js +0 -190
- package/workspace/public/pm/view-scheduled.js +0 -161
- package/workspace/public/pm/view-skills.js +0 -216
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* pmos proactive brief [show]
|
|
14
14
|
* pmos proactive brief submit --file PATH
|
|
15
15
|
* pmos proactive brief schedule [--repo PATH]
|
|
16
|
+
* pmos proactive goals [list] | add TEXT [--initiative ID] | done N | drop N | reopen N | remove N | plan [--repo PATH]
|
|
16
17
|
* pmos proactive prepare plan --initiative ID [--repo PATH]
|
|
17
18
|
* pmos proactive prepare queue --initiative ID --item ITEM --sha HASH [--repo PATH]
|
|
18
19
|
* pmos proactive prepare status | cancel ID | run
|
|
@@ -23,8 +24,8 @@ const path = require('path');
|
|
|
23
24
|
const { execFileSync } = require('child_process');
|
|
24
25
|
const { parseArgs } = require('./schedule-cli');
|
|
25
26
|
|
|
26
|
-
const COMMANDS = { proactive: ['today', 'scan', 'settings', 'feedback', 'brief', 'prepare', 'learn'] };
|
|
27
|
-
const BRIEF_SOURCES = ['calendar', 'wispr', 'mail', 'jira', 'confluence'];
|
|
27
|
+
const COMMANDS = { proactive: ['today', 'scan', 'settings', 'feedback', 'brief', 'goals', 'prepare', 'learn'] };
|
|
28
|
+
const BRIEF_SOURCES = ['calendar', 'wispr', 'mail', 'slack', 'jira', 'confluence'];
|
|
28
29
|
const onOff = (v, what) => (v === 'on' || v === 'true' ? true : v === 'off' || v === 'false' ? false : { err: `${what} takes on or off` });
|
|
29
30
|
|
|
30
31
|
async function run(argv, { call, say, cwd = process.cwd() }) {
|
|
@@ -189,6 +190,61 @@ async function run(argv, { call, say, cwd = process.cwd() }) {
|
|
|
189
190
|
for (const l of r.lessons.filter((x) => x.status === 'proposed' || x.status === 'approved')) say(`${l.id} ${l.status} ${l.text}`);
|
|
190
191
|
return 0;
|
|
191
192
|
}
|
|
193
|
+
if (verb === 'goals') {
|
|
194
|
+
const sub = pos[0] || 'list';
|
|
195
|
+
const usage = 'usage: pmos proactive goals [list] | add TEXT [--initiative ID] | done N | drop N | reopen N | remove N | plan [--repo PATH]';
|
|
196
|
+
const wsOfRepo = async () => {
|
|
197
|
+
let root; try { root = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: flags.repo ? path.resolve(cwd, flags.repo) : cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; }
|
|
198
|
+
const wl = await call('GET', '/api/workspaces');
|
|
199
|
+
const hit = ((wl && wl.workspaces) || []).find((w) => path.resolve(w.root) === path.resolve(root));
|
|
200
|
+
return hit ? hit.id : null;
|
|
201
|
+
};
|
|
202
|
+
const get = async () => { const ws = await wsOfRepo(); return call('GET', '/api/proactive/goals' + (ws ? '?ws=' + encodeURIComponent(ws) : '')); };
|
|
203
|
+
if (sub === 'add') {
|
|
204
|
+
const text = pos.slice(1).join(' ');
|
|
205
|
+
if (!text.trim()) return fail(usage);
|
|
206
|
+
const r = await call('POST', '/api/proactive/goals', { action: 'add', text, ...(flags.initiative ? { initiative_id: flags.initiative } : {}) });
|
|
207
|
+
if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}. Nothing changed.`);
|
|
208
|
+
say(`added goal ${r.day.goals.length}: ${r.goal.text}${r.goal.initiative_id ? ' · ' + r.goal.initiative_id : ''}`);
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
if (['done', 'drop', 'reopen', 'remove'].includes(sub)) {
|
|
212
|
+
const t = await get();
|
|
213
|
+
if (!t || t.ok === false) return fail((t && t.error) || 'the server did not answer');
|
|
214
|
+
const ref = pos[1];
|
|
215
|
+
const g = /^\d+$/.test(String(ref)) ? t.plan.goals[Number(ref) - 1] : t.plan.goals.find((x) => x.id === ref);
|
|
216
|
+
if (!g) return fail(`no goal ${ref || ''} today — ${usage}`);
|
|
217
|
+
const r = await call('POST', '/api/proactive/goals', { action: sub, id: g.id });
|
|
218
|
+
if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}. Nothing changed.`);
|
|
219
|
+
say(`${sub === 'remove' ? 'removed' : sub === 'reopen' ? 'reopened' : sub === 'drop' ? 'dropped' : 'done'}: ${g.text}`);
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
if (sub === 'plan') {
|
|
223
|
+
const ws = await wsOfRepo();
|
|
224
|
+
if (!ws) return fail('this is not inside a registered workspace — open it with pmos first, or use --repo PATH');
|
|
225
|
+
const r = await call('POST', '/api/proactive/goals/plan', { ws });
|
|
226
|
+
if (!r || r.ok === false) return fail(`not started — ${(r && r.error) || 'the server did not answer'}`);
|
|
227
|
+
say(`${r.seeded ? `scheduled the morning brief daily at ${r.schedule.time}, and ` : ''}started a morning-brief session to plan ${r.goals} goal(s). The plan shows when it submits the brief.`);
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
230
|
+
if (sub !== 'list') return fail(usage);
|
|
231
|
+
const t = await get();
|
|
232
|
+
if (!t || t.ok === false) return fail((t && t.error) || 'the server did not answer');
|
|
233
|
+
const pl = t.plan;
|
|
234
|
+
if (!pl.goals.length) say(`No goals for ${t.today}. Add one with: pmos proactive goals add "…"`);
|
|
235
|
+
pl.goals.forEach((g, i) => {
|
|
236
|
+
say(`${i + 1}. ${g.id} [${g.coverage}] ${g.text}${g.initiative_id ? ' · ' + g.initiative_id : ''}`);
|
|
237
|
+
for (const e of g.context) say(` ${e.start} ${e.title}`);
|
|
238
|
+
for (const st of g.steps) say(` ${st.owner === 'pm' ? 'you ' : 'agent'} ${st.title} (${st.source}${st.mention ? ', mention' : ''}${st.prepare ? ', can prepare' : ''})`);
|
|
239
|
+
});
|
|
240
|
+
if (pl.also.length) { say('Also on your plate:'); for (const st of pl.also) say(` ${st.owner === 'pm' ? 'you ' : 'agent'} ${st.title} (${st.source})`); }
|
|
241
|
+
if (pl.brief && !pl.brief.fresh) say(`The brief is for ${pl.brief.date}, not today: it is not used. Run: pmos proactive goals plan`);
|
|
242
|
+
if (!pl.brief) say('No brief yet, so the plan uses only what Today derived. Run: pmos proactive goals plan');
|
|
243
|
+
if (pl.time) say(`Free until ${pl.time.until}: ${pl.time.free_minutes} min${pl.time.next ? ` · next: ${pl.time.next.title} at ${pl.time.next.start}` : ''}`);
|
|
244
|
+
if (pl.not_read.length) say('not read: ' + pl.not_read.map((x) => `${x.source} (${x.state})`).join(', '));
|
|
245
|
+
if (t.cards_error) say('! Today could not be read: ' + t.cards_error);
|
|
246
|
+
return 0;
|
|
247
|
+
}
|
|
192
248
|
if (verb === 'brief') {
|
|
193
249
|
const sub = pos[0] || 'show';
|
|
194
250
|
if (sub === 'submit') {
|
|
@@ -29,9 +29,9 @@ const DISMISS_MS = 7 * DAY;
|
|
|
29
29
|
const BASE = { needs: 100, returned: 100, review: 80, schedule: 60, stalled: 40 };
|
|
30
30
|
const OWNER = { needs: 'pm', returned: 'pm', review: 'pm', schedule: 'pm', stalled: 'agent' };
|
|
31
31
|
|
|
32
|
-
const BRIEF_SOURCES = ['calendar', 'wispr', 'mail', 'jira', 'confluence'];
|
|
32
|
+
const BRIEF_SOURCES = ['calendar', 'wispr', 'mail', 'slack', 'jira', 'confluence'];
|
|
33
33
|
// Mail is off by default: a brief reads titles and metadata of the other sources, and the PM turns mail on.
|
|
34
|
-
const BRIEF_DEFAULT = Object.freeze({ calendar: true, wispr: true, mail: false, jira: true, confluence: true });
|
|
34
|
+
const BRIEF_DEFAULT = Object.freeze({ calendar: true, wispr: true, mail: false, slack: true, jira: true, confluence: true });
|
|
35
35
|
const DEFAULTS = Object.freeze({ name: '', briefSources: BRIEF_DEFAULT, briefScoring: false, backgroundPrepare: false, enabled: true, triggers: Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t, true]))), stalledDays: 5, quiet: null, pauseLearning: false });
|
|
36
36
|
|
|
37
37
|
const idOf = (key) => crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 12);
|
|
@@ -46,7 +46,7 @@ function cand(kind, key, p) {
|
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* @param {{now:number, settings:object, repos:Array, nextStepRuns:Array}} ctx
|
|
49
|
-
* repos[]: {name, ws, error?, initiatives:[{id,title,active,lane,lastRun,hasRuns}], needs:[{initiative_id,question,run_id}],
|
|
49
|
+
* repos[]: {name, ws, error?, stall_days?, initiatives:[{id,title,active,lane,lastRun,hasRuns}], needs:[{initiative_id,question,run_id}],
|
|
50
50
|
* returned:[{initiative_id,note,at}], schedules:[{id,name,lastStatus,lastRun}]}
|
|
51
51
|
* nextStepRuns[]: {session_id, initiative_id, ws, key, ended, reviewed, at}
|
|
52
52
|
* @returns {{candidates:Array, unreadable:Array<{repo:string,error:string}>}}
|
|
@@ -86,7 +86,10 @@ function detect(ctx) {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
if (on('stalled')) {
|
|
89
|
-
|
|
89
|
+
// `stall_days` of the repository's plan-rules.json (lib/plan-rules.js readRules) wins when the caller passes it on the
|
|
90
|
+
// repo entry; otherwise the engine's own setting, whose default (5) is that rule's default. This file stays pure: it requires only crypto.
|
|
91
|
+
const stallDays = r.stall_days || st.stalledDays || DEFAULTS.stalledDays;
|
|
92
|
+
const limit = stallDays * DAY;
|
|
90
93
|
for (const i of r.initiatives || []) {
|
|
91
94
|
if (!i.active || !i.hasRuns || i.lastRun == null) continue;
|
|
92
95
|
const idle = now - i.lastRun;
|
|
@@ -94,8 +97,8 @@ function detect(ctx) {
|
|
|
94
97
|
const d = days(idle);
|
|
95
98
|
out.push(cand('stalled', `stalled:${r.ws}:${i.id}`, {
|
|
96
99
|
initiative_id: i.id, ws: r.ws, repo: r.name, lane: i.lane, title: i.title || i.id,
|
|
97
|
-
why: `In progress, with no agent run for ${d} days.`, evidence: `last run ${new Date(i.lastRun).toISOString().slice(0, 10)}; the threshold is ${
|
|
98
|
-
confidence: Math.min(0.85, +(0.5 + 0.05 * (d -
|
|
100
|
+
why: `In progress, with no agent run for ${d} days.`, evidence: `last run ${new Date(i.lastRun).toISOString().slice(0, 10)}; the threshold is ${stallDays} days`,
|
|
101
|
+
confidence: Math.min(0.85, +(0.5 + 0.05 * (d - stallDays)).toFixed(2)), since: i.lastRun,
|
|
99
102
|
open: { type: 'initiative', initiative_id: i.id, ws: r.ws } }));
|
|
100
103
|
}
|
|
101
104
|
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* qr — a QR code encoder small enough to read, for one job: the remote-mode startup banner prints
|
|
4
|
+
* the phone URL as a code the phone camera can scan (workspace-phone-app). Typing
|
|
5
|
+
* http://100.x.y.z:7433/?token=<43 characters> on a phone keyboard is the step that made the
|
|
6
|
+
* tailnet flow impractical.
|
|
7
|
+
*
|
|
8
|
+
* Why it is written here and not installed: the workspace ships offline (D62) and `create-pmos
|
|
9
|
+
* workspace` installs it with `npm install -g`, so every dependency is weight on every adopter.
|
|
10
|
+
* The encoder needs about 200 lines. test.js (QR-*) checks it against published vectors: the
|
|
11
|
+
* Reed-Solomon worked example for "HELLO WORLD" 1-M, a format string, the alignment positions and
|
|
12
|
+
* the data capacities. When it was written, every version 1 to 10 at both levels and with each of
|
|
13
|
+
* the eight masks (193 codes) was also decoded by an independent reader, zxing-cpp; that reader is
|
|
14
|
+
* not a dependency, so the suite does not repeat the check.
|
|
15
|
+
*
|
|
16
|
+
* Scope, on purpose: byte mode only, error-correction level M (or L), versions 1 to 10 (up to 213
|
|
17
|
+
* bytes at M). A URL with a token is about 80 bytes. Anything longer throws rather than producing
|
|
18
|
+
* a code that cannot scan. The structure follows ISO/IEC 18004 and Project Nayuki's reference
|
|
19
|
+
* implementation (MIT), cut down to that scope.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// Per version 1..10 (index 0 unused): error-correction codewords per block, and number of blocks.
|
|
23
|
+
const ECC = {
|
|
24
|
+
L: { bits: 1, perBlock: [0, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18], blocks: [0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4] },
|
|
25
|
+
M: { bits: 0, perBlock: [0, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26], blocks: [0, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5] },
|
|
26
|
+
};
|
|
27
|
+
const MAX_VERSION = 10;
|
|
28
|
+
|
|
29
|
+
// ── GF(256) arithmetic and Reed-Solomon, over the QR polynomial x^8+x^4+x^3+x^2+1 (0x11D) ────────
|
|
30
|
+
function gfMul(x, y) {
|
|
31
|
+
let z = 0;
|
|
32
|
+
for (let i = 7; i >= 0; i--) {
|
|
33
|
+
z = (z << 1) ^ ((z >>> 7) * 0x11d);
|
|
34
|
+
z ^= ((y >>> i) & 1) * x;
|
|
35
|
+
}
|
|
36
|
+
return z & 0xff;
|
|
37
|
+
}
|
|
38
|
+
function rsDivisor(degree) {
|
|
39
|
+
const out = new Array(degree).fill(0);
|
|
40
|
+
out[degree - 1] = 1;
|
|
41
|
+
let root = 1;
|
|
42
|
+
for (let i = 0; i < degree; i++) {
|
|
43
|
+
for (let j = 0; j < out.length; j++) {
|
|
44
|
+
out[j] = gfMul(out[j], root);
|
|
45
|
+
if (j + 1 < out.length) out[j] ^= out[j + 1];
|
|
46
|
+
}
|
|
47
|
+
root = gfMul(root, 0x02);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
function rsRemainder(data, divisor) {
|
|
52
|
+
const out = new Array(divisor.length).fill(0);
|
|
53
|
+
for (const b of data) {
|
|
54
|
+
const factor = b ^ out.shift();
|
|
55
|
+
out.push(0);
|
|
56
|
+
divisor.forEach((coef, i) => { out[i] ^= gfMul(coef, factor); });
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── sizes ────────────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
function rawDataModules(ver) {
|
|
63
|
+
let n = (16 * ver + 128) * ver + 64;
|
|
64
|
+
if (ver >= 2) {
|
|
65
|
+
const align = Math.floor(ver / 7) + 2;
|
|
66
|
+
n -= (25 * align - 10) * align - 55;
|
|
67
|
+
if (ver >= 7) n -= 36;
|
|
68
|
+
}
|
|
69
|
+
return n;
|
|
70
|
+
}
|
|
71
|
+
function dataCodewords(ver, ecl) {
|
|
72
|
+
return Math.floor(rawDataModules(ver) / 8) - ECC[ecl].perBlock[ver] * ECC[ecl].blocks[ver];
|
|
73
|
+
}
|
|
74
|
+
function alignmentPositions(ver) {
|
|
75
|
+
if (ver === 1) return [];
|
|
76
|
+
const count = Math.floor(ver / 7) + 2;
|
|
77
|
+
const step = Math.ceil((ver * 4 + 4) / (count * 2 - 2)) * 2;
|
|
78
|
+
const out = [6];
|
|
79
|
+
for (let pos = ver * 4 + 17 - 7; out.length < count; pos -= step) out.splice(1, 0, pos);
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── data codewords: byte-mode segment, terminator, padding ───────────────────────────────────
|
|
84
|
+
function dataBits(bytes, ver, ecl) {
|
|
85
|
+
const bits = [];
|
|
86
|
+
const put = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >>> i) & 1); };
|
|
87
|
+
put(0x4, 4); // byte mode
|
|
88
|
+
put(bytes.length, ver <= 9 ? 8 : 16); // character count
|
|
89
|
+
for (const b of bytes) put(b, 8);
|
|
90
|
+
const capacity = dataCodewords(ver, ecl) * 8;
|
|
91
|
+
put(0, Math.min(4, capacity - bits.length));
|
|
92
|
+
put(0, (8 - (bits.length % 8)) % 8);
|
|
93
|
+
for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11) put(pad, 8);
|
|
94
|
+
const out = [];
|
|
95
|
+
for (let i = 0; i < bits.length; i += 8) out.push(parseInt(bits.slice(i, i + 8).join(''), 2));
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function withEccInterleaved(data, ver, ecl) {
|
|
100
|
+
const numBlocks = ECC[ecl].blocks[ver];
|
|
101
|
+
const eccLen = ECC[ecl].perBlock[ver];
|
|
102
|
+
const raw = Math.floor(rawDataModules(ver) / 8);
|
|
103
|
+
const numShort = numBlocks - (raw % numBlocks);
|
|
104
|
+
const shortLen = Math.floor(raw / numBlocks);
|
|
105
|
+
const div = rsDivisor(eccLen);
|
|
106
|
+
const blocks = [];
|
|
107
|
+
for (let i = 0, k = 0; i < numBlocks; i++) {
|
|
108
|
+
const dat = data.slice(k, k + shortLen - eccLen + (i < numShort ? 0 : 1));
|
|
109
|
+
k += dat.length;
|
|
110
|
+
const ecc = rsRemainder(dat, div);
|
|
111
|
+
if (i < numShort) dat.push(0); // placeholder so every block has the same length
|
|
112
|
+
blocks.push(dat.concat(ecc));
|
|
113
|
+
}
|
|
114
|
+
const out = [];
|
|
115
|
+
for (let i = 0; i < blocks[0].length; i++) {
|
|
116
|
+
blocks.forEach((blk, j) => { if (i !== shortLen - eccLen || j >= numShort) out.push(blk[i]); });
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── the module grid ──────────────────────────────────────────────────────────────────────────
|
|
122
|
+
function formatBits(ecl, mask) {
|
|
123
|
+
const data = (ECC[ecl].bits << 3) | mask;
|
|
124
|
+
let rem = data;
|
|
125
|
+
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
|
|
126
|
+
return ((data << 10) | rem) ^ 0x5412;
|
|
127
|
+
}
|
|
128
|
+
function versionBits(ver) {
|
|
129
|
+
let rem = ver;
|
|
130
|
+
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
|
|
131
|
+
return (ver << 12) | rem;
|
|
132
|
+
}
|
|
133
|
+
const MASKS = [
|
|
134
|
+
(x, y) => (x + y) % 2 === 0,
|
|
135
|
+
(x, y) => y % 2 === 0,
|
|
136
|
+
(x) => x % 3 === 0,
|
|
137
|
+
(x, y) => (x + y) % 3 === 0,
|
|
138
|
+
(x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
|
|
139
|
+
(x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
|
|
140
|
+
(x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
|
|
141
|
+
(x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
function build(codewords, ver, ecl, mask) {
|
|
145
|
+
const size = ver * 4 + 17;
|
|
146
|
+
const mod = Array.from({ length: size }, () => new Array(size).fill(false));
|
|
147
|
+
const fn = Array.from({ length: size }, () => new Array(size).fill(false));
|
|
148
|
+
const set = (x, y, dark) => { mod[y][x] = dark; fn[y][x] = true; };
|
|
149
|
+
const bit = (v, i) => ((v >>> i) & 1) !== 0;
|
|
150
|
+
|
|
151
|
+
for (let i = 0; i < size; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
|
|
152
|
+
for (const [cx, cy] of [[3, 3], [size - 4, 3], [3, size - 4]]) {
|
|
153
|
+
for (let dy = -4; dy <= 4; dy++) for (let dx = -4; dx <= 4; dx++) {
|
|
154
|
+
const d = Math.max(Math.abs(dx), Math.abs(dy));
|
|
155
|
+
const x = cx + dx; const y = cy + dy;
|
|
156
|
+
if (x >= 0 && x < size && y >= 0 && y < size) set(x, y, d !== 2 && d !== 4);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const al = alignmentPositions(ver);
|
|
160
|
+
for (let i = 0; i < al.length; i++) for (let j = 0; j < al.length; j++) {
|
|
161
|
+
if ((i === 0 && j === 0) || (i === 0 && j === al.length - 1) || (i === al.length - 1 && j === 0)) continue;
|
|
162
|
+
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {
|
|
163
|
+
set(al[i] + dx, al[j] + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const drawFormat = (m) => {
|
|
167
|
+
const f = formatBits(ecl, m);
|
|
168
|
+
for (let i = 0; i <= 5; i++) set(8, i, bit(f, i));
|
|
169
|
+
set(8, 7, bit(f, 6)); set(8, 8, bit(f, 7)); set(7, 8, bit(f, 8));
|
|
170
|
+
for (let i = 9; i < 15; i++) set(14 - i, 8, bit(f, i));
|
|
171
|
+
for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(f, i));
|
|
172
|
+
for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(f, i));
|
|
173
|
+
set(8, size - 8, true); // the always-dark module
|
|
174
|
+
};
|
|
175
|
+
drawFormat(0); // reserve the area; redrawn with the real mask below
|
|
176
|
+
if (ver >= 7) {
|
|
177
|
+
const v = versionBits(ver);
|
|
178
|
+
for (let i = 0; i < 18; i++) {
|
|
179
|
+
const a = size - 11 + (i % 3); const b = Math.floor(i / 3);
|
|
180
|
+
set(a, b, bit(v, i)); set(b, a, bit(v, i));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Codewords in the two-column zigzag, right to left, skipping the vertical timing column.
|
|
185
|
+
let i = 0;
|
|
186
|
+
for (let right = size - 1; right >= 1; right -= 2) {
|
|
187
|
+
if (right === 6) right = 5;
|
|
188
|
+
for (let vert = 0; vert < size; vert++) for (let j = 0; j < 2; j++) {
|
|
189
|
+
const x = right - j;
|
|
190
|
+
const y = ((right + 1) & 2) === 0 ? size - 1 - vert : vert;
|
|
191
|
+
if (!fn[y][x] && i < codewords.length * 8) {
|
|
192
|
+
mod[y][x] = bit(codewords[i >>> 3], 7 - (i & 7));
|
|
193
|
+
i++;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
|
|
198
|
+
if (!fn[y][x] && MASKS[mask](x, y)) mod[y][x] = !mod[y][x];
|
|
199
|
+
}
|
|
200
|
+
drawFormat(mask);
|
|
201
|
+
return mod;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// The standard's four penalty rules, so the mask chosen is the one a camera reads most easily.
|
|
205
|
+
function penalty(mod) {
|
|
206
|
+
const size = mod.length;
|
|
207
|
+
let score = 0;
|
|
208
|
+
const lines = [];
|
|
209
|
+
for (let y = 0; y < size; y++) lines.push(mod[y]);
|
|
210
|
+
for (let x = 0; x < size; x++) lines.push(mod.map((row) => row[x]));
|
|
211
|
+
const finderLike = [true, false, true, true, true, false, true];
|
|
212
|
+
for (const line of lines) {
|
|
213
|
+
let run = 1;
|
|
214
|
+
for (let k = 1; k <= line.length; k++) {
|
|
215
|
+
if (k < line.length && line[k] === line[k - 1]) { run++; continue; }
|
|
216
|
+
if (run >= 5) score += 3 + (run - 5);
|
|
217
|
+
run = 1;
|
|
218
|
+
}
|
|
219
|
+
for (let k = 0; k + 7 <= line.length; k++) {
|
|
220
|
+
if (!finderLike.every((v, n) => line[k + n] === v)) continue;
|
|
221
|
+
const lightBefore = k >= 4 && [1, 2, 3, 4].every((n) => !line[k - n]);
|
|
222
|
+
const lightAfter = k + 11 <= line.length && [7, 8, 9, 10].every((n) => !line[k + n]);
|
|
223
|
+
if (lightBefore || lightAfter) score += 40;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
let dark = 0;
|
|
227
|
+
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
|
|
228
|
+
if (mod[y][x]) dark++;
|
|
229
|
+
if (x + 1 < size && y + 1 < size) {
|
|
230
|
+
const c = mod[y][x];
|
|
231
|
+
if (mod[y][x + 1] === c && mod[y + 1][x] === c && mod[y + 1][x + 1] === c) score += 3;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
score += 10 * Math.floor(Math.abs((dark * 100) / (size * size) - 50) / 5);
|
|
235
|
+
return score;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Encode a string as a QR code. Returns the module grid: rows of booleans, true = dark, with no
|
|
240
|
+
* quiet zone (the renderer adds it).
|
|
241
|
+
* encode('http://100.64.0.1:7433/?token=abc') → level M, smallest version that fits
|
|
242
|
+
* encode(text, { ecl: 'L' }) → more capacity, less damage tolerance
|
|
243
|
+
* encode(text, { mask: 3 }) → force a mask (tests only)
|
|
244
|
+
* Throws on text longer than version 10 holds at the chosen level.
|
|
245
|
+
*/
|
|
246
|
+
function encode(text, { ecl = 'M', mask = null, minVersion = 1 } = {}) {
|
|
247
|
+
if (!ECC[ecl]) throw new Error(`qr: unsupported error-correction level ${ecl} (use M or L)`);
|
|
248
|
+
const bytes = [...Buffer.from(String(text), 'utf8')];
|
|
249
|
+
let ver = minVersion;
|
|
250
|
+
for (; ver <= MAX_VERSION; ver++) {
|
|
251
|
+
const need = 4 + (ver <= 9 ? 8 : 16) + bytes.length * 8;
|
|
252
|
+
if (need <= dataCodewords(ver, ecl) * 8) break;
|
|
253
|
+
}
|
|
254
|
+
if (ver > MAX_VERSION) throw new Error(`qr: ${bytes.length} bytes do not fit in a version-${MAX_VERSION} code at level ${ecl}`);
|
|
255
|
+
const codewords = withEccInterleaved(dataBits(bytes, ver, ecl), ver, ecl);
|
|
256
|
+
if (mask != null) return build(codewords, ver, ecl, mask);
|
|
257
|
+
let best = null; let bestScore = Infinity;
|
|
258
|
+
for (let m = 0; m < 8; m++) {
|
|
259
|
+
const grid = build(codewords, ver, ecl, m);
|
|
260
|
+
const s = penalty(grid);
|
|
261
|
+
if (s < bestScore) { best = grid; bestScore = s; }
|
|
262
|
+
}
|
|
263
|
+
return best;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Draw a module grid as terminal lines, two module rows per text line (half blocks), with the
|
|
268
|
+
* 4-module quiet zone a scanner needs.
|
|
269
|
+
* ansi: true explicit black-on-white colours, correct on any terminal theme (the default for a TTY)
|
|
270
|
+
* ansi: false no escape codes; light modules are drawn as blocks, so it reads correctly on the
|
|
271
|
+
* usual dark terminal and inverted on a light one (most phone cameras read both)
|
|
272
|
+
*/
|
|
273
|
+
function toTerminal(grid, { ansi = true } = {}) {
|
|
274
|
+
const q = 4;
|
|
275
|
+
const size = grid.length + q * 2;
|
|
276
|
+
const dark = (x, y) => {
|
|
277
|
+
const gx = x - q; const gy = y - q;
|
|
278
|
+
return gx >= 0 && gy >= 0 && gx < grid.length && gy < grid.length && grid[gy][gx];
|
|
279
|
+
};
|
|
280
|
+
const lines = [];
|
|
281
|
+
for (let y = 0; y < size; y += 2) {
|
|
282
|
+
let s = '';
|
|
283
|
+
for (let x = 0; x < size; x++) {
|
|
284
|
+
const top = dark(x, y); const bottom = y + 1 < size ? dark(x, y + 1) : false;
|
|
285
|
+
if (ansi) s += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' ';
|
|
286
|
+
else s += !top && !bottom ? '█' : !top ? '▀' : !bottom ? '▄' : ' ';
|
|
287
|
+
}
|
|
288
|
+
lines.push(ansi ? `\x1b[30;47m${s}\x1b[0m` : s);
|
|
289
|
+
}
|
|
290
|
+
return lines;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
module.exports = { encode, toTerminal, _internal: { rsDivisor, rsRemainder, formatBits, dataCodewords, alignmentPositions } };
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
const crypto = require('crypto');
|
|
22
22
|
const os = require('os');
|
|
23
23
|
const { spawnSync } = require('child_process');
|
|
24
|
+
const qr = require('./qr');
|
|
24
25
|
|
|
25
26
|
const LOOPBACK = '127.0.0.1';
|
|
26
27
|
|
|
@@ -232,7 +233,16 @@ function authorize({ remoteAddress, presentedToken, expectedToken }) {
|
|
|
232
233
|
// The startup banner lines. This is the ONE place a token may be printed, and ONLY a freshly
|
|
233
234
|
// GENERATED token — a token supplied via PMOS_TOKEN is the PM's own secret and is never echoed.
|
|
234
235
|
// Nothing here (or anywhere in this module) logs request headers (D08).
|
|
235
|
-
|
|
236
|
+
//
|
|
237
|
+
// workspace-phone-app: for a generated token the banner also prints the phone URL
|
|
238
|
+
// (http://<tailnet>:<port>/?token=<t>) as a QR code, because typing 43 random characters on a phone
|
|
239
|
+
// keyboard is the step that made the tailnet flow impractical. The QR is the same secret in another
|
|
240
|
+
// form, so it follows the same rule: generated token only, startup only. `ansi` picks the
|
|
241
|
+
// renderer (lib/qr.js toTerminal); the server passes stdout.isTTY && !NO_COLOR.
|
|
242
|
+
function phoneUrl(bind, port) {
|
|
243
|
+
return `http://${bind.tailnetIp}:${port}/?token=${encodeURIComponent(bind.token)}`;
|
|
244
|
+
}
|
|
245
|
+
function startupLines(bind, port, { ansi = false } = {}) {
|
|
236
246
|
if (!bind || !bind.remote) {
|
|
237
247
|
return [`pmos workspace server — http://${LOOPBACK}:${port} (loopback only, D62)`];
|
|
238
248
|
}
|
|
@@ -244,16 +254,98 @@ function startupLines(bind, port) {
|
|
|
244
254
|
if (bind.generated) {
|
|
245
255
|
lines.push(` token (generated — shown ONCE, copy it now): ${bind.token}`);
|
|
246
256
|
lines.push(' attach with header Authorization: Bearer <token> or query ?token=<token>');
|
|
257
|
+
lines.push('', ' phone: scan this with the camera of a phone on the same tailnet. It opens the workspace signed in.');
|
|
258
|
+
try {
|
|
259
|
+
for (const l of qr.toTerminal(qr.encode(phoneUrl(bind, port)), { ansi })) lines.push(' ' + l);
|
|
260
|
+
} catch { lines.push(' (the phone URL is too long for a QR code here; type it instead)'); }
|
|
261
|
+
lines.push(` or open: ${phoneUrl(bind, port)}`, '');
|
|
247
262
|
} else {
|
|
248
263
|
lines.push(' token: using PMOS_TOKEN from the environment (not shown)');
|
|
264
|
+
lines.push(` phone: open http://${bind.tailnetIp}:${port}/ and paste your PMOS_TOKEN into the sign-in field`);
|
|
249
265
|
}
|
|
250
266
|
lines.push(' loopback requests are token-free; every tailnet request must present the token.');
|
|
251
267
|
return lines;
|
|
252
268
|
}
|
|
253
269
|
|
|
270
|
+
// ── Sign-in for a phone (workspace-phone-app) ────────────────────────────────────────────
|
|
271
|
+
// Three small pieces the HTTP guard uses so that a browser on a phone never meets a JSON 401 and
|
|
272
|
+
// never keeps the token in its address bar. None of them changes WHO is let in: the decision is
|
|
273
|
+
// still authorize() above, and the Origin/Host allowlist still runs first.
|
|
274
|
+
|
|
275
|
+
// A top-level page load, as opposed to an API call, a script, or a WebSocket upgrade. Only these
|
|
276
|
+
// get the HTML sign-in page or the token-stripping redirect; everything else keeps its JSON answer.
|
|
277
|
+
function isPageNavigation({ method, accept } = {}) {
|
|
278
|
+
return (method === 'GET' || method === 'HEAD') && /\btext\/html\b/i.test(String(accept || ''));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// The URL with its `token` parameter removed, or null when it had none. A valid ?token= navigation
|
|
282
|
+
// seeds the cookie and is then redirected here, so the secret leaves the address bar, the history,
|
|
283
|
+
// and the start_url a phone records on Add to Home Screen.
|
|
284
|
+
function stripTokenParam(url) {
|
|
285
|
+
let u;
|
|
286
|
+
try { u = new URL(url || '/', 'http://localhost'); } catch { return null; }
|
|
287
|
+
if (!u.searchParams.has('token')) return null;
|
|
288
|
+
u.searchParams.delete('token');
|
|
289
|
+
const q = u.searchParams.toString();
|
|
290
|
+
// A path that normalises to '//host/…' would be a scheme-relative Location, which a browser reads
|
|
291
|
+
// as another site. Collapse leading slashes so the redirect can only ever stay on this origin.
|
|
292
|
+
return u.pathname.replace(/^\/+/, '/') + (q ? '?' + q : '') + u.hash;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// The cookie a valid token seeds. HttpOnly (no script needs it) + SameSite=Strict + the Origin/Host
|
|
296
|
+
// allowlist keep a foreign site from riding it. No Secure flag: the tailnet speaks plain HTTP inside
|
|
297
|
+
// WireGuard. 7 days, unchanged from workspace-mobile-remote-access.
|
|
298
|
+
function sessionCookie(token) {
|
|
299
|
+
return `${COOKIE_NAME}=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// The sign-in form posts application/x-www-form-urlencoded `token=<t>` to /auth.
|
|
303
|
+
const AUTH_PATH = '/auth';
|
|
304
|
+
function parseAuthForm(body) {
|
|
305
|
+
try { return (new URLSearchParams(String(body || '')).get('token') || '').trim() || null; } catch { return null; }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// The page an unauthorized phone sees. Self-contained (no script, no sub-resource, so it renders
|
|
309
|
+
// without the cookie it is there to set) and phone-shaped. The password field lets the phone's
|
|
310
|
+
// password manager remember the token. The colours are the dark theme's --background/--foreground.
|
|
311
|
+
function signInPage({ failed = false } = {}) {
|
|
312
|
+
return `<!doctype html>
|
|
313
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
314
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
315
|
+
<meta name="theme-color" content="#090b0c">
|
|
316
|
+
<title>Sign in · PMOS workspace</title>
|
|
317
|
+
<style>
|
|
318
|
+
:root { color-scheme: dark; }
|
|
319
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #090b0c; color: #f9fbfb;
|
|
320
|
+
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; padding: 24px; box-sizing: border-box; }
|
|
321
|
+
main { width: 100%; max-width: 360px; }
|
|
322
|
+
h1 { font-size: 22px; margin: 0 0 8px; }
|
|
323
|
+
p { margin: 0 0 16px; color: #a3abae; }
|
|
324
|
+
label { display: block; font-size: 14px; margin-bottom: 6px; }
|
|
325
|
+
input, button { width: 100%; box-sizing: border-box; min-height: 48px; font: inherit; border-radius: 6px; }
|
|
326
|
+
input { padding: 0 12px; background: #15191b; color: inherit; border: 1px solid #3a4245; }
|
|
327
|
+
button { margin-top: 12px; border: 0; background: #f9fbfb; color: #090b0c; font-weight: 600; }
|
|
328
|
+
.err { color: #ff8a80; }
|
|
329
|
+
code { font-size: 13px; }
|
|
330
|
+
</style></head>
|
|
331
|
+
<body><main>
|
|
332
|
+
<h1>Sign in to the PMOS workspace</h1>
|
|
333
|
+
${failed ? '<p class="err" role="alert">That token does not match. Check it and try again.</p>' : ''}
|
|
334
|
+
<p>Paste the token that <code>PMOS_REMOTE=tailnet npm start</code> printed, or scan its QR code again.</p>
|
|
335
|
+
<form method="post" action="${AUTH_PATH}">
|
|
336
|
+
<input type="text" name="username" value="pmos" autocomplete="username" hidden>
|
|
337
|
+
<label for="token">Token</label>
|
|
338
|
+
<input id="token" name="token" type="password" autocomplete="current-password" autocapitalize="off" spellcheck="false" required autofocus>
|
|
339
|
+
<button type="submit">Sign in</button>
|
|
340
|
+
</form>
|
|
341
|
+
</main></body></html>
|
|
342
|
+
`;
|
|
343
|
+
}
|
|
344
|
+
|
|
254
345
|
module.exports = {
|
|
255
346
|
LOOPBACK, isLoopbackAddr, isTailnetIp, detectTailnetIp, remoteRequested,
|
|
256
347
|
resolveBinds, extractHttpToken, extractWsToken, tokenMatches, authorize, startupLines,
|
|
257
348
|
checkHostOrigin, isAllowedOrigin, hostnameAllowed,
|
|
258
349
|
COOKIE_NAME, extractCookieToken, presentedToken,
|
|
350
|
+
phoneUrl, isPageNavigation, stripTokenParam, sessionCookie, AUTH_PATH, parseAuthForm, signInPage,
|
|
259
351
|
};
|