workoffdesk 0.0.0-stage → 1.0.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.
Files changed (128) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -2
  4. package/SECURITY.md +50 -0
  5. package/dist/build.json +1 -0
  6. package/dist/server/agents/claude/accounts.js +105 -0
  7. package/dist/server/agents/claude/hooks/asks.js +135 -0
  8. package/dist/server/agents/claude/hooks/hooks.js +114 -0
  9. package/dist/server/agents/claude/hooks/status.js +34 -0
  10. package/dist/server/agents/claude/hooks/tracker.js +79 -0
  11. package/dist/server/agents/claude/offers/commands.js +219 -0
  12. package/dist/server/agents/claude/offers/files.js +51 -0
  13. package/dist/server/agents/claude/offers/initialize.js +59 -0
  14. package/dist/server/agents/claude/offers/models.js +20 -0
  15. package/dist/server/agents/claude/screen/choices.js +189 -0
  16. package/dist/server/agents/claude/screen/compacting.js +20 -0
  17. package/dist/server/agents/claude/screen/live.js +63 -0
  18. package/dist/server/agents/claude/screen/panel.js +140 -0
  19. package/dist/server/agents/claude/screen/prompt.js +23 -0
  20. package/dist/server/agents/claude/screen/screen.js +79 -0
  21. package/dist/server/agents/claude/terminals.js +110 -0
  22. package/dist/server/agents/claude/transcript/chat.js +254 -0
  23. package/dist/server/agents/claude/transcript/transcriptFile.js +194 -0
  24. package/dist/server/agents/claude/transcript/transcripts.js +210 -0
  25. package/dist/server/agents/claude/transcript/turn.js +136 -0
  26. package/dist/server/agents/claude/transcript/watch.js +60 -0
  27. package/dist/server/agents/claude/usage.js +36 -0
  28. package/dist/server/agents/codex/agent.js +263 -0
  29. package/dist/server/agents/codex/appServer.js +38 -0
  30. package/dist/server/agents/codex/items.js +226 -0
  31. package/dist/server/agents/codex/notifications.js +192 -0
  32. package/dist/server/agents/codex/rpc.js +121 -0
  33. package/dist/server/agents/codex/screen.js +5 -0
  34. package/dist/server/agents/codex/threads.js +208 -0
  35. package/dist/server/agents/types.js +1 -0
  36. package/dist/server/cli.js +49 -0
  37. package/dist/server/commands/account.js +61 -0
  38. package/dist/server/commands/attach.js +27 -0
  39. package/dist/server/commands/common.js +114 -0
  40. package/dist/server/commands/doctor.js +43 -0
  41. package/dist/server/commands/install.js +169 -0
  42. package/dist/server/commands/open.js +31 -0
  43. package/dist/server/commands/qr.js +33 -0
  44. package/dist/server/commands/serve.js +45 -0
  45. package/dist/server/commands/service.js +87 -0
  46. package/dist/server/commands/setup.js +36 -0
  47. package/dist/server/commands/setupWeb.js +87 -0
  48. package/dist/server/commands/status.js +32 -0
  49. package/dist/server/commands/wizard/probe.js +19 -0
  50. package/dist/server/commands/wizard/server.js +162 -0
  51. package/dist/server/http/app.js +144 -0
  52. package/dist/server/http/auth/guard.js +78 -0
  53. package/dist/server/http/auth/lockout.js +41 -0
  54. package/dist/server/http/auth/passkey.js +151 -0
  55. package/dist/server/http/auth/password.js +23 -0
  56. package/dist/server/http/auth/sessions.js +46 -0
  57. package/dist/server/http/routes/appUpdate.js +97 -0
  58. package/dist/server/http/routes/auth.js +79 -0
  59. package/dist/server/http/routes/chat.js +216 -0
  60. package/dist/server/http/routes/claude.js +69 -0
  61. package/dist/server/http/routes/codex.js +58 -0
  62. package/dist/server/http/routes/context.js +1 -0
  63. package/dist/server/http/routes/health.js +56 -0
  64. package/dist/server/http/routes/journal.js +35 -0
  65. package/dist/server/http/routes/mac.js +39 -0
  66. package/dist/server/http/routes/media.js +83 -0
  67. package/dist/server/http/routes/push.js +78 -0
  68. package/dist/server/http/routes/sessions.js +104 -0
  69. package/dist/server/http/routes/web.js +28 -0
  70. package/dist/server/http/serve.js +129 -0
  71. package/dist/server/http/sessionSocket.js +198 -0
  72. package/dist/server/ios-update/appUpdate.js +95 -0
  73. package/dist/server/ios-update/bundlePatch.js +56 -0
  74. package/dist/server/ios-update/bundlePatch.worker.js +11 -0
  75. package/dist/server/journal/db.js +243 -0
  76. package/dist/server/journal/derive.js +50 -0
  77. package/dist/server/journal/index.js +256 -0
  78. package/dist/server/journal/ingest.js +151 -0
  79. package/dist/server/notify/apns.js +126 -0
  80. package/dist/server/notify/push.js +109 -0
  81. package/dist/server/ops/awake.js +10 -0
  82. package/dist/server/ops/build.js +40 -0
  83. package/dist/server/ops/doctor.js +110 -0
  84. package/dist/server/ops/lastOp.js +34 -0
  85. package/dist/server/ops/problems.js +54 -0
  86. package/dist/server/ops/release.js +206 -0
  87. package/dist/server/ops/report.js +40 -0
  88. package/dist/server/ops/service.js +39 -0
  89. package/dist/server/ops/shutdown.js +12 -0
  90. package/dist/server/sessions/control.js +97 -0
  91. package/dist/server/sessions/events.js +77 -0
  92. package/dist/server/sessions/machine/accounts.js +55 -0
  93. package/dist/server/sessions/machine/input.js +208 -0
  94. package/dist/server/sessions/machine/lifecycle.js +357 -0
  95. package/dist/server/sessions/machine/moves.js +71 -0
  96. package/dist/server/sessions/machine/state.js +71 -0
  97. package/dist/server/sessions/machine/status.js +257 -0
  98. package/dist/server/sessions/machine/types.js +2 -0
  99. package/dist/server/sessions/machine.js +300 -0
  100. package/dist/server/sessions/outbox.js +194 -0
  101. package/dist/server/sessions/panes.js +60 -0
  102. package/dist/server/sessions/sessionName.js +7 -0
  103. package/dist/server/sessions/terminalEnv.js +40 -0
  104. package/dist/server/system/config.js +71 -0
  105. package/dist/server/system/dirs.js +54 -0
  106. package/dist/server/system/errors.js +37 -0
  107. package/dist/server/system/log.js +40 -0
  108. package/dist/server/system/messages.js +378 -0
  109. package/dist/server/system/node.js +6 -0
  110. package/dist/server/system/paths.js +51 -0
  111. package/dist/server/system/store.js +43 -0
  112. package/dist/server/system/tmux.js +198 -0
  113. package/dist/server/system/version.js +16 -0
  114. package/dist/server/uploads/fileUpload.js +171 -0
  115. package/dist/server/uploads/media.js +139 -0
  116. package/dist/web/assets/Setup-DinzDcF1.js +1 -0
  117. package/dist/web/assets/main-C8GiLoJy.css +1 -0
  118. package/dist/web/assets/main-w7i8PvU7.js +132 -0
  119. package/dist/web/icon-180.png +0 -0
  120. package/dist/web/icon-192.png +0 -0
  121. package/dist/web/icon-512.png +0 -0
  122. package/dist/web/icon.svg +6 -0
  123. package/dist/web/index.html +25 -0
  124. package/dist/web/manifest.webmanifest +15 -0
  125. package/dist/web/sw.js +8 -0
  126. package/dist/web/theme.js +11 -0
  127. package/package.json +77 -4
  128. package/scripts/smoke.mjs +130 -0
