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.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +199 -2
- package/SECURITY.md +50 -0
- package/dist/build.json +1 -0
- package/dist/server/agents/claude/accounts.js +105 -0
- package/dist/server/agents/claude/hooks/asks.js +135 -0
- package/dist/server/agents/claude/hooks/hooks.js +114 -0
- package/dist/server/agents/claude/hooks/status.js +34 -0
- package/dist/server/agents/claude/hooks/tracker.js +79 -0
- package/dist/server/agents/claude/offers/commands.js +219 -0
- package/dist/server/agents/claude/offers/files.js +51 -0
- package/dist/server/agents/claude/offers/initialize.js +59 -0
- package/dist/server/agents/claude/offers/models.js +20 -0
- package/dist/server/agents/claude/screen/choices.js +189 -0
- package/dist/server/agents/claude/screen/compacting.js +20 -0
- package/dist/server/agents/claude/screen/live.js +63 -0
- package/dist/server/agents/claude/screen/panel.js +140 -0
- package/dist/server/agents/claude/screen/prompt.js +23 -0
- package/dist/server/agents/claude/screen/screen.js +79 -0
- package/dist/server/agents/claude/terminals.js +110 -0
- package/dist/server/agents/claude/transcript/chat.js +254 -0
- package/dist/server/agents/claude/transcript/transcriptFile.js +194 -0
- package/dist/server/agents/claude/transcript/transcripts.js +210 -0
- package/dist/server/agents/claude/transcript/turn.js +136 -0
- package/dist/server/agents/claude/transcript/watch.js +60 -0
- package/dist/server/agents/claude/usage.js +36 -0
- package/dist/server/agents/codex/agent.js +263 -0
- package/dist/server/agents/codex/appServer.js +38 -0
- package/dist/server/agents/codex/items.js +226 -0
- package/dist/server/agents/codex/notifications.js +192 -0
- package/dist/server/agents/codex/rpc.js +121 -0
- package/dist/server/agents/codex/screen.js +5 -0
- package/dist/server/agents/codex/threads.js +208 -0
- package/dist/server/agents/types.js +1 -0
- package/dist/server/cli.js +49 -0
- package/dist/server/commands/account.js +61 -0
- package/dist/server/commands/attach.js +27 -0
- package/dist/server/commands/common.js +114 -0
- package/dist/server/commands/doctor.js +43 -0
- package/dist/server/commands/install.js +169 -0
- package/dist/server/commands/open.js +31 -0
- package/dist/server/commands/qr.js +33 -0
- package/dist/server/commands/serve.js +45 -0
- package/dist/server/commands/service.js +87 -0
- package/dist/server/commands/setup.js +36 -0
- package/dist/server/commands/setupWeb.js +87 -0
- package/dist/server/commands/status.js +32 -0
- package/dist/server/commands/wizard/probe.js +19 -0
- package/dist/server/commands/wizard/server.js +162 -0
- package/dist/server/http/app.js +144 -0
- package/dist/server/http/auth/guard.js +78 -0
- package/dist/server/http/auth/lockout.js +41 -0
- package/dist/server/http/auth/passkey.js +151 -0
- package/dist/server/http/auth/password.js +23 -0
- package/dist/server/http/auth/sessions.js +46 -0
- package/dist/server/http/routes/appUpdate.js +97 -0
- package/dist/server/http/routes/auth.js +79 -0
- package/dist/server/http/routes/chat.js +216 -0
- package/dist/server/http/routes/claude.js +69 -0
- package/dist/server/http/routes/codex.js +58 -0
- package/dist/server/http/routes/context.js +1 -0
- package/dist/server/http/routes/health.js +56 -0
- package/dist/server/http/routes/journal.js +35 -0
- package/dist/server/http/routes/mac.js +39 -0
- package/dist/server/http/routes/media.js +83 -0
- package/dist/server/http/routes/push.js +78 -0
- package/dist/server/http/routes/sessions.js +104 -0
- package/dist/server/http/routes/web.js +28 -0
- package/dist/server/http/serve.js +129 -0
- package/dist/server/http/sessionSocket.js +198 -0
- package/dist/server/ios-update/appUpdate.js +95 -0
- package/dist/server/ios-update/bundlePatch.js +56 -0
- package/dist/server/ios-update/bundlePatch.worker.js +11 -0
- package/dist/server/journal/db.js +243 -0
- package/dist/server/journal/derive.js +50 -0
- package/dist/server/journal/index.js +256 -0
- package/dist/server/journal/ingest.js +151 -0
- package/dist/server/notify/apns.js +126 -0
- package/dist/server/notify/push.js +109 -0
- package/dist/server/ops/awake.js +10 -0
- package/dist/server/ops/build.js +40 -0
- package/dist/server/ops/doctor.js +110 -0
- package/dist/server/ops/lastOp.js +34 -0
- package/dist/server/ops/problems.js +54 -0
- package/dist/server/ops/release.js +206 -0
- package/dist/server/ops/report.js +40 -0
- package/dist/server/ops/service.js +39 -0
- package/dist/server/ops/shutdown.js +12 -0
- package/dist/server/sessions/control.js +97 -0
- package/dist/server/sessions/events.js +77 -0
- package/dist/server/sessions/machine/accounts.js +55 -0
- package/dist/server/sessions/machine/input.js +208 -0
- package/dist/server/sessions/machine/lifecycle.js +357 -0
- package/dist/server/sessions/machine/moves.js +71 -0
- package/dist/server/sessions/machine/state.js +71 -0
- package/dist/server/sessions/machine/status.js +257 -0
- package/dist/server/sessions/machine/types.js +2 -0
- package/dist/server/sessions/machine.js +300 -0
- package/dist/server/sessions/outbox.js +194 -0
- package/dist/server/sessions/panes.js +60 -0
- package/dist/server/sessions/sessionName.js +7 -0
- package/dist/server/sessions/terminalEnv.js +40 -0
- package/dist/server/system/config.js +71 -0
- package/dist/server/system/dirs.js +54 -0
- package/dist/server/system/errors.js +37 -0
- package/dist/server/system/log.js +40 -0
- package/dist/server/system/messages.js +378 -0
- package/dist/server/system/node.js +6 -0
- package/dist/server/system/paths.js +51 -0
- package/dist/server/system/store.js +43 -0
- package/dist/server/system/tmux.js +198 -0
- package/dist/server/system/version.js +16 -0
- package/dist/server/uploads/fileUpload.js +171 -0
- package/dist/server/uploads/media.js +139 -0
- package/dist/web/assets/Setup-DinzDcF1.js +1 -0
- package/dist/web/assets/main-C8GiLoJy.css +1 -0
- package/dist/web/assets/main-w7i8PvU7.js +132 -0
- package/dist/web/icon-180.png +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon.svg +6 -0
- package/dist/web/index.html +25 -0
- package/dist/web/manifest.webmanifest +15 -0
- package/dist/web/sw.js +8 -0
- package/dist/web/theme.js +11 -0
- package/package.json +77 -4
- package/scripts/smoke.mjs +130 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// What the CLI says to the person at the Mac: Chinese when the Mac speaks it, English otherwise.
|
|
2
|
+
// The phone has its own dictionary (@workoffdesk/i18n); errors the server sends it go by code, not words.
|
|
3
|
+
// The same rule as the phone's.
|
|
4
|
+
export function pickLang(languages) {
|
|
5
|
+
for (const l of languages) {
|
|
6
|
+
if (/^zh\b/i.test(l))
|
|
7
|
+
return 'zh';
|
|
8
|
+
if (/^en\b/i.test(l))
|
|
9
|
+
return 'en';
|
|
10
|
+
}
|
|
11
|
+
return 'en';
|
|
12
|
+
}
|
|
13
|
+
// The locale the terminal runs in, as a language tag: zh_CN.UTF-8 is zh-CN.
|
|
14
|
+
export function systemLanguages(env = process.env) {
|
|
15
|
+
for (const name of ['LC_ALL', 'LC_MESSAGES', 'LANG']) {
|
|
16
|
+
const locale = env[name]?.replace(/[.@].*$/, '');
|
|
17
|
+
if (locale && locale !== 'C' && locale !== 'POSIX')
|
|
18
|
+
return [locale.replace(/_/g, '-')];
|
|
19
|
+
}
|
|
20
|
+
return [Intl.DateTimeFormat().resolvedOptions().locale];
|
|
21
|
+
}
|
|
22
|
+
export const en = {
|
|
23
|
+
cli: {
|
|
24
|
+
usage: 'Usage: workoffdesk <command>',
|
|
25
|
+
unknownCommand: (cmd) => `There is no command ${cmd}.`,
|
|
26
|
+
// Each command, in the order shown, with its forms; cli.ts runs exactly these names.
|
|
27
|
+
help: {
|
|
28
|
+
setup: [
|
|
29
|
+
['setup', 'Set the username and password; run again, a new password signs every device out'],
|
|
30
|
+
['setup --web', 'The setup in your browser, with the address and the service'],
|
|
31
|
+
],
|
|
32
|
+
install: [['install [--tunnel <name>]', 'Write the launchd service (it starts nothing) and print what to run']],
|
|
33
|
+
start: [['start', 'Start the service and wait for it to answer']],
|
|
34
|
+
stop: [['stop', 'Stop the service until the next login or start']],
|
|
35
|
+
restart: [['restart', 'Restart the service; sessions keep running']],
|
|
36
|
+
doctor: [['doctor', 'Check everything and print the fix for each failure']],
|
|
37
|
+
status: [['status --json', 'The checks, the version, the releases and the newest errors, as JSON']],
|
|
38
|
+
claude: [['claude [--resume <session id>]', 'Start Claude in this folder as a session and join it']],
|
|
39
|
+
codex: [['codex [--resume <thread id>]', 'Start Codex in this folder as a session and join it']],
|
|
40
|
+
attach: [['attach [id]', 'Join a session from this terminal; without an id, pick one']],
|
|
41
|
+
account: [
|
|
42
|
+
['account [--json]', 'The Claude accounts on this Mac, and the one new sessions start on'],
|
|
43
|
+
['account use <email or folder> [--move]', 'Start new sessions on it; with --move, move the open ones to it too'],
|
|
44
|
+
['account move', 'Move the open sessions to the account in use, each once its reply is done'],
|
|
45
|
+
],
|
|
46
|
+
serve: [['serve', 'Run the server in this terminal instead of as the service']],
|
|
47
|
+
deploy: [['deploy <build folder>', 'Install a build as a new release and switch to it']],
|
|
48
|
+
rollback: [['rollback', 'Go back to the release before']],
|
|
49
|
+
'--version': [['--version', 'Print the installed version']],
|
|
50
|
+
'--help': [['--help', 'Print this list']],
|
|
51
|
+
},
|
|
52
|
+
statusUsage: 'Usage: workoffdesk status --json (what the menu bar app reads; doctor is the one for people)',
|
|
53
|
+
notConfigured: (example) => `Not configured yet. Tell setup the address the site is reached at:\n workoffdesk setup --origin ${example}`,
|
|
54
|
+
askOrigin: (example) => `The address the site is reached at (the one the phone opens, like ${example}): `,
|
|
55
|
+
askUsername: 'Username: ',
|
|
56
|
+
emptyUsername: 'The username cannot be empty',
|
|
57
|
+
askPassword: (min) => `Password (at least ${min} characters): `,
|
|
58
|
+
askAgain: 'Again: ',
|
|
59
|
+
passwordsDiffer: 'The passwords do not match',
|
|
60
|
+
passwordTooShort: (min) => `The password needs at least ${min} characters`,
|
|
61
|
+
setUp: (username, origin, configFile, port) => `User ${username} is set up, at ${origin}. Config: ${configFile}
|
|
62
|
+
|
|
63
|
+
Next:
|
|
64
|
+
workoffdesk serve try it on this machine first (http://127.0.0.1:${port})
|
|
65
|
+
workoffdesk install install it as a service that starts at login, and see how to put a tunnel or reverse proxy in front
|
|
66
|
+
Before opening it to the internet, read SECURITY.md: whoever gets into this site gets this machine's terminal.`,
|
|
67
|
+
notSetUp: 'No user is set up yet. Run: workoffdesk setup',
|
|
68
|
+
scan: (origin) => `Open ${origin} on your phone with its camera:`,
|
|
69
|
+
running: (port, origin) => `workoffdesk is running on http://127.0.0.1:${port}, reached at ${origin}`,
|
|
70
|
+
noSessions: 'There are no sessions.',
|
|
71
|
+
whichOne: 'Which one? ',
|
|
72
|
+
noSuchNumber: 'There is no such number',
|
|
73
|
+
notRunning: 'The WorkOffDesk service is not running. Start it first (workoffdesk serve, or the installed service).',
|
|
74
|
+
couldNotOpen: (why) => `Could not open a session: ${why}`,
|
|
75
|
+
noTunnel: (name) => `There is no tunnel named ${name}. Run:\n cloudflared tunnel create ${name}\nthen workoffdesk install --tunnel ${name} again.`,
|
|
76
|
+
noCloudflared: 'cloudflared not found. Run brew install cloudflared first.',
|
|
77
|
+
notBuilt: (cliPath) => `Note: ${cliPath} does not exist yet; build first (pnpm build).`,
|
|
78
|
+
noCredentials: (file) => `Note: the tunnel credentials ${file} are not here; run the tunnel on the machine that created it.`,
|
|
79
|
+
frontDoor: (port, origin) => `The service listens only on http://127.0.0.1:${port}. Put something in front of it that brings ${origin} there:
|
|
80
|
+
a Cloudflare tunnel: cloudflared tunnel create <name>, then workoffdesk install --tunnel <name>
|
|
81
|
+
or any reverse proxy (Caddy, nginx…) forwarding ${origin} to http://127.0.0.1:${port}, with WebSocket support`,
|
|
82
|
+
installed: (o) => `node: ${o.nodePath} (after changing node versions, run install again with --node <path>)
|
|
83
|
+
Written:
|
|
84
|
+
${o.written.map((f) => ` ${f}`).join('\n')}
|
|
85
|
+
${o.warnings.length ? `\n${o.warnings.join('\n')}\n` : ''}
|
|
86
|
+
Commands to run now (only the first time):
|
|
87
|
+
${o.frontDoor}
|
|
88
|
+
|
|
89
|
+
To update later: workoffdesk deploy <build folder>; workoffdesk rollback if needed.
|
|
90
|
+
To restart the service only: launchctl kickstart -k gui/$(id -u)/${o.label}`,
|
|
91
|
+
deployUsage: 'Usage: workoffdesk deploy <build folder> [--new-app-runtime] (the folder pnpm deploy --prod makes)',
|
|
92
|
+
},
|
|
93
|
+
doctor: {
|
|
94
|
+
notFound: 'not found',
|
|
95
|
+
unknownVersion: (version) => `cannot tell its version (${version})`,
|
|
96
|
+
tooOld: (version, min) => `${version}; ${min} or later is needed`,
|
|
97
|
+
macosOnly: 'The service runs on macOS only (it uses launchd, and Claude Code sessions on this Mac).',
|
|
98
|
+
nodeFix: (min) => `Install node ${min} or later (brew install node, or nvm install 22), then run workoffdesk install again.`,
|
|
99
|
+
tmuxFix: 'brew install tmux',
|
|
100
|
+
claudeFix: 'Install Claude Code: curl -fsSL https://claude.ai/install.sh | bash',
|
|
101
|
+
noCodex: 'not installed (optional: for Codex sessions)',
|
|
102
|
+
setupFix: 'workoffdesk setup',
|
|
103
|
+
setUp: (user, origin) => `user ${user}, at ${origin}`,
|
|
104
|
+
notSetUp: (home) => `not set up yet (no config.json in ${home})`,
|
|
105
|
+
noUser: 'no user is set up yet',
|
|
106
|
+
running: (local, version) => `running on ${local}${version ? `, version ${version}` : ''}`,
|
|
107
|
+
notAnswering: (local) => `nothing answers on ${local}`,
|
|
108
|
+
otherVersion: (local, running, mine) => `${local} runs ${running}, but this is ${mine}`,
|
|
109
|
+
restartFix: (label) => `Restart it: launchctl kickstart -k gui/$(id -u)/${label}`,
|
|
110
|
+
installFix: 'Make it a service: workoffdesk install, then run the launchctl bootstrap line it prints (or workoffdesk serve to try it)',
|
|
111
|
+
bootstrapFix: (plist) => `Start it: launchctl bootstrap gui/$(id -u) ${plist}`,
|
|
112
|
+
kickstartFix: (label, log) => `Restart it: launchctl kickstart -k gui/$(id -u)/${label}; if it stops again, the reason is in ${log}`,
|
|
113
|
+
localOnly: (origin) => `${origin}: only this Mac can reach it`,
|
|
114
|
+
notReached: (origin) => `${origin} does not reach the service`,
|
|
115
|
+
originFix: (origin, local) => `Check the tunnel or reverse proxy that should bring ${origin} to ${local}.`,
|
|
116
|
+
sleepsOff: 'preventSleep is false: when the Mac idle-sleeps, the phone cannot reach it until someone wakes it',
|
|
117
|
+
sleepFix: (file) => `Remove "preventSleep": false from ${file}, then restart the service.`,
|
|
118
|
+
awake: 'the Mac does not idle-sleep while the service runs',
|
|
119
|
+
noApns: 'no push key: the iPhone app gets no notifications; web push works without it',
|
|
120
|
+
apnsKey: (keyId) => `push key ${keyId}`,
|
|
121
|
+
apnsFix: (file) => `Fix ${file}, or remove it if you do not use the iPhone app.`,
|
|
122
|
+
failed: 'Some checks failed; each ✗ says what to do.',
|
|
123
|
+
},
|
|
124
|
+
ops: {
|
|
125
|
+
started: (port) => `The service is running on http://127.0.0.1:${port}`,
|
|
126
|
+
stopped: 'The service is stopped; it starts again at the next login, or with workoffdesk start',
|
|
127
|
+
restarted: 'The service restarted',
|
|
128
|
+
notUp: (log) => `The service did not answer within 30 seconds; the reason is likely in ${log}`,
|
|
129
|
+
},
|
|
130
|
+
wizard: {
|
|
131
|
+
open: (url) => `Setup continues in the browser. If it did not open, open this address on this Mac:\n ${url}\nCtrl-C ends it.`,
|
|
132
|
+
idle: 'Nobody used the setup page for 30 minutes, so it ended. Run workoffdesk setup --web to start it again.',
|
|
133
|
+
done: 'Setup is done.',
|
|
134
|
+
notReady: 'Set up the account and the address first',
|
|
135
|
+
noTailscale: 'Tailscale is not running on this Mac',
|
|
136
|
+
tailscaleFailed: (out) => `tailscale serve failed: ${out}`,
|
|
137
|
+
noTunnel: (name) => `There is no Cloudflare tunnel named ${name} on this Mac`,
|
|
138
|
+
badHostname: 'The hostname is a name like workoffdesk.example.com, without https:// or a path',
|
|
139
|
+
routeFailed: (out) => `cloudflared could not point the hostname at the tunnel: ${out}`,
|
|
140
|
+
},
|
|
141
|
+
node: {
|
|
142
|
+
doesNotRun: (node) => `${node} does not run.`,
|
|
143
|
+
tooOld: (node, version, min) => `${node} is ${version}; WorkOffDesk needs node ${min} or later.`,
|
|
144
|
+
},
|
|
145
|
+
config: {
|
|
146
|
+
wrong: (field, expected) => `config.json: ${field} is wrong; it should be ${expected}`,
|
|
147
|
+
origin: 'the address the site is reached at, like https://workoffdesk.example.com (http://localhost:<port> to try it on this machine)',
|
|
148
|
+
port: 'a whole number from 1 to 65535',
|
|
149
|
+
rpId: (hostname) => `the site's hostname (${hostname}) or a domain above it`,
|
|
150
|
+
pinnedDirs: 'an array of folder paths',
|
|
151
|
+
serviceLabel: 'a launchd label of letters, digits, dots and hyphens',
|
|
152
|
+
claudeArgs: 'an array of arguments claude is started with, like ["--chrome"]',
|
|
153
|
+
preventSleep: 'true (keep the Mac from idle-sleeping while the service runs) or false',
|
|
154
|
+
missing: (file) => `set, but ${file} does not exist yet; run workoffdesk setup first`,
|
|
155
|
+
format: 'the format',
|
|
156
|
+
notJson: (why) => `valid JSON (${why})`,
|
|
157
|
+
},
|
|
158
|
+
release: {
|
|
159
|
+
notABuild: (dir) => `${dir} is not a build (it needs package.json and dist/server/cli.js)`,
|
|
160
|
+
smokeFailed: (version) => `${version} failed its smoke test; the running version is untouched`,
|
|
161
|
+
didNotComeUp: (version, prev) => `${version} did not come up within 30 seconds; ${prev ? `switched back to ${prev}` : 'there is no earlier version to switch back to'}`,
|
|
162
|
+
staleBuild: (version, built) => `This build says ${version}, but its dist ${built ? `was built for ${built}` : 'is not from a build that finished'}; run pnpm build again`,
|
|
163
|
+
deployed: (version, name) => `Deployed ${version} (${name})`,
|
|
164
|
+
builtFrom: (s) => s.commit ? `Built from ${s.commit.slice(0, 12)}${s.dirty ? ' with uncommitted changes' : ''}` : 'Built outside git',
|
|
165
|
+
appUpdate: (runtime) => (runtime ? `iPhone app update for runtime ${runtime}` : 'This build has no iPhone app update'),
|
|
166
|
+
appRuntimeChanged: (live, built) => `The live iPhone update is for runtime ${live}, but this build's ${built ? `is for ${built}` : 'is missing (was WORKOFFDESK_IOS_BUNDLE_ID set?)'}, so phones on ${live} would get no more updates. A change outside the app's JavaScript (its package.json or app config) can change the runtime. Deploy with --new-app-runtime only once a new build of the app is out`,
|
|
167
|
+
nothingToRollBackTo: 'There is no earlier version to roll back to',
|
|
168
|
+
rollbackFailed: (target, current) => `${target} did not come up; switched back to ${current}`,
|
|
169
|
+
rolledBack: (version, name) => `Rolled back to ${version} (${name})`,
|
|
170
|
+
},
|
|
171
|
+
// A Claude or Codex conversation open in a terminal on the Mac, taken over to go on in workoffdesk.
|
|
172
|
+
account: {
|
|
173
|
+
usage: 'Usage: workoffdesk account [--json] | account use <email or folder> [--move] | account move',
|
|
174
|
+
signedOut: 'Signed out',
|
|
175
|
+
notReady: 'Open Claude Code with it in a terminal once first',
|
|
176
|
+
apart: 'Its conversations are kept apart from this Mac’s',
|
|
177
|
+
notSeen: 'Not seen in use yet',
|
|
178
|
+
fiveHour: (p) => `5-hour ${p}`,
|
|
179
|
+
sevenDay: (p) => `7-day ${p}`,
|
|
180
|
+
reset: 'reset',
|
|
181
|
+
elsewhere: (n) => n === 1
|
|
182
|
+
? '1 open session is on another account. To move it: workoffdesk account move'
|
|
183
|
+
: `${n} open sessions are on another account. To move them: workoffdesk account move`,
|
|
184
|
+
moving: (n) => (n === 1 ? '1 session moves once its reply is done.' : `${n} sessions move once their reply is done.`),
|
|
185
|
+
noSuch: (who) => `There is no Claude account ${who} on this Mac.`,
|
|
186
|
+
twoWithEmail: (email, folders) => `More than one account is ${email}; name its folder instead: ${folders.join(', ')}`,
|
|
187
|
+
nonePicked: 'No account is picked yet. Pick one first: workoffdesk account use <email or folder>',
|
|
188
|
+
},
|
|
189
|
+
takeOver: {
|
|
190
|
+
openOnMac: (ttys, agent) => `this conversation is open in a terminal on the Mac${ttys.some(Boolean) ? ` (${ttys.filter(Boolean).join(', ')})` : ''}; quit ${agent} there first, or take it over from the phone`,
|
|
191
|
+
wouldNotQuit: (agent) => `${agent} in the terminal on the Mac did not quit within 10 seconds; nothing was resumed`,
|
|
192
|
+
openElsewhere: 'this conversation is open in another Codex app (an editor or the Codex app); close it there first',
|
|
193
|
+
// Said in the terminal it was quit in.
|
|
194
|
+
goneToPhone: (id) => `workoffdesk: this conversation now goes on in workoffdesk. To go on with it here: workoffdesk attach ${id}`,
|
|
195
|
+
},
|
|
196
|
+
goOn: {
|
|
197
|
+
onlyClaude: 'only a Claude session whose Claude quit is gone on with in place',
|
|
198
|
+
terminalBusy: "something runs in this session's terminal on the Mac; quit it there first, or start another conversation",
|
|
199
|
+
},
|
|
200
|
+
folderGone: (path) => `its folder is gone: ${path}`,
|
|
201
|
+
};
|
|
202
|
+
export const zh = {
|
|
203
|
+
cli: {
|
|
204
|
+
usage: '用法:workoffdesk <命令>',
|
|
205
|
+
unknownCommand: (cmd) => `没有 ${cmd} 这个命令。`,
|
|
206
|
+
help: {
|
|
207
|
+
setup: [
|
|
208
|
+
['setup', '设用户名和密码;再跑一次会换新密码,所有设备都要重新登录'],
|
|
209
|
+
['setup --web', '在浏览器里做设置,包括地址和服务'],
|
|
210
|
+
],
|
|
211
|
+
install: [['install [--tunnel <名字>]', '写好 launchd 服务(不启动),并打印接下来要跑的命令']],
|
|
212
|
+
start: [['start', '启动服务,等它应答']],
|
|
213
|
+
stop: [['stop', '停掉服务,直到下次登录或 start']],
|
|
214
|
+
restart: [['restart', '重启服务;会话继续跑']],
|
|
215
|
+
doctor: [['doctor', '检查所有项,每项没过都给出修复方法']],
|
|
216
|
+
status: [['status --json', '以 JSON 输出检查结果、版本、保留的各版本和最近的错误']],
|
|
217
|
+
claude: [['claude [--resume <会话 id>]', '在当前目录开一个 Claude 会话并接上']],
|
|
218
|
+
codex: [['codex [--resume <对话 id>]', '在当前目录开一个 Codex 会话并接上']],
|
|
219
|
+
attach: [['attach [id]', '在当前终端接上一个会话;不给 id 就让你挑']],
|
|
220
|
+
account: [
|
|
221
|
+
['account [--json]', '这台 Mac 上的 Claude 账号,以及新会话用哪一个'],
|
|
222
|
+
['account use <邮箱或目录> [--move]', '新会话改用它;加 --move 把开着的会话也搬过去'],
|
|
223
|
+
['account move', '把开着的会话搬到正在用的账号,每个等它这轮回复完再搬'],
|
|
224
|
+
],
|
|
225
|
+
serve: [['serve', '在当前终端里跑服务器,而不是作为后台服务']],
|
|
226
|
+
deploy: [['deploy <构建目录>', '把一个构建装成新版本并切过去']],
|
|
227
|
+
rollback: [['rollback', '回到上一个版本']],
|
|
228
|
+
'--version': [['--version', '打印装的版本']],
|
|
229
|
+
'--help': [['--help', '打印这张列表']],
|
|
230
|
+
},
|
|
231
|
+
statusUsage: '用法:workoffdesk status --json(给菜单栏 App 读的;给人看用 doctor)',
|
|
232
|
+
notConfigured: (example) => `还没有配置。告诉 setup 这个站点的访问地址:\n workoffdesk setup --origin ${example}`,
|
|
233
|
+
askOrigin: (example) => `站点的访问地址(手机打开的那个,比如 ${example}):`,
|
|
234
|
+
askUsername: '用户名:',
|
|
235
|
+
emptyUsername: '用户名不能为空',
|
|
236
|
+
askPassword: (min) => `密码(至少 ${min} 个字符):`,
|
|
237
|
+
askAgain: '再输一次:',
|
|
238
|
+
passwordsDiffer: '两次输入的密码不一致',
|
|
239
|
+
passwordTooShort: (min) => `密码至少要 ${min} 个字符`,
|
|
240
|
+
setUp: (username, origin, configFile, port) => `用户 ${username} 已设置好,地址 ${origin}。配置:${configFile}
|
|
241
|
+
|
|
242
|
+
下一步:
|
|
243
|
+
workoffdesk serve 先在本机试一下(http://127.0.0.1:${port})
|
|
244
|
+
workoffdesk install 装成登录时自动启动的服务,并看看怎么在前面接隧道或反向代理
|
|
245
|
+
开放到公网之前,先读 SECURITY.md:谁进得了这个站点,谁就拿到了这台电脑的终端。`,
|
|
246
|
+
notSetUp: '还没有设置用户。运行:workoffdesk setup',
|
|
247
|
+
scan: (origin) => `用手机相机扫一下,打开 ${origin}:`,
|
|
248
|
+
running: (port, origin) => `workoffdesk 正在 http://127.0.0.1:${port} 上运行,访问地址 ${origin}`,
|
|
249
|
+
noSessions: '没有会话。',
|
|
250
|
+
whichOne: '选哪一个?',
|
|
251
|
+
noSuchNumber: '没有这个编号',
|
|
252
|
+
notRunning: 'WorkOffDesk 服务没有在运行。先启动它(workoffdesk serve,或已安装的服务)。',
|
|
253
|
+
couldNotOpen: (why) => `打不开会话:${why}`,
|
|
254
|
+
noTunnel: (name) => `没有叫 ${name} 的隧道。运行:\n cloudflared tunnel create ${name}\n然后再运行一次 workoffdesk install --tunnel ${name}。`,
|
|
255
|
+
noCloudflared: '找不到 cloudflared。先运行 brew install cloudflared。',
|
|
256
|
+
notBuilt: (cliPath) => `注意:${cliPath} 还不存在,先构建(pnpm build)。`,
|
|
257
|
+
noCredentials: (file) => `注意:隧道凭据 ${file} 不在这台电脑上;请在创建这条隧道的电脑上运行它。`,
|
|
258
|
+
frontDoor: (port, origin) => `服务只监听 http://127.0.0.1:${port}。需要在它前面放一个能把 ${origin} 转到这里的东西:
|
|
259
|
+
Cloudflare 隧道:cloudflared tunnel create <名字>,然后 workoffdesk install --tunnel <名字>
|
|
260
|
+
或任何反向代理(Caddy、nginx…),把 ${origin} 转发到 http://127.0.0.1:${port},并支持 WebSocket`,
|
|
261
|
+
installed: (o) => `node:${o.nodePath}(换了 node 版本之后,用 --node <路径> 再运行一次 install)
|
|
262
|
+
已写入:
|
|
263
|
+
${o.written.map((f) => ` ${f}`).join('\n')}
|
|
264
|
+
${o.warnings.length ? `\n${o.warnings.join('\n')}\n` : ''}
|
|
265
|
+
现在要运行的命令(只需第一次):
|
|
266
|
+
${o.frontDoor}
|
|
267
|
+
|
|
268
|
+
以后更新:workoffdesk deploy <构建目录>;需要时 workoffdesk rollback。
|
|
269
|
+
只重启服务:launchctl kickstart -k gui/$(id -u)/${o.label}`,
|
|
270
|
+
deployUsage: '用法:workoffdesk deploy <构建目录> [--new-app-runtime](构建目录是 pnpm deploy --prod 生成的那个)',
|
|
271
|
+
},
|
|
272
|
+
doctor: {
|
|
273
|
+
notFound: '找不到',
|
|
274
|
+
unknownVersion: (version) => `看不出版本(${version})`,
|
|
275
|
+
tooOld: (version, min) => `${version};需要 ${min} 或更高`,
|
|
276
|
+
macosOnly: '服务只支持 macOS(它用 launchd,管的是这台 Mac 上的 Claude Code 会话)。',
|
|
277
|
+
nodeFix: (min) => `装 node ${min} 或更高版本(brew install node,或 nvm install 22),然后再运行一次 workoffdesk install。`,
|
|
278
|
+
tmuxFix: 'brew install tmux',
|
|
279
|
+
claudeFix: '安装 Claude Code:curl -fsSL https://claude.ai/install.sh | bash',
|
|
280
|
+
noCodex: '没有安装(可选:用来开 Codex 会话)',
|
|
281
|
+
setupFix: 'workoffdesk setup',
|
|
282
|
+
setUp: (user, origin) => `用户 ${user},地址 ${origin}`,
|
|
283
|
+
notSetUp: (home) => `还没有设置(${home} 里没有 config.json)`,
|
|
284
|
+
noUser: '还没有设置用户',
|
|
285
|
+
running: (local, version) => `在 ${local} 上运行${version ? `,版本 ${version}` : ''}`,
|
|
286
|
+
notAnswering: (local) => `${local} 上没有回应`,
|
|
287
|
+
otherVersion: (local, running, mine) => `${local} 上跑的是 ${running},这个命令是 ${mine}`,
|
|
288
|
+
restartFix: (label) => `重启它:launchctl kickstart -k gui/$(id -u)/${label}`,
|
|
289
|
+
installFix: '把它装成服务:workoffdesk install,再执行它打印的那行 launchctl bootstrap(或者用 workoffdesk serve 先试)',
|
|
290
|
+
bootstrapFix: (plist) => `启动它:launchctl bootstrap gui/$(id -u) ${plist}`,
|
|
291
|
+
kickstartFix: (label, log) => `重启它:launchctl kickstart -k gui/$(id -u)/${label};如果又停了,原因在 ${log}`,
|
|
292
|
+
localOnly: (origin) => `${origin}:只有这台 Mac 能访问`,
|
|
293
|
+
notReached: (origin) => `${origin} 到不了服务`,
|
|
294
|
+
originFix: (origin, local) => `检查把 ${origin} 转到 ${local} 的隧道或反向代理。`,
|
|
295
|
+
sleepsOff: 'preventSleep 是 false:Mac 空闲睡眠之后,手机就连不上,直到有人把它叫醒',
|
|
296
|
+
sleepFix: (file) => `把 ${file} 里的 "preventSleep": false 删掉,然后重启服务。`,
|
|
297
|
+
awake: '服务运行时 Mac 不会空闲睡眠',
|
|
298
|
+
noApns: '没有推送密钥:iPhone App 收不到通知;网页推送不受影响',
|
|
299
|
+
apnsKey: (keyId) => `推送密钥 ${keyId}`,
|
|
300
|
+
apnsFix: (file) => `改好 ${file};不用 iPhone App 的话删掉它。`,
|
|
301
|
+
failed: '有检查没通过;每个 ✗ 下面写了怎么办。',
|
|
302
|
+
},
|
|
303
|
+
ops: {
|
|
304
|
+
started: (port) => `服务已在 http://127.0.0.1:${port} 运行`,
|
|
305
|
+
stopped: '服务已停止;下次登录时会再启动,或运行 workoffdesk start',
|
|
306
|
+
restarted: '服务已重启',
|
|
307
|
+
notUp: (log) => `服务 30 秒内没有应答;原因多半在 ${log}`,
|
|
308
|
+
},
|
|
309
|
+
wizard: {
|
|
310
|
+
open: (url) => `接下来在浏览器里设置。如果没有自动打开,在这台 Mac 上打开这个地址:\n ${url}\n按 Ctrl-C 结束。`,
|
|
311
|
+
idle: '设置页 30 分钟没人用,已经结束。再运行 workoffdesk setup --web 重新开始。',
|
|
312
|
+
done: '设置完成。',
|
|
313
|
+
notReady: '先设好账号和地址',
|
|
314
|
+
noTailscale: '这台 Mac 上的 Tailscale 没有运行',
|
|
315
|
+
tailscaleFailed: (out) => `tailscale serve 失败:${out}`,
|
|
316
|
+
noTunnel: (name) => `这台 Mac 上没有叫 ${name} 的 Cloudflare 隧道`,
|
|
317
|
+
badHostname: '主机名是 workoffdesk.example.com 这样的名字,不带 https:// 也不带路径',
|
|
318
|
+
routeFailed: (out) => `cloudflared 没能把主机名指到隧道:${out}`,
|
|
319
|
+
},
|
|
320
|
+
node: {
|
|
321
|
+
doesNotRun: (node) => `${node} 运行不了。`,
|
|
322
|
+
tooOld: (node, version, min) => `${node} 是 ${version};WorkOffDesk 需要 node ${min} 或更高版本。`,
|
|
323
|
+
},
|
|
324
|
+
config: {
|
|
325
|
+
wrong: (field, expected) => `config.json:${field} 不对,应该是${expected}`,
|
|
326
|
+
origin: '站点的访问地址,比如 https://workoffdesk.example.com(在本机试用就用 http://localhost:<端口>)',
|
|
327
|
+
port: '1 到 65535 之间的整数',
|
|
328
|
+
rpId: (hostname) => `站点的主机名(${hostname})或它的上级域名`,
|
|
329
|
+
pinnedDirs: '文件夹路径的数组',
|
|
330
|
+
serviceLabel: '由字母、数字、点和连字符组成的 launchd 标签',
|
|
331
|
+
claudeArgs: '启动 claude 时带的参数数组,比如 ["--chrome"]',
|
|
332
|
+
preventSleep: 'true(服务运行时不让 Mac 空闲睡眠)或 false',
|
|
333
|
+
missing: (file) => `已设置的,但 ${file} 还不存在;先运行 workoffdesk setup`,
|
|
334
|
+
format: '格式',
|
|
335
|
+
notJson: (why) => `合法的 JSON(${why})`,
|
|
336
|
+
},
|
|
337
|
+
release: {
|
|
338
|
+
notABuild: (dir) => `${dir} 不是一个构建(需要有 package.json 和 dist/server/cli.js)`,
|
|
339
|
+
smokeFailed: (version) => `${version} 没通过冒烟测试;正在运行的版本没动`,
|
|
340
|
+
didNotComeUp: (version, prev) => `${version} 30 秒内没有起来;${prev ? `已切回 ${prev}` : '没有更早的版本可以切回'}`,
|
|
341
|
+
staleBuild: (version, built) => `这个构建写的是 ${version},但它的 dist ${built ? `是为 ${built} 构建的` : '不是一次完整构建产出的'};重新运行 pnpm build`,
|
|
342
|
+
deployed: (version, name) => `已部署 ${version}(${name})`,
|
|
343
|
+
builtFrom: (s) => (s.commit ? `构建自 ${s.commit.slice(0, 12)}${s.dirty ? ',带未提交的改动' : ''}` : '不是在 git 里构建的'),
|
|
344
|
+
appUpdate: (runtime) => (runtime ? `iPhone 应用更新,运行时 ${runtime}` : '这个构建没有 iPhone 应用更新'),
|
|
345
|
+
appRuntimeChanged: (live, built) => `线上的 iPhone 应用更新是给运行时 ${live} 的,这个构建的${built ? `却是给 ${built} 的` : '没有(设了 WORKOFFDESK_IOS_BUNDLE_ID 吗?)'},装着 ${live} 的手机就再也收不到更新。App 的 JavaScript 以外的改动(它的 package.json 或 app 配置)会改变运行时。只有 App 的新版本发出去以后,才用 --new-app-runtime 部署`,
|
|
346
|
+
nothingToRollBackTo: '没有更早的版本可以回滚',
|
|
347
|
+
rollbackFailed: (target, current) => `${target} 没有起来;已切回 ${current}`,
|
|
348
|
+
rolledBack: (version, name) => `已回滚到 ${version}(${name})`,
|
|
349
|
+
},
|
|
350
|
+
account: {
|
|
351
|
+
usage: '用法:workoffdesk account [--json] | account use <邮箱或目录> [--move] | account move',
|
|
352
|
+
signedOut: '已退出登录',
|
|
353
|
+
notReady: '先在终端里用它打开一次 Claude Code',
|
|
354
|
+
apart: '它的对话没有和这台 Mac 的共用',
|
|
355
|
+
notSeen: '还没看到它的用量',
|
|
356
|
+
fiveHour: (p) => `5 小时 ${p}`,
|
|
357
|
+
sevenDay: (p) => `7 天 ${p}`,
|
|
358
|
+
reset: '已重置',
|
|
359
|
+
elsewhere: (n) => `${n} 个开着的会话在别的账号上。搬过来:workoffdesk account move`,
|
|
360
|
+
moving: (n) => `${n} 个会话等这轮回复完就搬过来。`,
|
|
361
|
+
noSuch: (who) => `这台 Mac 上没有 ${who} 这个 Claude 账号。`,
|
|
362
|
+
twoWithEmail: (email, folders) => `不止一个账号是 ${email},改用目录指定:${folders.join('、')}`,
|
|
363
|
+
nonePicked: '还没有选过账号。先选一个:workoffdesk account use <邮箱或目录>',
|
|
364
|
+
},
|
|
365
|
+
takeOver: {
|
|
366
|
+
openOnMac: (ttys, agent) => `这个对话正开在 Mac 的终端里${ttys.some(Boolean) ? `(${ttys.filter(Boolean).join('、')})` : ''};先在那里退出 ${agent},或者从手机上接管`,
|
|
367
|
+
wouldNotQuit: (agent) => `Mac 终端里的 ${agent} 10 秒内没有退出;没有续上`,
|
|
368
|
+
openElsewhere: '这个对话开在别的 Codex 应用里(编辑器或 Codex 桌面版);先在那里关掉',
|
|
369
|
+
goneToPhone: (id) => `workoffdesk:这个对话已转到 workoffdesk 里接着进行。要在这台电脑上接着用:workoffdesk attach ${id}`,
|
|
370
|
+
},
|
|
371
|
+
goOn: {
|
|
372
|
+
onlyClaude: '只有 Claude 已退出的 Claude 会话能原地接着',
|
|
373
|
+
terminalBusy: '这个会话在 Mac 的终端里正跑着别的程序;先在那里退出它,或者重新开一个对话',
|
|
374
|
+
},
|
|
375
|
+
folderGone: (path) => `它的文件夹已经不在了:${path}`,
|
|
376
|
+
};
|
|
377
|
+
export const messagesFor = (languages) => (pickLang(languages) === 'zh' ? zh : en);
|
|
378
|
+
export const m = messagesFor(systemLanguages());
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @fastify/static loads ESM-only dependencies through require(); SQLite is built in from 22.13 (journal/).
|
|
2
|
+
export const MIN_NODE = [22, 13];
|
|
3
|
+
export function nodeTooOld(version) {
|
|
4
|
+
const [major, minor] = version.split('.').map(Number);
|
|
5
|
+
return major < MIN_NODE[0] || (major === MIN_NODE[0] && minor < MIN_NODE[1]);
|
|
6
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { chmodSync, closeSync, fsyncSync, mkdirSync, openSync, renameSync, writeSync } from 'node:fs';
|
|
3
|
+
import { homedir, tmpdir } from 'node:os';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
export function homeDir(env = process.env, userHome = homedir()) {
|
|
6
|
+
const dir = env.WORKOFFDESK_HOME || join(userHome, '.workoffdesk');
|
|
7
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
8
|
+
chmodSync(dir, 0o700);
|
|
9
|
+
return dir;
|
|
10
|
+
}
|
|
11
|
+
// The environment for a child process that must run in the given home and nowhere else; HOME follows.
|
|
12
|
+
export function envForHome(env, home) {
|
|
13
|
+
return { ...env, WORKOFFDESK_HOME: home, HOME: home };
|
|
14
|
+
}
|
|
15
|
+
// Where a home's unix socket `name` is: in the home, unless that path is too long for a socket address
|
|
16
|
+
// (about 104 bytes on macOS), then in a folder of the temp dir only this user can enter, named by the home.
|
|
17
|
+
const SOCKET_PATH_MAX = 100;
|
|
18
|
+
export function socketPath(home, name, tmp = tmpdir()) {
|
|
19
|
+
const inHome = join(resolve(home), name);
|
|
20
|
+
if (Buffer.byteLength(inHome) <= SOCKET_PATH_MAX)
|
|
21
|
+
return inHome;
|
|
22
|
+
const dir = join(tmp, `workoffdesk-${createHash('sha1').update(resolve(home)).digest('hex').slice(0, 12)}`);
|
|
23
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
24
|
+
chmodSync(dir, 0o700);
|
|
25
|
+
return join(dir, name);
|
|
26
|
+
}
|
|
27
|
+
// The tmux server a home's sessions live in: the default home has the plain name, any other home
|
|
28
|
+
// (a smoke test, a second install) gets one of its own.
|
|
29
|
+
export function tmuxSocketFor(home, userHome = homedir()) {
|
|
30
|
+
const dir = resolve(home);
|
|
31
|
+
if (dir === join(userHome, '.workoffdesk'))
|
|
32
|
+
return 'workoffdesk';
|
|
33
|
+
return `workoffdesk-${createHash('sha256').update(dir).digest('hex').slice(0, 8)}`;
|
|
34
|
+
}
|
|
35
|
+
export function writeFileAtomic(file, content) {
|
|
36
|
+
// Unique per write: the CLI and the server may write the same file at once.
|
|
37
|
+
const tmp = `${file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
38
|
+
const fd = openSync(tmp, 'w', 0o600);
|
|
39
|
+
try {
|
|
40
|
+
writeSync(fd, content);
|
|
41
|
+
fsyncSync(fd);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
closeSync(fd);
|
|
45
|
+
}
|
|
46
|
+
chmodSync(tmp, 0o600);
|
|
47
|
+
renameSync(tmp, file);
|
|
48
|
+
}
|
|
49
|
+
export function writeJsonAtomic(file, data) {
|
|
50
|
+
writeFileAtomic(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
51
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { writeJsonAtomic } from './paths.js';
|
|
4
|
+
// What a session is called: the name given it, else what its agent calls the conversation, else its
|
|
5
|
+
// directory's name. One kept without named was given its name unless that is the directory's.
|
|
6
|
+
export function shownTitle(meta) {
|
|
7
|
+
const named = meta.named ?? meta.title !== (basename(meta.cwd) || meta.cwd);
|
|
8
|
+
return named ? meta.title : (meta.customTitle ?? meta.aiTitle ?? meta.title);
|
|
9
|
+
}
|
|
10
|
+
export function emptyState() {
|
|
11
|
+
return { passkeys: [], logins: [], pushSubs: [], apnsDevices: [], sessions: {}, claudeNames: {} };
|
|
12
|
+
}
|
|
13
|
+
export class Store {
|
|
14
|
+
file;
|
|
15
|
+
state = emptyState();
|
|
16
|
+
version = '';
|
|
17
|
+
constructor(file) {
|
|
18
|
+
this.file = file;
|
|
19
|
+
this.reload();
|
|
20
|
+
}
|
|
21
|
+
get() {
|
|
22
|
+
this.reload();
|
|
23
|
+
return this.state;
|
|
24
|
+
}
|
|
25
|
+
update(fn) {
|
|
26
|
+
const next = structuredClone(this.get());
|
|
27
|
+
fn(next);
|
|
28
|
+
writeJsonAtomic(this.file, next);
|
|
29
|
+
this.state = next;
|
|
30
|
+
}
|
|
31
|
+
// Atomic writes replace the inode, so any write by another process changes the version.
|
|
32
|
+
reload() {
|
|
33
|
+
const version = fileVersion(this.file);
|
|
34
|
+
if (!version || version === this.version)
|
|
35
|
+
return;
|
|
36
|
+
this.state = { ...emptyState(), ...JSON.parse(readFileSync(this.file, 'utf8')) };
|
|
37
|
+
this.version = version;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function fileVersion(file) {
|
|
41
|
+
const st = statSync(file, { throwIfNoEntry: false });
|
|
42
|
+
return st ? `${st.ino}:${st.mtimeMs}:${st.size}` : '';
|
|
43
|
+
}
|