create-pmos 0.55.5 → 0.57.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.
@@ -0,0 +1,221 @@
1
+ 'use strict';
2
+ /* docs-cli.js — `pmos docs`: the sync signal for documents linked to Google Docs and Confluence, from a terminal
3
+ * (initiative docs-three-way-sync).
4
+ *
5
+ * pmos docs [list] [--attention] [--json] each linked document and its signal per copy
6
+ * pmos docs link <file> <google-or-confluence-link> link a repository file to a copy
7
+ * pmos docs unlink <file> google|confluence remove that link (the copy is not touched)
8
+ * pmos docs check-start [--json] open a check: what to read, with each file's hash
9
+ * pmos docs record <check> --file <readings.json> record what the session read
10
+ * pmos docs check-finish <check> [--summary "…"] close the check
11
+ * pmos docs find [words] the repository's markdown files that match, with or without a copy
12
+ * pmos docs sync-start <file> google|confluence --use pmos|copy [--json] open a sync of one copy (the sync session)
13
+ * pmos docs sync-finish <sync> --version <v> [--modified-at <t>] [--url <u>] close it: both sides now the same
14
+ * pmos docs sync-fail <sync> --reason "…" close it: nothing was written, and why
15
+ *
16
+ * The same module the Docs place reads (lib/doc-sync-store.js), so a terminal line and a row cannot disagree.
17
+ * No command writes a document. A check reads; a sync (amended 2026-10-06: the PM presses Use … version for one copy) is
18
+ * written by the doc-sync session with the PM's connectors, and these commands only record that it started and ended.
19
+ * check-start, record and check-finish are the check session's commands; each must name who read, which is the
20
+ * session's PMOS_SESSION token, or `--by <profile>` when the session runs outside the workspace.
21
+ *
22
+ * Exit codes: 0 done, 1 refused, 2 usage.
23
+ */
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const { execFileSync } = require('child_process');
27
+ const D = require('./doc-sync');
28
+ const store = require('./doc-sync-store');
29
+ const { parseArgs } = require('./schedule-cli');
30
+
31
+ const COMMANDS = { docs: ['list', 'link', 'unlink', 'check-start', 'record', 'check-finish', 'find', 'sync-start', 'sync-finish', 'sync-fail'] };
32
+ const USAGE = [
33
+ 'usage: pmos docs [list|link|unlink|find|check-start|record|check-finish|sync-start|sync-finish|sync-fail] — is each linked document the same as its Google Docs and Confluence copy',
34
+ ' pmos docs [list] [--attention] [--json] each linked document and its signal: same, ahead, behind, edited in both',
35
+ ' pmos docs link <file> <link> link a repository file to a Google Doc or a Confluence page',
36
+ ' pmos docs unlink <file> google|confluence remove that link; the copy is not touched',
37
+ ' pmos docs check-start [--json] (check session) open a check and list what to read',
38
+ ' pmos docs record <check> --file <readings.json> (check session) record what was read',
39
+ ' pmos docs check-finish <check> [--summary "…"] (check session) close the check',
40
+ ' pmos docs find [words] the repository\'s markdown files that match',
41
+ ' pmos docs sync-start <file> google|confluence --use pmos|copy (sync session) open a sync of one copy',
42
+ ' pmos docs sync-finish <sync> --version <v> [--modified-at <t>] [--url <u>] (sync session) both sides are now the same',
43
+ ' pmos docs sync-fail <sync> --reason "…" (sync session) nothing was written, and why',
44
+ 'Nothing here writes a document. Press Check now, or Use … version on one copy, in Docs.',
45
+ ];
46
+ const BOOL = new Set(['json', 'attention', 'help']);
47
+
48
+ function parse(argv) {
49
+ const rest = []; const bools = {};
50
+ for (const a of argv) {
51
+ if (a.startsWith('--') && BOOL.has(a.slice(2))) bools[a.slice(2)] = true;
52
+ else if (a === '-h') bools.help = true;
53
+ else rest.push(a);
54
+ }
55
+ const { pos, flags } = parseArgs(rest);
56
+ return { pos, flags: { ...flags, ...bools } };
57
+ }
58
+ function repoRoot(cwd) {
59
+ try { return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd; }
60
+ catch { return cwd; }
61
+ }
62
+ /** Who is reading: the workspace session's token names it; outside the workspace, the profile the PM names. */
63
+ function whoOf(flags, env) {
64
+ const tok = String(env.PMOS_SESSION || '').trim();
65
+ if (tok) return 'session:' + tok.split('.')[0].slice(0, 60);
66
+ if (flags.by) return 'profile:' + String(flags.by).slice(0, 60);
67
+ return null;
68
+ }
69
+ const kindOf = (target) => {
70
+ if (D.remoteIdOf('confluence', target) && /atlassian|confluence|\/wiki\/|pageId=/i.test(target)) return 'confluence';
71
+ if (D.remoteIdOf('google', target) && /google|docs\.|drive\./i.test(target)) return 'google';
72
+ return null;
73
+ };
74
+ const ago = (iso, now) => {
75
+ if (!iso) return 'never';
76
+ const m = Math.round((now - Date.parse(iso)) / 60000);
77
+ if (m < 1) return 'just now';
78
+ if (m < 60) return `${m} min ago`;
79
+ if (m < 48 * 60) return `${Math.round(m / 60)} h ago`;
80
+ return `${Math.round(m / 1440)} days ago`;
81
+ };
82
+
83
+ async function run(argv, { say = console.log, cwd = process.cwd(), env = process.env, now = Date.now() } = {}) {
84
+ const { pos, flags } = parse(argv.slice(1));
85
+ const sub = pos[0] || 'list';
86
+ if (flags.help || sub === 'help') { USAGE.forEach((l) => say(l)); return 0; }
87
+ const json = !!flags.json;
88
+ const root = flags.root ? path.resolve(cwd, flags.root) : repoRoot(cwd);
89
+ const fail = (error, code = 1) => { if (json) say(JSON.stringify({ ok: false, error })); else say(`✗ ${error}`); return code; };
90
+ const rel = (p) => {
91
+ const abs = path.resolve(cwd, p);
92
+ const r = path.relative(root, abs).split(path.sep).join('/');
93
+ return r.startsWith('..') ? null : r;
94
+ };
95
+
96
+ if (sub === 'list') {
97
+ const v = store.view(root);
98
+ if (!v.ok) return fail(v.error);
99
+ const docs = flags.attention ? v.docs.filter((d) => d.attention) : v.docs;
100
+ if (json) { say(JSON.stringify({ ...v, docs })); return 0; }
101
+ if (!v.docs.length) { say('No document is linked to Google Docs or Confluence. Link one: pmos docs link <file> <link>'); return 0; }
102
+ const s = v.summary;
103
+ say(`Docs · ${s.documents} document${s.documents === 1 ? '' : 's'} · ${s.same} same · ${s.ahead} ahead · ${s.behind} behind · ${s.diverged} edited in both · ${s.unchecked} not checked`);
104
+ say(v.last ? `Last check ${ago(v.last.finished_at, now)}${v.last.summary ? ': ' + v.last.summary : ''}` : 'Never checked. Press Check now in Docs.');
105
+ if (v.open) say(`A check is running (started ${ago(v.open.started_at, now)}).`);
106
+ for (const d of docs) {
107
+ say(`${d.title} (${d.path})`);
108
+ for (const l of d.links) say(` ${l.label.padEnd(12)} ${l.words}${l.checked_at ? ' · checked ' + ago(l.checked_at, now) : ''}`);
109
+ }
110
+ return 0;
111
+ }
112
+
113
+ if (sub === 'link') {
114
+ const [file, target] = [pos[1], pos[2]];
115
+ if (!file || !target) return fail('usage: pmos docs link <file> <google-or-confluence-link>', 2);
116
+ const kind = flags.kind || kindOf(target);
117
+ if (!kind) return fail('that is not a Google Docs or Confluence link — paste the page\'s address');
118
+ const r = rel(file);
119
+ if (!r) return fail(`${file} is not inside this repository`);
120
+ const w = store.addLink(root, { path: r, kind, target });
121
+ if (!w.ok) return fail(w.error);
122
+ if (json) say(JSON.stringify({ ok: true, path: r, kind })); else say(`✓ ${r} is linked to ${D.KIND_LABEL[kind]}. Its signal shows after the next check.`);
123
+ return 0;
124
+ }
125
+
126
+ if (sub === 'unlink') {
127
+ const [file, kind] = [pos[1], pos[2]];
128
+ if (!file || !D.KINDS.includes(kind)) return fail('usage: pmos docs unlink <file> google|confluence', 2);
129
+ const r = rel(file);
130
+ if (!r) return fail(`${file} is not inside this repository`);
131
+ const w = store.removeLink(root, { path: r, kind });
132
+ if (!w.ok) return fail(w.error);
133
+ if (json) say(JSON.stringify({ ok: true })); else say(`✓ ${r} is no longer linked to ${D.KIND_LABEL[kind]}. The copy is not touched.`);
134
+ return 0;
135
+ }
136
+
137
+ if (sub === 'find') {
138
+ const found = store.findFiles(root, pos.slice(1).join(' '));
139
+ const v = store.view(root);
140
+ const linked = new Map((v.ok ? v.docs : []).map((d) => [d.path, d.links.map((l) => l.label)]));
141
+ const files = found.map((f) => ({ ...f, copies: linked.get(f.path) || [] }));
142
+ if (json) { say(JSON.stringify({ ok: true, files })); return 0; }
143
+ if (!files.length) { say('No markdown file matches.'); return 0; }
144
+ for (const f of files) say(`${f.title} (${f.path}) ${f.copies.length ? f.copies.join(', ') : 'no copy'}`);
145
+ return 0;
146
+ }
147
+
148
+ const by = whoOf(flags, env);
149
+ if (sub === 'check-start') {
150
+ if (!by) return fail('a check must name who reads: run it in a workspace session, or add --by <profile>');
151
+ const s = store.checkStart(root, { by, now: new Date(now) });
152
+ if (!s.ok) return fail(s.error);
153
+ if (json) { say(JSON.stringify(s)); return 0; }
154
+ say(`Check ${s.run} · ${s.links.length} link${s.links.length === 1 ? '' : 's'} to read`);
155
+ for (const l of s.links) say(` ${l.path} ${D.KIND_LABEL[l.kind]} ${l.target} hash ${l.repo_hash || '(no file)'}`);
156
+ return 0;
157
+ }
158
+ if (sub === 'record') {
159
+ const runId = pos[1];
160
+ if (!runId || !flags.file) return fail('usage: pmos docs record <check> --file <readings.json>', 2);
161
+ if (!by) return fail('a reading must name who read: run it in a workspace session, or add --by <profile>');
162
+ let readings;
163
+ try {
164
+ const parsed = JSON.parse(fs.readFileSync(path.resolve(cwd, flags.file), 'utf8'));
165
+ readings = Array.isArray(parsed) ? parsed : parsed.readings;
166
+ } catch (e) { return fail(`could not read ${flags.file}: ${String((e && e.message) || e).slice(0, 160)}`); }
167
+ const r = store.record(root, { run: runId, by, readings, now: new Date(now) });
168
+ if (json) say(JSON.stringify(r));
169
+ else {
170
+ say(`${r.recorded ? r.recorded.length : 0} reading(s) recorded.`);
171
+ for (const x of r.refused || []) say(`✗ ${x.path} ${x.kind}: ${x.error}`);
172
+ if (!r.recorded && r.error) say(`✗ ${r.error}`);
173
+ }
174
+ return r.ok ? 0 : 1;
175
+ }
176
+ if (sub === 'check-finish') {
177
+ const runId = pos[1];
178
+ if (!runId) return fail('usage: pmos docs check-finish <check> [--summary "…"]', 2);
179
+ if (!by) return fail('a check must name who ran it: run it in a workspace session, or add --by <profile>');
180
+ const r = store.checkFinish(root, { run: runId, by, summary: flags.summary || null, now: new Date(now) });
181
+ if (!r.ok) return fail(r.error);
182
+ const v = store.view(root);
183
+ if (json) say(JSON.stringify({ ok: true, summary: v.ok ? v.summary : null }));
184
+ else say(v.ok ? `✓ Check closed · ${v.summary.same} same · ${v.summary.ahead} ahead · ${v.summary.behind} behind · ${v.summary.diverged} edited in both` : '✓ Check closed');
185
+ return 0;
186
+ }
187
+ if (sub === 'sync-start') {
188
+ const [file, kind] = [pos[1], pos[2]];
189
+ if (!file || !D.KINDS.includes(kind) || !D.USES.includes(flags.use)) return fail('usage: pmos docs sync-start <file> google|confluence --use pmos|copy', 2);
190
+ if (!by) return fail('a sync must name who writes: run it in a workspace session, or add --by <profile>');
191
+ const r = rel(file);
192
+ if (!r) return fail(`${file} is not inside this repository`);
193
+ const s = store.syncStart(root, { path: r, kind, use: flags.use, by, now: new Date(now) });
194
+ if (!s.ok) return fail(s.error);
195
+ if (json) { say(JSON.stringify(s)); return 0; }
196
+ say(`Sync ${s.run} · ${s.path} · ${s.use === 'pmos' ? 'write the PMOS version to ' + s.label : 'write the ' + s.label + ' version to PMOS'} · ${s.target}`);
197
+ return 0;
198
+ }
199
+ if (sub === 'sync-finish') {
200
+ const runId = pos[1];
201
+ if (!runId || !flags.version) return fail('usage: pmos docs sync-finish <sync> --version <v> [--modified-at <t>] [--url <u>]', 2);
202
+ if (!by) return fail('a sync must name who wrote: run it in a workspace session, or add --by <profile>');
203
+ const r = store.syncFinish(root, { run: runId, by, version: flags.version, modified_at: flags['modified-at'] || null,
204
+ modified_by: flags['modified-by'] || null, url: flags.url || null, now: new Date(now) });
205
+ if (!r.ok) return fail(r.error);
206
+ if (json) say(JSON.stringify({ ok: true })); else say('✓ Sync closed. Both sides are the same.');
207
+ return 0;
208
+ }
209
+ if (sub === 'sync-fail') {
210
+ const runId = pos[1];
211
+ if (!runId || !flags.reason) return fail('usage: pmos docs sync-fail <sync> --reason "…"', 2);
212
+ if (!by) return fail('a sync must name who ran it: run it in a workspace session, or add --by <profile>');
213
+ const r = store.syncFail(root, { run: runId, by, reason: String(flags.reason), now: new Date(now) });
214
+ if (!r.ok) return fail(r.error);
215
+ if (json) say(JSON.stringify({ ok: true })); else say('✓ Sync closed as failed. Nothing was written.');
216
+ return 0;
217
+ }
218
+ return fail(`pmos docs ${sub} is not a command. ${USAGE[0]}`, 2);
219
+ }
220
+
221
+ module.exports = { run, COMMANDS, USAGE };
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+ /* online-mode.js — the Online switch: its settings record and the tunnel it runs (workspace-online-mode, PRD O1, O2).
3
+ *
4
+ * THE RECORD. `<runtime state>/online.json`, in the per-user runtime directory lib/state-root.js keeps outside every
5
+ * repository, written with mode 0600 through a temporary file and a rename (the typesafe-settings.js pattern):
6
+ * { on, host, tunnel, team, audience, email }
7
+ * host the public hostname, validated by remote-access.parsePublicHost (pmos.pm-os.app)
8
+ * tunnel the Cloudflare tunnel name or id that `cloudflared tunnel run` takes (pmos)
9
+ * team the Cloudflare team domain, <team>.cloudflareaccess.com
10
+ * audience the Access application's audience tag, 64 hex characters
11
+ * email the one address allowed in
12
+ * A field that fails its check is refused with the reason, and nothing is written.
13
+ *
14
+ * THE TUNNEL. createTunnel() runs `cloudflared tunnel --no-autoupdate run <tunnel>` as a child of the server, with its
15
+ * output appended to `<runtime state>/online-tunnel.log`. stop() ends it. If it exits while Online is on, it is started
16
+ * again after 5 seconds. cloudflared is the PM's own installed program; it is found on PATH or in the two Homebrew
17
+ * folders, because a workspace started at login has a short PATH.
18
+ */
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const childProcess = require('child_process');
22
+ const remoteAccess = require('./remote-access');
23
+ const accessKeys = require('./access-keys');
24
+
25
+ const FILE = 'online.json';
26
+ const LOG = 'online-tunnel.log';
27
+ const FIELDS = ['host', 'tunnel', 'team', 'audience', 'email'];
28
+ const RESTART_MS = 5000;
29
+
30
+ function file(stateDir) { return path.join(stateDir, FILE); }
31
+
32
+ const CHECKS = {
33
+ host: (v) => {
34
+ if (!v) return 'give the public hostname, such as pmos.pm-os.app';
35
+ const r = remoteAccess.parsePublicHost(v);
36
+ return r.refused ? 'the hostname must be a bare DNS name, such as pmos.pm-os.app (no https://, port or path)' : null;
37
+ },
38
+ tunnel: (v) => (/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(v) ? null : 'give the tunnel name you created with cloudflared, such as pmos'),
39
+ team: (v) => (accessKeys.validTeamDomain(v) ? null : 'give your Cloudflare team domain, such as myteam.cloudflareaccess.com'),
40
+ audience: (v) => (/^[a-f0-9]{64}$/.test(v) ? null : "give the Access application's Audience (AUD) tag: 64 letters and digits from the application's Overview"),
41
+ email: (v) => (/^[^\s@]{1,64}@[^\s@]+\.[^\s@]{2,}$/.test(v) && v.length <= 254 ? null : 'give the email address you sign in with'),
42
+ };
43
+ function norm(field, v) {
44
+ const s = String(v == null ? '' : v).trim();
45
+ return field === 'tunnel' ? s : s.toLowerCase();
46
+ }
47
+
48
+ function read(stateDir) {
49
+ const out = { on: false, host: '', tunnel: '', team: '', audience: '', email: '' };
50
+ try {
51
+ const st = fs.lstatSync(file(stateDir));
52
+ if (!st.isFile()) return out;
53
+ const o = JSON.parse(fs.readFileSync(file(stateDir), 'utf8')) || {};
54
+ for (const f of FIELDS) if (typeof o[f] === 'string' && !CHECKS[f](o[f])) out[f] = o[f];
55
+ out.on = o.on === true && complete(out);
56
+ } catch { /* none yet */ }
57
+ return out;
58
+ }
59
+
60
+ function complete(s) { return FIELDS.every((f) => s[f] && !CHECKS[f](s[f])); }
61
+
62
+ /** The first field that is missing or invalid, as { field, error }, or null when all five are good. */
63
+ function firstProblem(s) {
64
+ for (const f of FIELDS) { const e = CHECKS[f](s[f] || ''); if (e) return { field: f, error: e }; }
65
+ return null;
66
+ }
67
+
68
+ function write(stateDir, data) {
69
+ const target = file(stateDir);
70
+ try { if (fs.lstatSync(target).isSymbolicLink()) return { ok: false, error: 'the settings file is a symlink and was not written' }; }
71
+ catch { /* not there yet */ }
72
+ const tmp = target + '.' + process.pid + '.tmp';
73
+ try {
74
+ fs.mkdirSync(stateDir, { recursive: true });
75
+ fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
76
+ fs.chmodSync(tmp, 0o600);
77
+ fs.renameSync(tmp, target);
78
+ return { ok: true };
79
+ } catch (e) {
80
+ try { fs.unlinkSync(tmp); } catch { /* nothing to clean */ }
81
+ return { ok: false, error: 'the online settings could not be saved (' + String((e && e.code) || 'write failed') + ')' };
82
+ }
83
+ }
84
+
85
+ /**
86
+ * save(stateDir, patch) → { ok, settings } | { ok: false, field?, error }
87
+ * patch may carry any of the five fields and `on`. Fields are validated first; `on: true` then needs all five.
88
+ * Whether cloudflared is installed is the caller's check, because it is not a property of the record.
89
+ */
90
+ function save(stateDir, patch = {}) {
91
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return { ok: false, error: 'the body must be an object' };
92
+ const next = read(stateDir);
93
+ for (const f of FIELDS) {
94
+ if (!(f in patch)) continue;
95
+ const v = norm(f, patch[f]);
96
+ const e = CHECKS[f](v);
97
+ if (e) return { ok: false, field: f, error: e };
98
+ next[f] = v;
99
+ }
100
+ if ('on' in patch) {
101
+ if (typeof patch.on !== 'boolean') return { ok: false, field: 'on', error: 'on must be true or false' };
102
+ if (patch.on) { const p = firstProblem(next); if (p) return { ok: false, ...p }; }
103
+ next.on = patch.on;
104
+ }
105
+ const w = write(stateDir, next);
106
+ return w.ok ? { ok: true, settings: next } : { ok: false, error: w.error };
107
+ }
108
+
109
+ /** The cloudflared binary: PATH first, then the two Homebrew folders. null when absent. */
110
+ function findCloudflared({ env = process.env, exists = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); } catch { return false; } } } = {}) {
111
+ const dirs = String(env.PATH || '').split(path.delimiter).filter(Boolean).concat(['/opt/homebrew/bin', '/usr/local/bin']);
112
+ const name = process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared';
113
+ for (const d of dirs) { const p = path.join(d, name); if (exists(p)) return p; }
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * createTunnel({ stateDir, spawn, find, setTimer }) → { start(name), stop(), status() }
119
+ * status() → { running, pid, since, lastExit } — lastExit is { code, at } of the last exit, or null.
120
+ */
121
+ function createTunnel({ stateDir, spawn = childProcess.spawn, find = findCloudflared, setTimer = setTimeout } = {}) {
122
+ let child = null; let want = null; let since = null; let lastExit = null; let timer = null;
123
+ function launch() {
124
+ timer = null;
125
+ if (!want || child) return;
126
+ const bin = find();
127
+ if (!bin) { lastExit = { code: null, at: new Date().toISOString(), error: 'cloudflared is not installed' }; return; }
128
+ let fd = 'ignore';
129
+ try { fs.mkdirSync(stateDir, { recursive: true }); fd = fs.openSync(path.join(stateDir, LOG), 'a', 0o600); } catch { fd = 'ignore'; }
130
+ try {
131
+ child = spawn(bin, ['tunnel', '--no-autoupdate', 'run', want], { stdio: ['ignore', fd, fd], detached: false });
132
+ } catch (e) {
133
+ child = null; lastExit = { code: null, at: new Date().toISOString(), error: 'cloudflared could not start' };
134
+ }
135
+ if (typeof fd === 'number') { try { fs.closeSync(fd); } catch { /* ignore */ } }
136
+ if (!child) return;
137
+ since = new Date().toISOString();
138
+ const mine = child;
139
+ mine.on('error', () => {});
140
+ mine.on('exit', (code) => {
141
+ if (child !== mine) return;
142
+ child = null; since = null; lastExit = { code, at: new Date().toISOString() };
143
+ if (want && !timer) timer = setTimer(launch, RESTART_MS);
144
+ });
145
+ }
146
+ return {
147
+ start(name) { want = name; if (!child && !timer) launch(); },
148
+ stop() {
149
+ want = null;
150
+ if (timer) { clearTimeout(timer); timer = null; }
151
+ const c = child; child = null; since = null;
152
+ if (c) { try { c.kill('SIGTERM'); } catch { /* already gone */ } }
153
+ },
154
+ status: () => ({ running: !!child, pid: child ? child.pid : null, since, lastExit }),
155
+ };
156
+ }
157
+
158
+ module.exports = { read, save, complete, firstProblem, findCloudflared, createTunnel, FIELDS, FILE, LOG };
@@ -35,6 +35,9 @@ const VERBS = Object.freeze({
35
35
  report: Object.freeze({ module: 'report-cli.js', run: 5, guide: 'reports.md',
36
36
  help: 'pmos report draft|issue|list … — draft and issue reports (pmos report help)' }),
37
37
  // Not a pm-os-absorb verb (run: null): the workspace-launcher initiative added it through the same table.
38
+ // Not a pm-os-absorb verb (run: null): docs-three-way-sync added it through the same table.
39
+ docs: Object.freeze({ module: 'docs-cli.js', run: null, guide: 'docs.md',
40
+ help: 'pmos docs [list|link|unlink|find] … — is each linked document the same as, ahead of or behind its Google Docs and Confluence copy, and record a sync (pmos docs help)' }),
38
41
  launcher: Object.freeze({ module: 'launcher-cli.js', run: null, guide: 'launcher.md',
39
42
  help: 'pmos launcher on|off|status — a PMOS app and a login item, so the workspace starts without a terminal' }),
40
43
  });
@@ -373,9 +373,11 @@ function stripTokenParam(url) {
373
373
  // The cookie a valid token seeds. HttpOnly (no script needs it) + SameSite=Strict + the Origin/Host
374
374
  // allowlist keep a foreign site from riding it. No Secure flag on the tailnet: it speaks plain HTTP
375
375
  // inside WireGuard. On the public host the browser is on HTTPS (TLS ends at the edge), so the cookie
376
- // is Secure there and is never sent over plain HTTP. 7 days, unchanged from workspace-mobile-remote-access.
376
+ // is Secure there and is never sent over plain HTTP. 30 days (workspace-remember-device; was 7), so a
377
+ // device signs in about once a month: the PM's own phone, behind Cloudflare Access or the tailnet.
378
+ const SESSION_MAX_AGE = 30 * 24 * 60 * 60;
377
379
  function sessionCookie(token, { secure = false } = {}) {
378
- return `${COOKIE_NAME}=${encodeURIComponent(token)}; HttpOnly; ${secure ? 'Secure; ' : ''}SameSite=Strict; Path=/; Max-Age=604800`;
380
+ return `${COOKIE_NAME}=${encodeURIComponent(token)}; HttpOnly; ${secure ? 'Secure; ' : ''}SameSite=Strict; Path=/; Max-Age=${SESSION_MAX_AGE}`;
379
381
  }
380
382
 
381
383
  // The sign-in form posts application/x-www-form-urlencoded `token=<t>` to /auth.
@@ -425,7 +427,7 @@ module.exports = {
425
427
  LOOPBACK, isLoopbackAddr, isTailnetIp, detectTailnetIp, remoteRequested,
426
428
  resolveBinds, extractHttpToken, extractWsToken, tokenMatches, authorize, startupLines,
427
429
  checkHostOrigin, isAllowedOrigin, hostnameAllowed,
428
- parsePublicHost, PROXY_HEADERS, ACCESS_HEADER, isProxied, edgeAllowed,
430
+ parsePublicHost, PROXY_HEADERS, ACCESS_HEADER, isProxied, edgeAllowed, hostnameOf,
429
431
  COOKIE_NAME, extractCookieToken, presentedToken,
430
432
  phoneUrl, isPageNavigation, stripTokenParam, sessionCookie, AUTH_PATH, parseAuthForm, signInPage,
431
433
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.55.5",
3
+ "version": "0.57.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.55.5",
3
+ "version": "0.57.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -406,7 +406,7 @@ function render() {
406
406
 
407
407
  const FRAME = !FIXTURE && !LEGACY_LAYOUT_FLAG;
408
408
  const FRAME_KEY = 'pmos.workspace.pm.frame';
409
- const FRAME_DESTS = Object.freeze(['today', 'initiatives', 'board', 'reports', 'settings']);
409
+ const FRAME_DESTS = Object.freeze(['today', 'initiatives', 'board', 'reports', 'docs', 'settings']);
410
410
 
411
411
  function readFrame() {
412
412
  if (!FRAME) return {};
@@ -637,7 +637,7 @@ function pressIn(r, key) {
637
637
  if (b && !b.disabled) { b.click(); return true; }
638
638
  return false;
639
639
  }
640
- const G_KEYS = { t: 'today', i: 'initiatives', b: 'board', r: 'reports' };
640
+ const G_KEYS = { t: 'today', i: 'initiatives', b: 'board', r: 'reports', d: 'docs' };
641
641
 
642
642
  function frameKey(e) {
643
643
  const mod = e.metaKey || e.ctrlKey;
@@ -4067,6 +4067,152 @@
4067
4067
  }
4068
4068
  }
4069
4069
 
4070
+ /* Docs (docs-three-way-sync): one line per copy — a status dot, the title over the copy's link, one sentence, one action. */
4071
+ .rp-docs {
4072
+ display: flex;
4073
+ flex-direction: column;
4074
+ gap: 16px;
4075
+ }
4076
+ .rp-docs-top {
4077
+ display: flex;
4078
+ flex-wrap: wrap;
4079
+ align-items: center;
4080
+ gap: 12px;
4081
+ }
4082
+ .rp-docs-top > .rp-head__title {
4083
+ margin-right: auto;
4084
+ }
4085
+ .rp-docs-find {
4086
+ flex: 1 1 260px;
4087
+ max-width: 420px;
4088
+ }
4089
+ .rp-docs-body {
4090
+ display: flex;
4091
+ flex-direction: column;
4092
+ gap: 16px;
4093
+ }
4094
+ .rp-docs-views {
4095
+ display: flex;
4096
+ flex-wrap: wrap;
4097
+ align-items: center;
4098
+ gap: 12px;
4099
+ border-bottom: 1px solid var(--border);
4100
+ }
4101
+ .rp-docs-when {
4102
+ display: flex;
4103
+ align-items: center;
4104
+ gap: 4px;
4105
+ margin-left: auto;
4106
+ font-size: 12px;
4107
+ color: var(--muted-foreground);
4108
+ }
4109
+ .rp-docs-group__title {
4110
+ margin: 0;
4111
+ padding: 8px 0;
4112
+ font-size: 12px;
4113
+ font-weight: 500;
4114
+ color: var(--muted-foreground);
4115
+ }
4116
+ .rp-docs-row {
4117
+ border-top: 1px solid var(--border);
4118
+ }
4119
+ .rp-docs-row:last-child {
4120
+ border-bottom: 1px solid var(--border);
4121
+ }
4122
+ .rp-docs-dot {
4123
+ display: inline-block;
4124
+ width: 8px;
4125
+ height: 8px;
4126
+ background: var(--muted-foreground);
4127
+ }
4128
+ .rp-docs-dot--ok {
4129
+ background: var(--muted-foreground);
4130
+ }
4131
+ .rp-docs-dot--warn {
4132
+ background: var(--warning);
4133
+ }
4134
+ .rp-docs-dot--bad {
4135
+ background: var(--destructive);
4136
+ }
4137
+ .rp-docs-dot--none {
4138
+ background: transparent;
4139
+ border: 1px solid var(--muted-foreground);
4140
+ }
4141
+ .rp-docs-open {
4142
+ font-size: 12px;
4143
+ color: var(--muted-foreground);
4144
+ white-space: nowrap;
4145
+ text-underline-offset: 2px;
4146
+ }
4147
+ .rp-docs-detail {
4148
+ display: grid;
4149
+ grid-template-columns: 1fr 1fr;
4150
+ gap: 12px;
4151
+ padding: 4px 0 16px 24px;
4152
+ }
4153
+ .rp-docs-side {
4154
+ display: flex;
4155
+ flex-direction: column;
4156
+ align-items: flex-start;
4157
+ gap: 4px;
4158
+ min-width: 0;
4159
+ padding: 12px;
4160
+ border: 1px solid var(--border);
4161
+ }
4162
+ .rp-docs-side--new {
4163
+ border-color: var(--foreground);
4164
+ }
4165
+ .rp-docs-side__label {
4166
+ font-size: 12px;
4167
+ color: var(--muted-foreground);
4168
+ }
4169
+ .rp-docs-side__where {
4170
+ max-width: 100%;
4171
+ font-family: var(--mono);
4172
+ font-size: 12px;
4173
+ overflow-wrap: anywhere;
4174
+ }
4175
+ .rp-docs-side__edit {
4176
+ margin-bottom: 8px;
4177
+ font-size: 14px;
4178
+ }
4179
+ .rp-docs-diff {
4180
+ grid-column: 1 / -1;
4181
+ margin: 0;
4182
+ padding: 8px 10px;
4183
+ border: 1px solid var(--border);
4184
+ font-family: var(--mono);
4185
+ font-size: 12px;
4186
+ overflow-wrap: anywhere;
4187
+ }
4188
+ .rp-docs-more {
4189
+ grid-column: 1 / -1;
4190
+ display: flex;
4191
+ flex-wrap: wrap;
4192
+ gap: 8px;
4193
+ }
4194
+ .rp-docs-add {
4195
+ display: flex;
4196
+ flex-wrap: wrap;
4197
+ align-items: center;
4198
+ gap: 8px;
4199
+ padding: 6px 0 12px 24px;
4200
+ }
4201
+ .rp-docs-add__input {
4202
+ flex: 1 1 240px;
4203
+ }
4204
+ .rp-docs-add__err {
4205
+ flex-basis: 100%;
4206
+ font-size: 12px;
4207
+ color: var(--destructive);
4208
+ }
4209
+ @media (max-width: 640px) {
4210
+ .rp-docs-detail {
4211
+ grid-template-columns: 1fr;
4212
+ padding-left: 0;
4213
+ }
4214
+ }
4215
+
4070
4216
  /* -- layouts · .lg- the legacy and fixture shells (Run 3, slice 3D) -- */
4071
4217
  /* The legacy and fixture cockpit's two columns: the list head (a stack) and the rail's tile column. */
4072
4218
  .lg-stack {