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,31 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { askToOpen } from '../sessions/control.js';
|
|
4
|
+
import { tmuxName } from '../sessions/sessionName.js';
|
|
5
|
+
import { fromTerminal } from '../sessions/terminalEnv.js';
|
|
6
|
+
import { m } from '../system/messages.js';
|
|
7
|
+
import { homeDir, socketPath, tmuxSocketFor } from '../system/paths.js';
|
|
8
|
+
import { Tmux } from '../system/tmux.js';
|
|
9
|
+
import { fail, flag } from './common.js';
|
|
10
|
+
// A Claude or Codex session opened in this terminal's directory and joined at once: this Mac has it as a
|
|
11
|
+
// terminal, the phone as a chat.
|
|
12
|
+
export async function openHere(kind, args) {
|
|
13
|
+
const home = homeDir();
|
|
14
|
+
const resumeId = flag(args, 'resume');
|
|
15
|
+
let id;
|
|
16
|
+
try {
|
|
17
|
+
id = await askToOpen(socketPath(home, 'control.sock'), {
|
|
18
|
+
kind,
|
|
19
|
+
cwd: process.cwd(),
|
|
20
|
+
...(resumeId ? { resumeId } : {}),
|
|
21
|
+
...(kind === 'claude' ? { env: fromTerminal(process.env) } : {}),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
const code = e.code;
|
|
26
|
+
fail(code === 'ENOENT' || code === 'ECONNREFUSED' ? m.cli.notRunning : m.cli.couldNotOpen(e.message));
|
|
27
|
+
}
|
|
28
|
+
const tmux = new Tmux({ socket: tmuxSocketFor(home), conf: join(home, 'tmux.conf') });
|
|
29
|
+
const r = spawnSync(tmux.bin, tmux.attachArgs(tmuxName(id)), { stdio: 'inherit', env: tmux.env() });
|
|
30
|
+
process.exit(r.status ?? 0);
|
|
31
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import qrcode from 'qrcode-generator';
|
|
2
|
+
const BORDER = 2;
|
|
3
|
+
// Two modules to a character, the upper and the lower.
|
|
4
|
+
const HALF = { '11': '█', '10': '▀', '01': '▄', '00': ' ' };
|
|
5
|
+
// The address as a QR code for the phone's camera, drawn black on white whatever the terminal's colors.
|
|
6
|
+
// A local address has nothing for a phone to open, so it gets none.
|
|
7
|
+
export function originQr(origin) {
|
|
8
|
+
if (!origin.startsWith('https://'))
|
|
9
|
+
return '';
|
|
10
|
+
const qr = qrcode(0, 'M');
|
|
11
|
+
qr.addData(origin);
|
|
12
|
+
qr.make();
|
|
13
|
+
const n = qr.getModuleCount();
|
|
14
|
+
const size = n + 2 * BORDER;
|
|
15
|
+
const dark = (r, c) => r >= BORDER && c >= BORDER && r < BORDER + n && c < BORDER + n && qr.isDark(r - BORDER, c - BORDER);
|
|
16
|
+
const lines = [];
|
|
17
|
+
for (let r = 0; r < size; r += 2) {
|
|
18
|
+
let line = '';
|
|
19
|
+
for (let c = 0; c < size; c++)
|
|
20
|
+
line += HALF[`${+dark(r, c)}${+dark(r + 1, c)}`];
|
|
21
|
+
lines.push(`\x1b[30;47m${line}\x1b[0m`);
|
|
22
|
+
}
|
|
23
|
+
return lines.join('\n');
|
|
24
|
+
}
|
|
25
|
+
// The same, as an SVG for a page: the setup wizard's last step.
|
|
26
|
+
export function originQrSvg(origin) {
|
|
27
|
+
if (!origin.startsWith('https://'))
|
|
28
|
+
return '';
|
|
29
|
+
const qr = qrcode(0, 'M');
|
|
30
|
+
qr.addData(origin);
|
|
31
|
+
qr.make();
|
|
32
|
+
return qr.createSvgTag({ cellSize: 6, margin: 4 * 6, scalable: true });
|
|
33
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { NotSetUp, startServer } from '../http/serve.js';
|
|
3
|
+
import { keepAwake } from '../ops/awake.js';
|
|
4
|
+
import { runDoctor } from '../ops/doctor.js';
|
|
5
|
+
import { isManaged } from '../ops/service.js';
|
|
6
|
+
import { shutdownOnce } from '../ops/shutdown.js';
|
|
7
|
+
import { m } from '../system/messages.js';
|
|
8
|
+
import { homeDir } from '../system/paths.js';
|
|
9
|
+
import { ACTIVATED_FILE, APP_UPDATE_DIR, PACKAGE_ROOT, WEB_DIR, fail, loadConfigOrFail, spawnOp } from './common.js';
|
|
10
|
+
import { doctorDeps } from './doctor.js';
|
|
11
|
+
export async function serve() {
|
|
12
|
+
const home = homeDir();
|
|
13
|
+
const config = loadConfigOrFail(home, 78);
|
|
14
|
+
let server;
|
|
15
|
+
try {
|
|
16
|
+
server = await startServer({
|
|
17
|
+
home,
|
|
18
|
+
config,
|
|
19
|
+
webDir: WEB_DIR,
|
|
20
|
+
// A release is the whole package folder, dist/server/cli.js inside it.
|
|
21
|
+
appUpdate: { dir: APP_UPDATE_DIR, activatedFile: ACTIVATED_FILE },
|
|
22
|
+
ops: {
|
|
23
|
+
home,
|
|
24
|
+
managed: isManaged(process.env, config.serviceLabel),
|
|
25
|
+
startedAt: Date.now(),
|
|
26
|
+
buildRoot: PACKAGE_ROOT,
|
|
27
|
+
doctor: (messages) => runDoctor({ ...doctorDeps(home), messages }),
|
|
28
|
+
spawnOp: (op) => spawnOp(home, op),
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
if (e instanceof NotSetUp)
|
|
34
|
+
fail(m.cli.notSetUp);
|
|
35
|
+
throw e;
|
|
36
|
+
}
|
|
37
|
+
await server.app.listen({ port: config.port, host: '127.0.0.1' });
|
|
38
|
+
server.app.log.info({ port: config.port, origin: config.origin }, 'started');
|
|
39
|
+
keepAwake({ enabled: config.preventSleep, platform: process.platform, pid: process.pid, spawn, log: (msg, err) => server.app.log.warn({ err }, msg) });
|
|
40
|
+
if (process.stdout.isTTY)
|
|
41
|
+
console.log(m.cli.running(config.port, config.origin));
|
|
42
|
+
const stop = shutdownOnce(server.close, { timeoutMs: 10_000, exit: (code) => process.exit(code) });
|
|
43
|
+
process.on('SIGTERM', stop);
|
|
44
|
+
process.on('SIGINT', stop);
|
|
45
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { createServer } from 'node:net';
|
|
4
|
+
import { homedir, tmpdir } from 'node:os';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { deploy, healthOn, rollback } from '../ops/release.js';
|
|
7
|
+
import { restartService, startService, stopService } from '../ops/service.js';
|
|
8
|
+
import { m } from '../system/messages.js';
|
|
9
|
+
import { envForHome, homeDir } from '../system/paths.js';
|
|
10
|
+
import { HEALTH_WAIT_MS, exec, fail, loadConfigOrFail, record } from './common.js';
|
|
11
|
+
import { servicePath } from './install.js';
|
|
12
|
+
function freePort() {
|
|
13
|
+
return new Promise((done, reject) => {
|
|
14
|
+
const s = createServer().once('error', reject);
|
|
15
|
+
s.listen(0, '127.0.0.1', () => {
|
|
16
|
+
const { port } = s.address();
|
|
17
|
+
s.close(() => done(port));
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
// Runs the build's own smoke script against a throwaway home, with the node this command runs on.
|
|
22
|
+
async function smoke(appDir) {
|
|
23
|
+
const home = mkdtempSync(join(tmpdir(), 'ts-smoke-'));
|
|
24
|
+
try {
|
|
25
|
+
const port = await freePort();
|
|
26
|
+
const child = spawn(process.execPath, [join(appDir, 'scripts/smoke.mjs')], {
|
|
27
|
+
env: { ...envForHome(process.env, home), APP: appDir, PORT: String(port), SMOKE_HOME: home },
|
|
28
|
+
stdio: 'inherit',
|
|
29
|
+
});
|
|
30
|
+
return await new Promise((done) => child.once('exit', (code) => done(code === 0)));
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
rmSync(home, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function releaseDeps() {
|
|
37
|
+
const home = homeDir();
|
|
38
|
+
const config = loadConfigOrFail(home);
|
|
39
|
+
return { home, label: config.serviceLabel, uid: process.getuid(), exec, smoke, health: healthOn(config.port, HEALTH_WAIT_MS) };
|
|
40
|
+
}
|
|
41
|
+
function serviceDeps() {
|
|
42
|
+
const home = homeDir();
|
|
43
|
+
const config = loadConfigOrFail(home);
|
|
44
|
+
return { home, port: config.port, label: config.serviceLabel, uid: process.getuid(), exec };
|
|
45
|
+
}
|
|
46
|
+
export async function startCommand() {
|
|
47
|
+
const d = serviceDeps();
|
|
48
|
+
const plist = servicePath(homedir(), d.label);
|
|
49
|
+
if (!existsSync(plist))
|
|
50
|
+
fail(m.doctor.installFix);
|
|
51
|
+
await startService(d, plist).catch((e) => fail(e.message));
|
|
52
|
+
const up = await healthOn(d.port, HEALTH_WAIT_MS)(null);
|
|
53
|
+
console.log(up ? m.ops.started(d.port) : m.ops.notUp(join(d.home, 'logs/server.log')));
|
|
54
|
+
if (!up)
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
export async function stopCommand() {
|
|
58
|
+
await stopService(serviceDeps()).catch((e) => fail(e.message));
|
|
59
|
+
console.log(m.ops.stopped);
|
|
60
|
+
}
|
|
61
|
+
export async function restartCommand(args) {
|
|
62
|
+
const d = serviceDeps();
|
|
63
|
+
await restartService(d);
|
|
64
|
+
const up = await healthOn(d.port, HEALTH_WAIT_MS)(null);
|
|
65
|
+
const message = up ? m.ops.restarted : m.ops.notUp(join(d.home, 'logs/server.log'));
|
|
66
|
+
record(args, d.home, { ok: up, message });
|
|
67
|
+
console.log(message);
|
|
68
|
+
if (!up)
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
export async function deployCommand(args) {
|
|
72
|
+
const [dir, ...flags] = args;
|
|
73
|
+
if (!dir || dir.startsWith('-') || flags.some((f) => f !== '--new-app-runtime'))
|
|
74
|
+
fail(m.cli.deployUsage);
|
|
75
|
+
const r = await deploy(resolve(dir), releaseDeps(), { newAppRuntime: flags.includes('--new-app-runtime') });
|
|
76
|
+
console.log(r.message);
|
|
77
|
+
if (!r.ok)
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
export async function rollbackCommand(args) {
|
|
81
|
+
const d = releaseDeps();
|
|
82
|
+
const r = await rollback(d);
|
|
83
|
+
record(args, d.home, r);
|
|
84
|
+
console.log(r.message);
|
|
85
|
+
if (!r.ok)
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { hashPassword, MIN_PASSWORD } from '../http/auth/password.js';
|
|
3
|
+
import { writeConfig } from '../system/config.js';
|
|
4
|
+
import { m } from '../system/messages.js';
|
|
5
|
+
import { homeDir } from '../system/paths.js';
|
|
6
|
+
import { Store } from '../system/store.js';
|
|
7
|
+
import { ask, configForSetup, fail, flag, readStdinLine, showQr } from './common.js';
|
|
8
|
+
import { setupWeb } from './setupWeb.js';
|
|
9
|
+
export async function setup(args) {
|
|
10
|
+
if (args.includes('--web'))
|
|
11
|
+
return setupWeb();
|
|
12
|
+
const home = homeDir();
|
|
13
|
+
const config = await configForSetup(home, flag(args, 'origin'));
|
|
14
|
+
const username = flag(args, 'username') ?? (await ask(m.cli.askUsername)).trim();
|
|
15
|
+
if (!username)
|
|
16
|
+
fail(m.cli.emptyUsername);
|
|
17
|
+
let password;
|
|
18
|
+
if (process.stdin.isTTY) {
|
|
19
|
+
password = await ask(m.cli.askPassword(MIN_PASSWORD), true);
|
|
20
|
+
if ((await ask(m.cli.askAgain, true)) !== password)
|
|
21
|
+
fail(m.cli.passwordsDiffer);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
password = await readStdinLine();
|
|
25
|
+
}
|
|
26
|
+
if ([...password].length < MIN_PASSWORD)
|
|
27
|
+
fail(m.cli.passwordTooShort(MIN_PASSWORD));
|
|
28
|
+
const hash = await hashPassword(password);
|
|
29
|
+
new Store(join(home, 'state.json')).update((s) => {
|
|
30
|
+
s.user = { username, hash };
|
|
31
|
+
s.logins = [];
|
|
32
|
+
});
|
|
33
|
+
writeConfig(home, config);
|
|
34
|
+
console.log(m.cli.setUp(username, config.origin, join(home, 'config.json'), config.port));
|
|
35
|
+
showQr(config.origin);
|
|
36
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { runDoctor } from '../ops/doctor.js';
|
|
6
|
+
import { healthOn } from '../ops/release.js';
|
|
7
|
+
import { startService } from '../ops/service.js';
|
|
8
|
+
import { loadConfig } from '../system/config.js';
|
|
9
|
+
import { m } from '../system/messages.js';
|
|
10
|
+
import { homeDir } from '../system/paths.js';
|
|
11
|
+
import { HEALTH_WAIT_MS, WEB_DIR, exec, which } from './common.js';
|
|
12
|
+
import { doctorDeps } from './doctor.js';
|
|
13
|
+
import { installPlan, installService } from './install.js';
|
|
14
|
+
import { tailscaleOf, tunnelsOf } from './wizard/probe.js';
|
|
15
|
+
import { buildWizard } from './wizard/server.js';
|
|
16
|
+
const TAILSCALE_APP = '/Applications/Tailscale.app/Contents/MacOS/Tailscale';
|
|
17
|
+
// The Mac App Store's Tailscale puts no command on PATH; its app carries one.
|
|
18
|
+
const tool = (name) => which(name) ?? (name === 'tailscale' && existsSync(TAILSCALE_APP) ? TAILSCALE_APP : undefined);
|
|
19
|
+
const stdoutOf = (cmd, args) => new Promise((done) => execFile(cmd, args, { timeout: 10_000 }, (err, stdout) => done(err ? null : stdout)));
|
|
20
|
+
async function probeRemote() {
|
|
21
|
+
const tailscale = tool('tailscale');
|
|
22
|
+
const cloudflared = tool('cloudflared');
|
|
23
|
+
const [ts, cf] = await Promise.all([
|
|
24
|
+
tailscale ? stdoutOf(tailscale, ['status', '--json']) : null,
|
|
25
|
+
cloudflared ? stdoutOf(cloudflared, ['tunnel', 'list', '--output', 'json']) : null,
|
|
26
|
+
]);
|
|
27
|
+
return { tailscale: tailscale ? tailscaleOf(ts) : null, cloudflared: cloudflared ? { tunnels: tunnelsOf(cf) } : null };
|
|
28
|
+
}
|
|
29
|
+
// The wizard's service step: what install writes, the tunnel's DNS record, both agents started, and the
|
|
30
|
+
// service waited for.
|
|
31
|
+
async function installFromWizard(home, tunnelName) {
|
|
32
|
+
try {
|
|
33
|
+
const config = loadConfig(home);
|
|
34
|
+
const plan = installPlan(tunnelName ? ['--tunnel', tunnelName] : [], home, config);
|
|
35
|
+
const { warnings, servicePlist, tunnelPlist } = installService(plan);
|
|
36
|
+
const d = { uid: process.getuid(), label: config.serviceLabel, exec };
|
|
37
|
+
if (plan.tunnel && tunnelName && tunnelPlist) {
|
|
38
|
+
const route = await exec(plan.tunnel.cloudflared, ['tunnel', '--config', join(home, 'cloudflared.yml'), 'route', 'dns', tunnelName, new URL(config.origin).hostname]);
|
|
39
|
+
// A record the hostname already has is left as it is, as cloudflared does.
|
|
40
|
+
if (route.code !== 0 && !/already exists/i.test(route.out))
|
|
41
|
+
return { ok: false, message: m.wizard.routeFailed(route.out.trim()) };
|
|
42
|
+
await startService({ ...d, label: `${config.serviceLabel}-tunnel` }, tunnelPlist);
|
|
43
|
+
}
|
|
44
|
+
await startService(d, servicePlist);
|
|
45
|
+
const up = await healthOn(config.port, HEALTH_WAIT_MS)(null);
|
|
46
|
+
return { ok: up, message: [up ? m.ops.started(config.port) : m.ops.notUp(join(home, 'logs/server.log')), ...warnings].join('\n') };
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
return { ok: false, message: e.message };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const WIZARD_IDLE_MS = 30 * 60_000;
|
|
53
|
+
// The first install in the browser (wizard/server.ts), on loopback at a free port, guarded by a token that
|
|
54
|
+
// travels in the address's fragment.
|
|
55
|
+
export async function setupWeb() {
|
|
56
|
+
const home = homeDir();
|
|
57
|
+
const token = randomBytes(24).toString('base64url');
|
|
58
|
+
let host = '';
|
|
59
|
+
let idle;
|
|
60
|
+
const end = (message) => {
|
|
61
|
+
clearTimeout(idle);
|
|
62
|
+
console.log(message);
|
|
63
|
+
void app.close().finally(() => process.exit(0));
|
|
64
|
+
};
|
|
65
|
+
const app = await buildWizard({
|
|
66
|
+
home,
|
|
67
|
+
webDir: WEB_DIR,
|
|
68
|
+
token,
|
|
69
|
+
host: () => host,
|
|
70
|
+
doctor: () => runDoctor(doctorDeps(home)),
|
|
71
|
+
probe: probeRemote,
|
|
72
|
+
run: (cmd, args) => exec(tool(cmd) ?? cmd, args),
|
|
73
|
+
install: (o) => installFromWizard(home, o.tunnel),
|
|
74
|
+
// After the answer has gone out.
|
|
75
|
+
onDone: () => setTimeout(() => end(m.wizard.done), 200),
|
|
76
|
+
touch: () => {
|
|
77
|
+
clearTimeout(idle);
|
|
78
|
+
idle = setTimeout(() => end(m.wizard.idle), WIZARD_IDLE_MS);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
await app.listen({ host: '127.0.0.1', port: 0 });
|
|
82
|
+
host = `127.0.0.1:${app.server.address().port}`;
|
|
83
|
+
const url = `http://${host}/setup#${token}`;
|
|
84
|
+
console.log(m.wizard.open(url));
|
|
85
|
+
execFile('open', [url], () => { });
|
|
86
|
+
idle = setTimeout(() => end(m.wizard.idle), WIZARD_IDLE_MS);
|
|
87
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { runDoctor } from '../ops/doctor.js';
|
|
3
|
+
import { recentProblems } from '../ops/problems.js';
|
|
4
|
+
import { macReport } from '../ops/report.js';
|
|
5
|
+
import { loadConfig } from '../system/config.js';
|
|
6
|
+
import { m } from '../system/messages.js';
|
|
7
|
+
import { homeDir } from '../system/paths.js';
|
|
8
|
+
import { PACKAGE_ROOT, fail } from './common.js';
|
|
9
|
+
import { doctorDeps } from './doctor.js';
|
|
10
|
+
// What the menu bar app shows: the checks, the build, the releases and the recent problems, as JSON.
|
|
11
|
+
export async function status(args) {
|
|
12
|
+
if (!args.includes('--json'))
|
|
13
|
+
fail(m.cli.statusUsage);
|
|
14
|
+
const home = homeDir();
|
|
15
|
+
let config = null;
|
|
16
|
+
try {
|
|
17
|
+
config = loadConfig(home);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// not set up yet: the checks say so
|
|
21
|
+
}
|
|
22
|
+
const report = await macReport({ home, managed: false, startedAt: 0, buildRoot: PACKAGE_ROOT, doctor: (messages) => runDoctor({ ...doctorDeps(home), messages }), spawnOp: () => 0 }, m);
|
|
23
|
+
const { managed: _managed, startedAt: _startedAt, ...rest } = report;
|
|
24
|
+
console.log(JSON.stringify({
|
|
25
|
+
...rest,
|
|
26
|
+
home,
|
|
27
|
+
origin: config?.origin ?? null,
|
|
28
|
+
port: config?.port ?? null,
|
|
29
|
+
running: report.checks.some((c) => c.id === 'service' && c.ok),
|
|
30
|
+
problems: recentProblems(join(home, 'logs/workoffdesk.log'), 20),
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// What the wizard reads of `tailscale status --json` and `cloudflared tunnel list --output json`; out is the
|
|
2
|
+
// command's stdout, null when it failed to run.
|
|
3
|
+
const parse = (out) => {
|
|
4
|
+
try {
|
|
5
|
+
return out === null ? null : JSON.parse(out);
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
export function tailscaleOf(out) {
|
|
12
|
+
const s = parse(out);
|
|
13
|
+
const dnsName = typeof s?.Self?.DNSName === 'string' ? s.Self.DNSName.replace(/\.$/, '') : '';
|
|
14
|
+
return { dnsName, running: s?.BackendState === 'Running' && dnsName !== '' };
|
|
15
|
+
}
|
|
16
|
+
export function tunnelsOf(out) {
|
|
17
|
+
const list = parse(out);
|
|
18
|
+
return Array.isArray(list) ? list.map((t) => t?.name).filter((n) => typeof n === 'string') : [];
|
|
19
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import fastifyStatic from '@fastify/static';
|
|
5
|
+
import Fastify from 'fastify';
|
|
6
|
+
import { hashPassword, MIN_PASSWORD } from '../../http/auth/password.js';
|
|
7
|
+
import { ConfigError, DEFAULT_PORT, loadConfig, parseConfig, writeConfig } from '../../system/config.js';
|
|
8
|
+
import { Conflict, DomainError, errorReply, InvalidRequest } from '../../system/errors.js';
|
|
9
|
+
import { m } from '../../system/messages.js';
|
|
10
|
+
import { originQrSvg } from '../qr.js';
|
|
11
|
+
import { Store } from '../../system/store.js';
|
|
12
|
+
const sameToken = (given, token) => {
|
|
13
|
+
if (typeof given !== 'string')
|
|
14
|
+
return false;
|
|
15
|
+
const a = Buffer.from(given);
|
|
16
|
+
const b = Buffer.from(token);
|
|
17
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
18
|
+
};
|
|
19
|
+
// The config as it is, even one that no longer parses, so what it holds besides the address is kept.
|
|
20
|
+
function configThere(home) {
|
|
21
|
+
try {
|
|
22
|
+
const raw = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8'));
|
|
23
|
+
return raw && typeof raw === 'object' ? raw : {};
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function configFor(home, origin) {
|
|
30
|
+
const there = configThere(home);
|
|
31
|
+
try {
|
|
32
|
+
// Passkeys belong to the rpId, so it is kept unless the site moves to another address.
|
|
33
|
+
return parseConfig({ ...there, origin, rpId: there.origin === origin ? there.rpId : undefined });
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
if (e instanceof ConfigError)
|
|
37
|
+
throw new InvalidRequest(e.message);
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function configNow(home) {
|
|
42
|
+
try {
|
|
43
|
+
return loadConfig(home);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const HOSTNAME = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
|
50
|
+
export async function buildWizard(d) {
|
|
51
|
+
const app = Fastify({ trustProxy: false, bodyLimit: 16 * 1024 });
|
|
52
|
+
const store = new Store(join(d.home, 'state.json'));
|
|
53
|
+
// The tunnel the address step chose, for the service step to run; another address forgets it.
|
|
54
|
+
let tunnel;
|
|
55
|
+
app.setErrorHandler((e, _req, reply) => {
|
|
56
|
+
const own = e;
|
|
57
|
+
if (!(e instanceof DomainError) && own.statusCode && own.statusCode < 500)
|
|
58
|
+
return reply.code(own.statusCode).send({ error: own.message });
|
|
59
|
+
const { status, body } = errorReply(e);
|
|
60
|
+
return reply.code(status).send(body);
|
|
61
|
+
});
|
|
62
|
+
app.addHook('onRequest', async (req, reply) => {
|
|
63
|
+
d.touch?.();
|
|
64
|
+
const host = d.host();
|
|
65
|
+
// Another site's page can reach this port by a name of its own that resolves here (DNS rebinding), but
|
|
66
|
+
// its requests carry that name.
|
|
67
|
+
if (req.headers.host !== host)
|
|
68
|
+
return reply.code(403).send({ error: 'forbidden' });
|
|
69
|
+
if (!req.url.startsWith('/api/'))
|
|
70
|
+
return;
|
|
71
|
+
if (!sameToken(req.headers['x-setup-token'], d.token))
|
|
72
|
+
return reply.code(403).send({ error: 'forbidden' });
|
|
73
|
+
if (req.method !== 'GET' && req.headers.origin !== `http://${host}`)
|
|
74
|
+
return reply.code(403).send({ error: 'forbidden' });
|
|
75
|
+
});
|
|
76
|
+
app.addHook('onSend', async (req, reply, payload) => {
|
|
77
|
+
reply.header('content-security-policy', "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'");
|
|
78
|
+
reply.header('x-frame-options', 'DENY');
|
|
79
|
+
reply.header('x-content-type-options', 'nosniff');
|
|
80
|
+
reply.header('referrer-policy', 'no-referrer');
|
|
81
|
+
reply.header('cache-control', req.url.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-store');
|
|
82
|
+
return payload;
|
|
83
|
+
});
|
|
84
|
+
app.get('/api/setup/state', async () => {
|
|
85
|
+
const config = configNow(d.home);
|
|
86
|
+
const [checks, remote] = await Promise.all([d.doctor(), d.probe()]);
|
|
87
|
+
const user = store.get().user;
|
|
88
|
+
return {
|
|
89
|
+
checks,
|
|
90
|
+
user: user ? user.username : null,
|
|
91
|
+
origin: config?.origin ?? null,
|
|
92
|
+
port: config?.port ?? DEFAULT_PORT,
|
|
93
|
+
qr: config ? originQrSvg(config.origin) || null : null,
|
|
94
|
+
remote,
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
app.post('/api/setup/account', async (req) => {
|
|
98
|
+
const username = typeof req.body?.username === 'string' ? req.body.username.trim() : '';
|
|
99
|
+
const password = typeof req.body?.password === 'string' ? req.body.password : '';
|
|
100
|
+
if (!username)
|
|
101
|
+
throw new InvalidRequest(m.cli.emptyUsername);
|
|
102
|
+
if ([...password].length < MIN_PASSWORD)
|
|
103
|
+
throw new InvalidRequest(m.cli.passwordTooShort(MIN_PASSWORD));
|
|
104
|
+
const hash = await hashPassword(password);
|
|
105
|
+
// As setup does: a new user signs every device out.
|
|
106
|
+
store.update((s) => {
|
|
107
|
+
s.user = { username, hash };
|
|
108
|
+
s.logins = [];
|
|
109
|
+
});
|
|
110
|
+
return { username };
|
|
111
|
+
});
|
|
112
|
+
app.post('/api/setup/address', async (req) => {
|
|
113
|
+
const body = req.body ?? {};
|
|
114
|
+
let config;
|
|
115
|
+
let chosen;
|
|
116
|
+
if (body.kind === 'tailscale') {
|
|
117
|
+
const tailscale = (await d.probe()).tailscale;
|
|
118
|
+
if (!tailscale?.running)
|
|
119
|
+
throw new Conflict(m.wizard.noTailscale, 'no_tailscale');
|
|
120
|
+
config = configFor(d.home, `https://${tailscale.dnsName}`);
|
|
121
|
+
const r = await d.run('tailscale', ['serve', '--bg', String(config.port)]);
|
|
122
|
+
if (r.code !== 0)
|
|
123
|
+
throw new DomainError(502, m.wizard.tailscaleFailed(r.out.trim()));
|
|
124
|
+
}
|
|
125
|
+
else if (body.kind === 'cloudflare') {
|
|
126
|
+
const tunnels = (await d.probe()).cloudflared?.tunnels ?? [];
|
|
127
|
+
if (typeof body.tunnel !== 'string' || !tunnels.includes(body.tunnel))
|
|
128
|
+
throw new InvalidRequest(m.wizard.noTunnel(String(body.tunnel)));
|
|
129
|
+
if (typeof body.hostname !== 'string' || !HOSTNAME.test(body.hostname))
|
|
130
|
+
throw new InvalidRequest(m.wizard.badHostname);
|
|
131
|
+
config = configFor(d.home, `https://${body.hostname.toLowerCase()}`);
|
|
132
|
+
chosen = body.tunnel;
|
|
133
|
+
}
|
|
134
|
+
else if (body.kind === 'other') {
|
|
135
|
+
if (typeof body.origin !== 'string')
|
|
136
|
+
throw new InvalidRequest(m.config.origin);
|
|
137
|
+
config = configFor(d.home, body.origin.trim().replace(/\/+$/, ''));
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
throw new InvalidRequest('kind: tailscale, cloudflare or other');
|
|
141
|
+
}
|
|
142
|
+
writeConfig(d.home, config);
|
|
143
|
+
tunnel = chosen;
|
|
144
|
+
return { origin: config.origin };
|
|
145
|
+
});
|
|
146
|
+
app.post('/api/setup/service', async () => {
|
|
147
|
+
if (!store.get().user || !configNow(d.home))
|
|
148
|
+
throw new Conflict(m.wizard.notReady, 'not_ready');
|
|
149
|
+
return d.install(tunnel ? { tunnel } : {});
|
|
150
|
+
});
|
|
151
|
+
app.post('/api/setup/done', async () => {
|
|
152
|
+
d.onDone();
|
|
153
|
+
return {};
|
|
154
|
+
});
|
|
155
|
+
// The web app's build, its index.html for /setup; nothing else of the app is reachable without its server.
|
|
156
|
+
if (existsSync(join(d.webDir, 'index.html'))) {
|
|
157
|
+
await app.register(fastifyStatic, { root: d.webDir, index: false, wildcard: false });
|
|
158
|
+
app.get('/setup', (_req, reply) => reply.type('text/html; charset=utf-8').sendFile('index.html'));
|
|
159
|
+
}
|
|
160
|
+
app.setNotFoundHandler((_req, reply) => reply.code(404).send({ error: 'not found' }));
|
|
161
|
+
return app;
|
|
162
|
+
}
|