@@ -0,0 +1,208 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { realpathSync } from 'node:fs';
3
+ import { open } from 'node:fs/promises';
4
+ import { basename, join } from 'node:path';
5
+ const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : undefined);
6
+ const str = (v) => (typeof v === 'string' ? v : '');
7
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
8
+ const LIMIT = 50;
9
+ // How long the list is kept: it takes the app server seconds to make.
10
+ const KEEP_MS = 5 * 60_000;
11
+ // How much of the end of a rollout is read for how its last turn went and what the person said last.
12
+ const TAIL = 64 * 1024;
13
+ const RUN_MS = 5_000;
14
+ const PROMPT_MAX = 200;
15
+ const run = (bin, args) => new Promise((resolve, reject) => execFile(bin, args, { timeout: RUN_MS, maxBuffer: 16 * 1024 * 1024 }, (err, stdout) =>
16
+ // lsof says 1 when some file asked about is open nowhere; what it found is still on stdout.
17
+ err && !stdout ? reject(err) : resolve(String(stdout))));
18
+ const real = (p) => {
19
+ try {
20
+ return realpathSync(p);
21
+ }
22
+ catch {
23
+ return p;
24
+ }
25
+ };
26
+ // A thread people opened themselves: from the TUI or an editor, not `codex exec` or a subagent.
27
+ function threadOf(v) {
28
+ const t = obj(v);
29
+ const source = t?.source;
30
+ if (!t || t.ephemeral === true || t.parentThreadId || (source !== 'cli' && source !== 'vscode'))
31
+ return null;
32
+ return anyThread(t);
33
+ }
34
+ function anyThread(t) {
35
+ const id = str(t?.id);
36
+ if (!t || !id)
37
+ return null;
38
+ return {
39
+ id,
40
+ cwd: str(t.cwd),
41
+ title: str(t.name).trim() || str(t.preview).replace(/\s+/g, ' ').trim(),
42
+ path: str(t.path) || null,
43
+ updatedAt: num(t.updatedAt) * 1000,
44
+ };
45
+ }
46
+ export class CodexThreads {
47
+ o;
48
+ kept = null;
49
+ listing = null;
50
+ asking = null;
51
+ locks;
52
+ constructor(o) {
53
+ this.o = o;
54
+ this.locks = join(o.codexHome, 'thread-writer-locks');
55
+ }
56
+ // The latest threads and every thread open somewhere now, newest first. Asked again while one runs, the one
57
+ // running answers.
58
+ list() {
59
+ this.asking ??= this.listNow().finally(() => {
60
+ this.asking = null;
61
+ });
62
+ return this.asking;
63
+ }
64
+ // One thread as list() has it, also when it is older than the list goes or not one list() shows; null when
65
+ // there is none.
66
+ async where(id) {
67
+ const listed = (await this.list()).find((t) => t.id === id);
68
+ if (listed)
69
+ return listed;
70
+ const thread = anyThread(await this.o.agent.readThread(id));
71
+ if (!thread)
72
+ return null;
73
+ const holders = await this.holders();
74
+ return this.live(thread, holders.get(id) ?? null);
75
+ }
76
+ async listNow() {
77
+ const [threads, holders] = await Promise.all([this.threads(), this.holders()]);
78
+ const known = new Set(threads.map((t) => t.id));
79
+ // Opened since the list was made: read on its own, which is quick.
80
+ const added = await Promise.all([...holders.keys()].filter((id) => !known.has(id)).map((id) => this.o.agent.readThread(id).then(threadOf, () => null)));
81
+ const all = [...threads, ...added.flatMap((t) => (t ? [t] : []))];
82
+ const shown = await Promise.all(all.map((t) => this.live(t, holders.get(t.id) ?? null)));
83
+ return shown.sort((a, b) => b.lastActivity - a.lastActivity);
84
+ }
85
+ // The kept list, made again in the background once old; the first ask waits for it.
86
+ threads() {
87
+ const fresh = this.kept && Date.now() - this.kept.at < KEEP_MS;
88
+ if (!fresh && !this.listing) {
89
+ this.listing = this.o.agent
90
+ .listThreads({ limit: LIMIT, sourceKinds: ['cli', 'vscode'] })
91
+ .then((data) => {
92
+ const threads = data.flatMap((d) => threadOf(d) ?? []);
93
+ this.kept = { at: Date.now(), threads };
94
+ return threads;
95
+ })
96
+ .finally(() => {
97
+ this.listing = null;
98
+ });
99
+ }
100
+ if (this.kept) {
101
+ this.listing?.catch(() => { });
102
+ return Promise.resolve(this.kept.threads);
103
+ }
104
+ return this.listing;
105
+ }
106
+ // The thread's state as its rollout says now.
107
+ async live(t, holder) {
108
+ const end = t.path ? await rolloutEnd(t.path).catch(() => null) : null;
109
+ return {
110
+ ...t,
111
+ holder,
112
+ // A turn left unended by a Codex that was quit is not one the Codex holding it now runs.
113
+ busy: holder?.at === 'terminal' && !!end?.busy && end.turnAt >= holder.since,
114
+ lastPrompt: end?.lastPrompt ?? null,
115
+ lastActivity: Math.round(Math.max(t.updatedAt, end?.mtime ?? 0)),
116
+ };
117
+ }
118
+ // Who holds each thread's lock, by thread id.
119
+ async holders() {
120
+ const out = await run('/usr/sbin/lsof', ['-Fpn', '+d', this.locks]).catch(() => '');
121
+ const held = new Map();
122
+ let pid = 0;
123
+ for (const line of out.split('\n')) {
124
+ if (line.startsWith('p'))
125
+ pid = Number(line.slice(1));
126
+ else if (line.startsWith('n') && line.endsWith('.lock') && pid)
127
+ held.set(basename(line.slice(1), '.lock'), pid);
128
+ }
129
+ if (!held.size)
130
+ return new Map();
131
+ const procs = await this.processes([...new Set(held.values())]);
132
+ return new Map([...held].flatMap(([id, p]) => {
133
+ const h = procs.get(p);
134
+ return h ? [[id, h]] : [];
135
+ }));
136
+ }
137
+ async processes(pids) {
138
+ const out = await run('/bin/ps', ['-E', '-ww', '-o', 'pid=,tty=,lstart=,command=', '-p', pids.join(',')]).catch(() => '');
139
+ const procs = new Map();
140
+ for (const line of out.split('\n')) {
141
+ const [, pid, tty, started, command] = /^\s*(\d+)\s+(\S+)\s+(\w{3}\s+\w{3}\s+\d+\s+[\d:]{8}\s+\d{4})\s(.*)$/.exec(line) ?? [];
142
+ if (!pid)
143
+ continue;
144
+ procs.set(Number(pid), holderOf({ pid: Number(pid), tty, since: Date.parse(started) || 0, command }, this.o.sock));
145
+ }
146
+ return procs;
147
+ }
148
+ }
149
+ // Where a process holding a thread's lock has it, by its line in ps (command with its environment after it);
150
+ // sock is this server's app server's. since: when the process started, to the second.
151
+ export function holderOf(p, sock) {
152
+ const listen = /\s--listen\s+unix:\/\/(\S+)/.exec(p.command)?.[1];
153
+ // Resolved when asked: the socket may not exist yet when the server starts, and /var is a link.
154
+ if (listen && real(listen) === real(sock))
155
+ return { at: 'here' };
156
+ if (/\sapp-server(\s|$)/.test(p.command) || /(^|\s)WORKOFFDESK_SESSION=\S/.test(p.command) || /(^|[\s/])codex\s+exec(\s|$)/.test(p.command))
157
+ return { at: 'elsewhere' };
158
+ return { at: 'terminal', pid: p.pid, tty: /^ttys?\d+$/.test(p.tty) ? p.tty : null, since: p.since };
159
+ }
160
+ const promptOf = (text) => [...text.replace(/\s+/g, ' ').trim()].slice(0, PROMPT_MAX).join('') || null;
161
+ // How a rollout ends: whether its last turn is still going and when it started, and what the person said last.
162
+ export async function rolloutEnd(path) {
163
+ const fh = await open(path, 'r');
164
+ try {
165
+ const { size, mtimeMs } = await fh.stat();
166
+ const from = Math.max(0, size - TAIL);
167
+ const buf = Buffer.alloc(size - from);
168
+ await fh.read(buf, 0, buf.length, from);
169
+ // A line the part read starts inside is not JSON on its own, so it is passed over below.
170
+ const lines = buf.toString('utf8').split('\n');
171
+ let busy = false;
172
+ let turnAt = 0;
173
+ let lastPrompt = null;
174
+ for (const line of lines) {
175
+ if (!line.includes('"event_msg"'))
176
+ continue;
177
+ let e;
178
+ let p;
179
+ try {
180
+ e = obj(JSON.parse(line));
181
+ p = obj(e?.payload);
182
+ }
183
+ catch {
184
+ continue;
185
+ }
186
+ const type = str(p?.type);
187
+ if (type === 'task_started') {
188
+ busy = true;
189
+ turnAt = Date.parse(str(e?.timestamp)) || 0;
190
+ }
191
+ else if (type === 'task_complete' || type === 'turn_aborted')
192
+ busy = false;
193
+ else if (type === 'item_completed' && obj(p?.item)?.type === 'UserMessage') {
194
+ const text = (Array.isArray(obj(p?.item)?.content) ? obj(p?.item).content : [])
195
+ .map((c) => (obj(c)?.type === 'text' ? str(obj(c)?.text) : ''))
196
+ .join(' ');
197
+ lastPrompt = promptOf(text) ?? lastPrompt;
198
+ }
199
+ // How Codex before September wrote it.
200
+ else if (type === 'user_message')
201
+ lastPrompt = promptOf(str(p?.message)) ?? lastPrompt;
202
+ }
203
+ return { busy, turnAt, lastPrompt, mtime: mtimeMs };
204
+ }
205
+ finally {
206
+ await fh.close();
207
+ }
208
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ import { account } from './commands/account.js';
3
+ import { attach } from './commands/attach.js';
4
+ import { doctor } from './commands/doctor.js';
5
+ import { install } from './commands/install.js';
6
+ import { openHere } from './commands/open.js';
7
+ import { serve } from './commands/serve.js';
8
+ import { deployCommand, restartCommand, rollbackCommand, startCommand, stopCommand } from './commands/service.js';
9
+ import { setup } from './commands/setup.js';
10
+ import { status } from './commands/status.js';
11
+ import { m } from './system/messages.js';
12
+ import { VERSION } from './system/version.js';
13
+ // Columns a string takes in a terminal: CJK characters take two.
14
+ const columns = (s) => [...s].reduce((n, c) => n + (/[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/.test(c) ? 2 : 1), 0);
15
+ function helpText() {
16
+ const rows = Object.values(m.cli.help).flat();
17
+ const width = Math.max(...rows.map(([form]) => columns(form)));
18
+ return [m.cli.usage, '', ...rows.map(([form, what]) => ` ${form}${' '.repeat(width - columns(form))} ${what}`)].join('\n');
19
+ }
20
+ const [cmd, ...rest] = process.argv.slice(2);
21
+ if (cmd === '--version' || cmd === '-v') {
22
+ console.log(VERSION);
23
+ process.exit(0);
24
+ }
25
+ if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
26
+ console.log(helpText());
27
+ process.exit(0);
28
+ }
29
+ const commands = {
30
+ setup,
31
+ serve,
32
+ doctor,
33
+ status,
34
+ attach,
35
+ account,
36
+ claude: (args) => openHere('claude', args),
37
+ codex: (args) => openHere('codex', args),
38
+ install,
39
+ start: startCommand,
40
+ stop: stopCommand,
41
+ restart: restartCommand,
42
+ deploy: deployCommand,
43
+ rollback: rollbackCommand,
44
+ };
45
+ if (!Object.hasOwn(commands, cmd)) {
46
+ console.error(`${m.cli.unknownCommand(cmd)}\n\n${helpText()}`);
47
+ process.exit(1);
48
+ }
49
+ await commands[cmd](rest);
@@ -0,0 +1,61 @@
1
+ import { askAccounts, askToMove, askToPick } from '../sessions/control.js';
2
+ import { m } from '../system/messages.js';
3
+ import { homeDir, socketPath } from '../system/paths.js';
4
+ import { fail } from './common.js';
5
+ // The account an email or folder names. An email more than one account has names none of them.
6
+ export function findAccount(view, who, w) {
7
+ const byFolder = view.accounts.find((a) => a.folder === who || (a.id && a.id === who));
8
+ if (byFolder)
9
+ return byFolder.id;
10
+ const byEmail = view.accounts.filter((a) => a.email.toLowerCase() === who.toLowerCase());
11
+ if (byEmail.length > 1)
12
+ throw new Error(w.twoWithEmail(byEmail[0].email, byEmail.map((a) => a.folder)));
13
+ if (!byEmail.length)
14
+ throw new Error(w.noSuch(who));
15
+ return byEmail[0].id;
16
+ }
17
+ // Why an account cannot be picked, else what it used when its sessions last said; a limit past its reset is reset.
18
+ function said(a, w, now) {
19
+ if (!a.signedIn)
20
+ return w.signedOut;
21
+ if (!a.ready)
22
+ return w.notReady;
23
+ if (!a.shared)
24
+ return w.apart;
25
+ const part = (l, label) => l && label(l.resetsAt !== undefined && l.resetsAt * 1000 <= now ? w.reset : `${Math.round(l.used)}%`);
26
+ const parts = [part(a.limits?.fiveHour, w.fiveHour), part(a.limits?.sevenDay, w.sevenDay)].filter(Boolean);
27
+ return parts.length ? parts.join(' · ') : w.notSeen;
28
+ }
29
+ // A line an account, the one in use checked; then the open sessions on another account, and those moving to it.
30
+ export function accountLines(view, w, now = Date.now()) {
31
+ const emails = Math.max(...view.accounts.map((a) => a.email.length));
32
+ const folders = Math.max(...view.accounts.map((a) => a.folder.length));
33
+ const lines = view.accounts.map((a) => `${a.id === view.inUse ? '✓' : ' '} ${a.email.padEnd(emails)} ${a.folder.padEnd(folders)} ${said(a, w, now)}`);
34
+ const sessions = [view.elsewhere && w.elsewhere(view.elsewhere), view.moving && w.moving(view.moving)].filter((l) => !!l);
35
+ return sessions.length ? [...lines, '', ...sessions] : lines;
36
+ }
37
+ export async function account(args) {
38
+ const sock = socketPath(homeDir(), 'control.sock');
39
+ const w = m.account;
40
+ const [sub, ...rest] = args;
41
+ const show = (view) => console.log(accountLines(view, w).join('\n'));
42
+ try {
43
+ if (sub === undefined)
44
+ return show(await askAccounts(sock));
45
+ if (sub === '--json')
46
+ return console.log(JSON.stringify(await askAccounts(sock)));
47
+ if (sub === 'move')
48
+ return show(await askToMove(sock));
49
+ const who = rest.find((a) => !a.startsWith('--'));
50
+ if (sub !== 'use' || !who)
51
+ return fail(w.usage);
52
+ const view = await askToPick(sock, findAccount(await askAccounts(sock), who, w));
53
+ show(rest.includes('--move') ? await askToMove(sock) : view);
54
+ }
55
+ catch (e) {
56
+ const code = e.code;
57
+ if (code === 'ENOENT' || code === 'ECONNREFUSED')
58
+ fail(m.cli.notRunning);
59
+ fail(code === 'account-none-picked' ? w.nonePicked : e.message);
60
+ }
61
+ }
@@ -0,0 +1,27 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { join } from 'node:path';
3
+ import { idOfTmuxName, tmuxName } from '../sessions/sessionName.js';
4
+ import { m } from '../system/messages.js';
5
+ import { homeDir, tmuxSocketFor } from '../system/paths.js';
6
+ import { shownTitle, Store } from '../system/store.js';
7
+ import { Tmux } from '../system/tmux.js';
8
+ import { ask, fail } from './common.js';
9
+ export async function attach(args) {
10
+ const home = homeDir();
11
+ const tmux = new Tmux({ socket: tmuxSocketFor(home), conf: join(home, 'tmux.conf') });
12
+ const ids = (await tmux.listSessions()).flatMap((s) => idOfTmuxName(s.name) ?? []);
13
+ if (!ids.length)
14
+ fail(m.cli.noSessions);
15
+ let id = args[0];
16
+ if (!id) {
17
+ const meta = new Store(join(home, 'state.json')).get().sessions;
18
+ ids.forEach((sid, i) => {
19
+ const info = meta[sid];
20
+ console.log(`${i + 1}) ${sid} ${info ? shownTitle(info) : ''} ${info?.cwd ?? ''}`);
21
+ });
22
+ const pick = Number(await ask(m.cli.whichOne));
23
+ id = ids[pick - 1] ?? fail(m.cli.noSuchNumber);
24
+ }
25
+ const r = spawnSync(tmux.bin, tmux.attachArgs(tmuxName(id)), { stdio: 'inherit', env: tmux.env() });
26
+ process.exit(r.status ?? 0);
27
+ }
@@ -0,0 +1,114 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { closeSync, existsSync, openSync } from 'node:fs';
3
+ import { delimiter, join } from 'node:path';
4
+ import { createInterface } from 'node:readline';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { endOp } from '../ops/lastOp.js';
7
+ import { ACTIVATED } from '../ops/release.js';
8
+ import { ConfigError, loadConfig, parseConfig } from '../system/config.js';
9
+ import { m } from '../system/messages.js';
10
+ import { originQr } from './qr.js';
11
+ // The package's parts, from this file: src/commands/ in the source, dist/server/commands/ in a build.
12
+ const fromHere = (path) => fileURLToPath(new URL(path, import.meta.url));
13
+ // The package folder: dist/server/commands/common.js is three below it in a build.
14
+ export const PACKAGE_ROOT = fromHere('../../../');
15
+ export const WEB_DIR = fromHere('../../web');
16
+ export const APP_UPDATE_DIR = fromHere('../../app-update/ios');
17
+ export const ACTIVATED_FILE = fromHere(`../../../${ACTIVATED}`);
18
+ // The CLI's entry; running from source (tsx), the built one.
19
+ export const CLI_ENTRY = fromHere(import.meta.url.endsWith('.ts') ? '../../dist/server/cli.js' : '../cli.js');
20
+ export const HEALTH_WAIT_MS = 30_000;
21
+ export function flag(args, name) {
22
+ const i = args.indexOf(`--${name}`);
23
+ return i >= 0 ? args[i + 1] : undefined;
24
+ }
25
+ export function fail(message, code = 1) {
26
+ process.stderr.write(`${message}\n`);
27
+ process.exit(code);
28
+ }
29
+ const ORIGIN_EXAMPLE = 'https://workoffdesk.example.com';
30
+ // The config setup will write: the one there is, with the origin replaced when one is given.
31
+ // With neither, a person at a terminal is asked for it until it is one that works.
32
+ export async function configForSetup(home, origin) {
33
+ const there = existsSync(join(home, 'config.json')) ? loadConfigOrFail(home) : null;
34
+ if (!origin && there)
35
+ return there;
36
+ if (!origin && !process.stdin.isTTY)
37
+ fail(m.cli.notConfigured(ORIGIN_EXAMPLE));
38
+ for (;;) {
39
+ const given = origin ?? (await ask(m.cli.askOrigin(ORIGIN_EXAMPLE))).trim();
40
+ try {
41
+ // Passkeys belong to the rpId, so it is kept unless the site moves to another address.
42
+ return parseConfig({ ...there, origin: given, rpId: there?.origin === given ? there.rpId : undefined });
43
+ }
44
+ catch (e) {
45
+ if (!(e instanceof ConfigError))
46
+ throw e;
47
+ if (origin)
48
+ fail(e.message);
49
+ console.log(e.message);
50
+ }
51
+ }
52
+ }
53
+ export function loadConfigOrFail(home, code = 1) {
54
+ try {
55
+ return loadConfig(home);
56
+ }
57
+ catch (e) {
58
+ if (e instanceof ConfigError)
59
+ fail(e.message, code);
60
+ throw e;
61
+ }
62
+ }
63
+ export async function readStdinLine() {
64
+ let data = '';
65
+ for await (const chunk of process.stdin)
66
+ data += chunk;
67
+ return data.split('\n')[0] ?? '';
68
+ }
69
+ export function ask(question, hidden = false) {
70
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
71
+ if (hidden) {
72
+ // Echo nothing after the prompt itself.
73
+ rl._writeToOutput = (s) => {
74
+ if (s.startsWith(question))
75
+ process.stdout.write(question);
76
+ };
77
+ }
78
+ return new Promise((resolve) => rl.question(question, (answer) => {
79
+ rl.close();
80
+ if (hidden)
81
+ process.stdout.write('\n');
82
+ resolve(answer);
83
+ }));
84
+ }
85
+ export function which(name) {
86
+ const dirs = [...(process.env.PATH ?? '').split(delimiter), '/opt/homebrew/bin', '/usr/local/bin'];
87
+ return dirs.map((d) => join(d, name)).find(existsSync);
88
+ }
89
+ export const exec = (cmd, args, o) => new Promise((done) => execFile(cmd, args, { env: o?.env }, (err, stdout, stderr) => done({ code: err ? Number(err.code) || 1 : 0, out: `${stdout}${stderr}` })));
90
+ // The CLI this server was started by, run apart from it: it outlives the server's restart, in a process
91
+ // group of its own, which launchd leaves alone when it stops the service.
92
+ export function spawnOp(home, op) {
93
+ const out = openSync(join(home, 'logs/ops.log'), 'a', 0o600);
94
+ try {
95
+ const child = spawn(process.execPath, [process.argv[1], op, '--record'], { detached: true, stdio: ['ignore', out, out] });
96
+ child.unref();
97
+ if (!child.pid)
98
+ throw new Error(`could not start the ${op}`);
99
+ return child.pid;
100
+ }
101
+ finally {
102
+ closeSync(out);
103
+ }
104
+ }
105
+ // A restart or rollback a screen asked for (routes/mac.ts) says how it ended in ops.json.
106
+ export const record = (args, home, outcome) => {
107
+ if (args.includes('--record'))
108
+ endOp(home, outcome);
109
+ };
110
+ export function showQr(origin) {
111
+ const qr = originQr(origin);
112
+ if (qr)
113
+ console.log(`\n${m.cli.scan(origin)}\n${qr}`);
114
+ }
@@ -0,0 +1,43 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { resolveClaude, resolveCodex } from '../http/serve.js';
5
+ import { formatChecks, runDoctor } from '../ops/doctor.js';
6
+ import { loadConfig } from '../system/config.js';
7
+ import { m } from '../system/messages.js';
8
+ import { homeDir } from '../system/paths.js';
9
+ import { VERSION } from '../system/version.js';
10
+ import { showQr } from './common.js';
11
+ export function doctorDeps(home) {
12
+ return {
13
+ platform: process.platform,
14
+ nodeVersion: process.versions.node,
15
+ home,
16
+ claudeBin: resolveClaude(),
17
+ codexBin: resolveCodex(),
18
+ version: VERSION,
19
+ launchAgents: join(homedir(), 'Library/LaunchAgents'),
20
+ run: (cmd, args) => new Promise((done) => execFile(cmd, args, { timeout: 10_000 }, (err, stdout) => done({ ok: !err, out: stdout }))),
21
+ fetch: async (url) => {
22
+ try {
23
+ const res = await fetch(url, { signal: AbortSignal.timeout(3_000) });
24
+ const body = res.ok ? (await res.json().catch(() => null)) : null;
25
+ return { ok: res.ok, version: body?.version };
26
+ }
27
+ catch {
28
+ return { ok: false };
29
+ }
30
+ },
31
+ };
32
+ }
33
+ export async function doctor() {
34
+ const home = homeDir();
35
+ const checks = await runDoctor(doctorDeps(home));
36
+ console.log(formatChecks(checks));
37
+ if (checks.find((c) => c.id === 'origin')?.ok)
38
+ showQr(loadConfig(home).origin);
39
+ if (checks.some((c) => !c.ok)) {
40
+ console.log(`\n${m.doctor.failed}`);
41
+ process.exit(1);
42
+ }
43
+ }
@@ -0,0 +1,169 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { homedir, userInfo } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { launchEntry } from '../ops/release.js';
6
+ import { m } from '../system/messages.js';
7
+ import { MIN_NODE, nodeTooOld } from '../system/node.js';
8
+ import { homeDir, writeFileAtomic } from '../system/paths.js';
9
+ import { CLI_ENTRY, fail, flag, loadConfigOrFail, which } from './common.js';
10
+ const xml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
11
+ function plist(label, args, log, env = {}) {
12
+ const envXml = Object.entries(env)
13
+ .map(([k, v]) => ` <key>${xml(k)}</key>\n <string>${xml(v)}</string>`)
14
+ .join('\n');
15
+ return `<?xml version="1.0" encoding="UTF-8"?>
16
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
17
+ <plist version="1.0">
18
+ <dict>
19
+ <key>Label</key>
20
+ <string>${xml(label)}</string>
21
+ <key>ProgramArguments</key>
22
+ <array>
23
+ ${args.map((a) => ` <string>${xml(a)}</string>`).join('\n')}
24
+ </array>
25
+ <key>RunAtLoad</key>
26
+ <true/>
27
+ <key>KeepAlive</key>
28
+ <true/>
29
+ <key>ThrottleInterval</key>
30
+ <integer>10</integer>
31
+ <key>ProcessType</key>
32
+ <string>Interactive</string>
33
+ <key>StandardOutPath</key>
34
+ <string>${xml(log)}</string>
35
+ <key>StandardErrorPath</key>
36
+ <string>${xml(log)}</string>${envXml
37
+ ? `
38
+ <key>EnvironmentVariables</key>
39
+ <dict>
40
+ ${envXml}
41
+ </dict>`
42
+ : ''}
43
+ </dict>
44
+ </plist>
45
+ `;
46
+ }
47
+ // launchd starts agents with PATH=/usr/bin:/bin:/usr/sbin:/sbin and no locale.
48
+ export function renderServicePlist(o) {
49
+ const path = [dirname(o.nodePath), join(o.userHome, '.local/bin'), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
50
+ return plist(o.label, [o.nodePath, o.cliPath, 'serve'], join(o.home, 'logs/server.log'), {
51
+ PATH: path.join(':'),
52
+ WORKOFFDESK_HOME: o.home,
53
+ SHELL: o.shell,
54
+ LANG: 'en_US.UTF-8',
55
+ });
56
+ }
57
+ const shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
58
+ export function renderWrapper(o) {
59
+ return `#!/bin/sh\n# Managed by workoffdesk install.\nexport WORKOFFDESK_HOME=${shellQuote(o.home)}\nexec ${shellQuote(o.nodePath)} ${shellQuote(o.cliPath)} "$@"\n`;
60
+ }
61
+ export function checkNode(node) {
62
+ let version;
63
+ try {
64
+ version = execFileSync(node, ['-p', 'process.versions.node'], { encoding: 'utf8' }).trim();
65
+ }
66
+ catch {
67
+ throw new Error(m.node.doesNotRun(node));
68
+ }
69
+ if (nodeTooOld(version))
70
+ throw new Error(m.node.tooOld(node, version, MIN_NODE.join('.')));
71
+ return node;
72
+ }
73
+ export function renderTunnelPlist(o) {
74
+ return plist(o.label, [o.cloudflaredPath, 'tunnel', '--config', join(o.home, 'cloudflared.yml'), 'run'], join(o.home, 'logs/tunnel.log'));
75
+ }
76
+ export function renderCloudflaredYml(o) {
77
+ return `# Managed by workoffdesk install. The default ~/.cloudflared/config.yml is left alone.
78
+ tunnel: ${o.tunnelId}
79
+ credentials-file: ${o.credentialsFile}
80
+ protocol: http2
81
+
82
+ ingress:
83
+ - hostname: ${o.hostname}
84
+ service: http://127.0.0.1:${o.port}
85
+ - service: http_status:404
86
+ `;
87
+ }
88
+ export function servicePath(userHome, label) {
89
+ return join(userHome, 'Library/LaunchAgents', `${label}.plist`);
90
+ }
91
+ // What install writes, for the CLI to print and the setup wizard to start: the service's agent, the workoffdesk
92
+ // command, and with a tunnel its agent and config. Starts nothing.
93
+ export function installService(o) {
94
+ const label = o.config.serviceLabel;
95
+ mkdirSync(o.agents, { recursive: true });
96
+ mkdirSync(o.binDir, { recursive: true });
97
+ mkdirSync(join(o.home, 'logs'), { recursive: true });
98
+ const servicePlist = join(o.agents, `${label}.plist`);
99
+ writeFileSync(servicePlist, renderServicePlist({ label, nodePath: o.nodePath, cliPath: o.cliPath, home: o.home, userHome: o.userHome, shell: o.shell }));
100
+ const wrapper = join(o.binDir, 'workoffdesk');
101
+ writeFileAtomic(wrapper, renderWrapper({ nodePath: o.nodePath, cliPath: o.cliPath, home: o.home }));
102
+ chmodSync(wrapper, 0o755);
103
+ const written = [wrapper, servicePlist];
104
+ const warnings = [!existsSync(o.cliPath) && m.cli.notBuilt(o.cliPath)];
105
+ let tunnelPlist;
106
+ if (o.tunnel) {
107
+ const tunnelLabel = `${label}-tunnel`;
108
+ const credentialsFile = join(o.userHome, '.cloudflared', `${o.tunnel.id}.json`);
109
+ tunnelPlist = join(o.agents, `${tunnelLabel}.plist`);
110
+ writeFileSync(tunnelPlist, renderTunnelPlist({ label: tunnelLabel, cloudflaredPath: o.tunnel.cloudflared, home: o.home }));
111
+ const yml = join(o.home, 'cloudflared.yml');
112
+ writeFileAtomic(yml, renderCloudflaredYml({ tunnelId: o.tunnel.id, credentialsFile, hostname: new URL(o.config.origin).hostname, port: o.config.port }));
113
+ written.push(tunnelPlist, yml);
114
+ warnings.push(!existsSync(credentialsFile) && m.cli.noCredentials(credentialsFile));
115
+ }
116
+ return { written, warnings: warnings.filter((w) => !!w), servicePlist, tunnelPlist };
117
+ }
118
+ function tunnelSetup(cloudflared, name) {
119
+ const tunnels = JSON.parse(execFileSync(cloudflared, ['tunnel', 'list', '--output', 'json'], { encoding: 'utf8' }) || '[]');
120
+ const tunnel = tunnels.find((t) => t.name === name);
121
+ if (!tunnel)
122
+ throw new Error(m.cli.noTunnel(name));
123
+ return tunnel;
124
+ }
125
+ // What install needs to know beyond the config: where node and the cli are, and the tunnel's id. Throws what
126
+ // is missing.
127
+ export function installPlan(args, home, config) {
128
+ const userHome = homedir();
129
+ const tunnelName = flag(args, 'tunnel');
130
+ const cloudflared = tunnelName ? (flag(args, 'cloudflared') ?? which('cloudflared')) : undefined;
131
+ if (tunnelName && !cloudflared)
132
+ throw new Error(m.cli.noCloudflared);
133
+ const tunnel = cloudflared && tunnelName ? tunnelSetup(cloudflared, tunnelName) : undefined;
134
+ const cliPath = launchEntry(CLI_ENTRY, home);
135
+ const nodePath = checkNode(flag(args, 'node') ?? process.execPath);
136
+ return {
137
+ home,
138
+ config,
139
+ userHome,
140
+ agents: flag(args, 'launch-agents-dir') ?? join(userHome, 'Library/LaunchAgents'),
141
+ binDir: flag(args, 'bin-dir') ?? join(userHome, '.local/bin'),
142
+ nodePath,
143
+ cliPath,
144
+ shell: process.env.SHELL || userInfo().shell || '/bin/zsh',
145
+ tunnel: tunnel && cloudflared ? { id: tunnel.id, cloudflared } : undefined,
146
+ tunnelName,
147
+ };
148
+ }
149
+ export function install(args) {
150
+ const home = homeDir();
151
+ const config = loadConfigOrFail(home);
152
+ let plan;
153
+ try {
154
+ plan = installPlan(args, home, config);
155
+ }
156
+ catch (e) {
157
+ fail(e.message);
158
+ }
159
+ const { written, warnings, servicePlist, tunnelPlist } = installService(plan);
160
+ const hostname = new URL(config.origin).hostname;
161
+ const frontDoor = tunnelPlist
162
+ ? ` cloudflared tunnel --config ${join(home, 'cloudflared.yml')} route dns ${plan.tunnelName} ${hostname}
163
+ launchctl bootstrap gui/$(id -u) ${servicePlist}
164
+ launchctl bootstrap gui/$(id -u) ${tunnelPlist}`
165
+ : ` launchctl bootstrap gui/$(id -u) ${servicePlist}
166
+
167
+ ${m.cli.frontDoor(config.port, config.origin)}`;
168
+ console.log(m.cli.installed({ nodePath: plan.nodePath, written, warnings, frontDoor, label: config.serviceLabel }));
169
+ }