@christoph-teichmeister/shub 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/README.md CHANGED
@@ -1,3 +1,23 @@
1
- # Temporary Holding Version
1
+ # shub launcher
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Installs, updates and starts [shub](https://github.com/christoph-teichmeister/shub), your session hub: all your
4
+ Claude Code sessions in one window. Linux and macOS (Windows only inside WSL2), Node 24+, tmux 3.3+, Claude Code.
5
+
6
+ ```sh
7
+ npm i -g @christoph-teichmeister/shub && shub install && shub service install
8
+ ```
9
+
10
+ | Command | What it does |
11
+ | ---------------------------------------- | ----------------------------------------------------------------------------- |
12
+ | `shub` / `shub start` | runs the installed version |
13
+ | `shub install [--channel stable\|beta]` | downloads and installs the latest release |
14
+ | `shub update [--to <version>] [--check]` | installs a newer release and restarts shub; rolls back if it does not come up |
15
+ | `shub rollback` | switches back to the previous version |
16
+ | `shub service install\|uninstall` | systemd user unit (Linux) or launchd agent (macOS) |
17
+ | `shub doctor` | checks Node, tmux, Claude Code, Docker and the port |
18
+
19
+ Every other command (`shub status`, `shub session start`, ...) goes to the installed version's CLI.
20
+
21
+ Releases are verified before they are installed: the archive's SHA-256 must be in a `SHA256SUMS` signed with the
22
+ shub release key (`release-keys.pem`). Versions live in `~/.local/share/shub/versions/` (`SHUB_HOME`), `current`
23
+ points at the active one, the last two stay for rollback.
package/dist/doctor.js ADDED
@@ -0,0 +1,97 @@
1
+ // `shub doctor`: is this machine ready for shub, and if not, what is missing.
2
+ import { execFileSync } from 'node:child_process';
3
+ import { accessSync, constants, existsSync } from 'node:fs';
4
+ import { createServer } from 'node:net';
5
+ import { delimiter, join } from 'node:path';
6
+ const atLeast = (have, need) => have[0] > need[0] || (have[0] === need[0] && have[1] >= need[1]);
7
+ export async function doctor(d) {
8
+ const nodeOk = atLeast(d.nodeVersion.split('.').map(Number), [24, 0]);
9
+ const tmux = d.tmuxVersion();
10
+ const tmuxNum = /(\d+)\.(\d+)/.exec(tmux ?? '');
11
+ const tmuxOk = !!tmuxNum && atLeast([Number(tmuxNum[1]), Number(tmuxNum[2])], [3, 3]);
12
+ const claude = d.which('claude');
13
+ const docker = d.exists(d.dockerSocket);
14
+ const port = await d.portState(d.port);
15
+ return [
16
+ {
17
+ name: 'Node',
18
+ ok: nodeOk,
19
+ detail: nodeOk ? `Node ${d.nodeVersion}` : `Node ${d.nodeVersion} found, shub needs 24 or newer`,
20
+ },
21
+ {
22
+ name: 'tmux',
23
+ ok: tmuxOk,
24
+ detail: tmuxOk
25
+ ? tmux
26
+ : tmux
27
+ ? `${tmux} found, shub needs 3.3 or newer`
28
+ : 'tmux is not installed: shub needs 3.3 or newer (apt install tmux, brew install tmux)',
29
+ },
30
+ {
31
+ name: 'Claude Code',
32
+ ok: !!claude,
33
+ detail: claude ?? 'claude is not on PATH: install Claude Code and make sure `claude` runs in a terminal',
34
+ },
35
+ {
36
+ name: 'Docker',
37
+ ok: docker,
38
+ optional: true,
39
+ detail: docker
40
+ ? d.dockerSocket
41
+ : `no Docker socket at ${d.dockerSocket} (optional: the services panel stays empty)`,
42
+ },
43
+ {
44
+ name: 'Port',
45
+ ok: port !== 'busy',
46
+ detail: port === 'free'
47
+ ? `127.0.0.1:${d.port} is free`
48
+ : port === 'shub'
49
+ ? `shub already answers on 127.0.0.1:${d.port}`
50
+ : `127.0.0.1:${d.port} is used by another program: stop it or set SHUB_PORT`,
51
+ },
52
+ ];
53
+ }
54
+ function which(command, path = process.env.PATH ?? '') {
55
+ for (const dir of path.split(delimiter)) {
56
+ const file = join(dir, command);
57
+ try {
58
+ accessSync(file, constants.X_OK);
59
+ return file;
60
+ }
61
+ catch {
62
+ // next
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+ async function portState(port) {
68
+ const free = await new Promise((resolve) => {
69
+ const server = createServer()
70
+ .once('error', () => resolve(false))
71
+ .listen(port, '127.0.0.1', () => server.close(() => resolve(true)));
72
+ });
73
+ if (free)
74
+ return 'free';
75
+ const health = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(2000) })
76
+ .then((r) => r.json())
77
+ .catch(() => null);
78
+ return health?.ok ? 'shub' : 'busy';
79
+ }
80
+ export function realDoctorDeps(env = process.env, port) {
81
+ return {
82
+ nodeVersion: process.versions.node,
83
+ tmuxVersion: () => {
84
+ try {
85
+ return execFileSync('tmux', ['-V'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ },
91
+ which: (c) => which(c, env.PATH),
92
+ exists: existsSync,
93
+ dockerSocket: env.DOCKER_SOCKET ?? '/var/run/docker.sock',
94
+ port,
95
+ portState,
96
+ };
97
+ }
@@ -0,0 +1,82 @@
1
+ // Where releases come from: the GitHub releases of christoph-teichmeister/shub, nothing else.
2
+ // Tests point SHUB_TEST_RELEASES at a local fixture (file:// or http://127.0.0.1) with its own release-keys.pem.
3
+ import { copyFile, readFile, writeFile } from 'node:fs/promises';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { pemKeys } from "./signing.js";
6
+ export const REPO = 'christoph-teichmeister/shub';
7
+ // ponytail: first page only; 100 releases cover months of betas. Page through (Link header) when they do not.
8
+ const API_URL = `https://api.github.com/repos/${REPO}/releases?per_page=100`;
9
+ const DOWNLOAD_PREFIX = `https://github.com/${REPO}/releases/download/`;
10
+ const VERSION = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
11
+ export const isVersion = (v) => VERSION.test(v);
12
+ /** Semver order, which CalVer `YYYY.M.MICRO[-beta.N]` follows: a pre-release sorts before its release. */
13
+ export function compareVersions(a, b) {
14
+ const pa = VERSION.exec(a);
15
+ const pb = VERSION.exec(b);
16
+ if (!pa || !pb)
17
+ throw new Error(`not a version: ${pa ? b : a}`);
18
+ for (let i = 1; i <= 3; i++)
19
+ if (Number(pa[i]) !== Number(pb[i]))
20
+ return Number(pa[i]) - Number(pb[i]);
21
+ if (!pa[4] || !pb[4])
22
+ return (pa[4] ? -1 : 0) - (pb[4] ? -1 : 0);
23
+ return pa[4].localeCompare(pb[4], 'en', { numeric: true }); // beta.2 < beta.10
24
+ }
25
+ /** Published releases with a version tag, newest first. */
26
+ export function parseReleases(list) {
27
+ return list
28
+ .filter((r) => !r.draft && isVersion(r.tag_name.replace(/^v/, '')))
29
+ .map((r) => ({
30
+ version: r.tag_name.replace(/^v/, ''),
31
+ prerelease: r.prerelease,
32
+ notes: r.body ?? '',
33
+ assets: new Map(r.assets.map((a) => [a.name, a.browser_download_url])),
34
+ }))
35
+ .sort((a, b) => compareVersions(b.version, a.version));
36
+ }
37
+ /** stable: the newest non-prerelease; beta: the newest of all. */
38
+ export function latest(releases, channel) {
39
+ return releases.find((r) => channel === 'beta' || !r.prerelease) ?? null;
40
+ }
41
+ /** Notes of every release on the channel after `from` up to and including `to`, newest first. */
42
+ export function changelog(releases, channel, from, to) {
43
+ return releases
44
+ .filter((r) => (channel === 'beta' || !r.prerelease) && compareVersions(r.version, to) <= 0)
45
+ .filter((r) => !from || compareVersions(r.version, from) > 0)
46
+ .map((r) => `## ${r.version}\n\n${r.notes.trim()}`)
47
+ .join('\n\n');
48
+ }
49
+ export const archiveName = (version, platform = `${process.platform}-${process.arch}`) => `shub-${version}-${platform}.tar.gz`;
50
+ async function get(url) {
51
+ if (url.startsWith('file:'))
52
+ return readFile(fileURLToPath(url));
53
+ const res = await fetch(url, { headers: { accept: 'application/vnd.github+json', 'user-agent': 'shub-launcher' } });
54
+ if (!res.ok)
55
+ throw new Error(`GET ${url} failed: ${res.status} ${res.statusText}`);
56
+ return Buffer.from(await res.arrayBuffer());
57
+ }
58
+ /** The real GitHub source, or the local fixture named by SHUB_TEST_RELEASES (tests only). */
59
+ export function source(env = process.env) {
60
+ const test = env.SHUB_TEST_RELEASES;
61
+ if (test && !/^(file:\/\/|http:\/\/(127\.0\.0\.1|localhost)[:/])/.test(test)) {
62
+ throw new Error('SHUB_TEST_RELEASES must be a file:// or http://127.0.0.1 URL');
63
+ }
64
+ const base = test ? (test.endsWith('/') ? test : `${test}/`) : null;
65
+ const prefix = base ?? DOWNLOAD_PREFIX;
66
+ return {
67
+ async releases() {
68
+ const list = JSON.parse((await get(base ? `${base}releases.json` : API_URL)).toString('utf8'));
69
+ return parseReleases(list);
70
+ },
71
+ async download(url, dest) {
72
+ if (!url.startsWith(prefix))
73
+ throw new Error(`refusing to download from ${url}: not under ${prefix}`);
74
+ if (url.startsWith('file:'))
75
+ return copyFile(fileURLToPath(url), dest);
76
+ await writeFile(dest, await get(url));
77
+ },
78
+ async testKeys() {
79
+ return base ? pemKeys((await get(`${base}release-keys.pem`)).toString('utf8')) : null;
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,146 @@
1
+ // `shub service install|uninstall`: a systemd user unit (Linux) or a launchd agent (macOS) that runs `shub start`.
2
+ // Sessions live in shub's tmux server, a child of the service, so stopping the service must only stop shub itself:
3
+ // KillMode=process (systemd) and AbandonProcessGroup (launchd). The updater relies on that too.
4
+ import { execFileSync } from 'node:child_process';
5
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { dirname, join } from 'node:path';
8
+ const MARKER = 'Written by `shub service install`';
9
+ export const UNIT = 'shub.service';
10
+ export const LABEL = 'com.github.christoph-teichmeister.shub';
11
+ /** Environment the service needs: PATH (tmux, claude, docker), shub's own settings. */
12
+ function serviceEnv(o) {
13
+ const out = {
14
+ NODE_ENV: 'production',
15
+ SHUB_HOME: o.home,
16
+ PATH: o.env.PATH ?? '/usr/bin:/bin',
17
+ };
18
+ for (const key of ['SHUB_PORT', 'SHUB_PUBLIC_URL', 'SHUB_CONFIG_DIR', 'SHUB_STATE_DIR']) {
19
+ if (o.env[key])
20
+ out[key] = o.env[key];
21
+ }
22
+ return out;
23
+ }
24
+ export function systemdUnit(o) {
25
+ const env = Object.entries(serviceEnv(o)).map(([k, v]) => `Environment="${k}=${v.replace(/["\\]/g, '\\$&')}"`);
26
+ return [
27
+ `# ${MARKER}; \`shub service uninstall\` removes it.`,
28
+ '[Unit]',
29
+ 'Description=shub, your session hub',
30
+ 'After=network.target',
31
+ '',
32
+ '[Service]',
33
+ 'Type=simple',
34
+ `ExecStart="${o.node}" "${o.launcher}" start`,
35
+ ...env,
36
+ 'Restart=on-failure',
37
+ 'RestartSec=3',
38
+ "# Sessions live in shub's tmux server, a child of this unit: keep it (and a running update) alive across restarts.",
39
+ 'KillMode=process',
40
+ '',
41
+ '[Install]',
42
+ 'WantedBy=default.target',
43
+ '',
44
+ ].join('\n');
45
+ }
46
+ const xml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
47
+ export function launchdPlist(o) {
48
+ const env = Object.entries(serviceEnv(o))
49
+ .map(([k, v]) => ` <key>${xml(k)}</key><string>${xml(v)}</string>`)
50
+ .join('\n');
51
+ return `<?xml version="1.0" encoding="UTF-8"?>
52
+ <!-- ${MARKER}; \`shub service uninstall\` removes it. -->
53
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
54
+ <plist version="1.0">
55
+ <dict>
56
+ <key>Label</key><string>${LABEL}</string>
57
+ <key>ProgramArguments</key>
58
+ <array>
59
+ <string>${xml(o.node)}</string>
60
+ <string>${xml(o.launcher)}</string>
61
+ <string>start</string>
62
+ </array>
63
+ <key>EnvironmentVariables</key>
64
+ <dict>
65
+ ${env}
66
+ </dict>
67
+ <key>RunAtLoad</key><true/>
68
+ <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
69
+ <!-- Sessions live in shub's tmux server: keep it (and a running update) alive across restarts. -->
70
+ <key>AbandonProcessGroup</key><true/>
71
+ <key>StandardOutPath</key><string>${xml(join(o.home, 'shub.log'))}</string>
72
+ <key>StandardErrorPath</key><string>${xml(join(o.home, 'shub.log'))}</string>
73
+ </dict>
74
+ </plist>
75
+ `;
76
+ }
77
+ const runQuiet = (cmd, args) => void execFileSync(cmd, args, { stdio: 'ignore' });
78
+ const succeeds = (run, cmd, args) => {
79
+ try {
80
+ run(cmd, args);
81
+ return true;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ };
87
+ export function service(platform = process.platform, env = process.env, run = runQuiet) {
88
+ const darwin = platform === 'darwin';
89
+ const userHome = env.HOME ?? homedir();
90
+ const file = darwin
91
+ ? join(userHome, 'Library', 'LaunchAgents', `${LABEL}.plist`)
92
+ : join(env.XDG_CONFIG_HOME ?? join(userHome, '.config'), 'systemd', 'user', UNIT);
93
+ const target = `gui/${process.getuid?.() ?? 0}`;
94
+ const text = () => (existsSync(file) ? readFileSync(file, 'utf8') : null);
95
+ const ours = () => text()?.includes(MARKER) ?? true;
96
+ return {
97
+ file,
98
+ install(o, force = false) {
99
+ if (!ours() && !force) {
100
+ throw new Error(`${file} exists and was not written by shub; move it away or run with --force`);
101
+ }
102
+ mkdirSync(dirname(file), { recursive: true });
103
+ writeFileSync(file, darwin ? launchdPlist(o) : systemdUnit(o));
104
+ if (darwin) {
105
+ succeeds(run, 'launchctl', ['bootout', `${target}/${LABEL}`]);
106
+ run('launchctl', ['bootstrap', target, file]);
107
+ return `Installed ${file} and started it. Logs: ${join(o.home, 'shub.log')}`;
108
+ }
109
+ run('systemctl', ['--user', 'daemon-reload']);
110
+ run('systemctl', ['--user', 'enable', '--now', UNIT]);
111
+ return [
112
+ `Installed ${file} and started it. Logs: journalctl --user -u shub -f`,
113
+ 'To keep shub running while you are logged out: loginctl enable-linger $USER',
114
+ ].join('\n');
115
+ },
116
+ uninstall() {
117
+ if (!existsSync(file))
118
+ return `No service installed (${file}).`;
119
+ if (!ours())
120
+ throw new Error(`${file} was not written by shub; remove it yourself`);
121
+ if (darwin)
122
+ succeeds(run, 'launchctl', ['bootout', `${target}/${LABEL}`]);
123
+ else
124
+ succeeds(run, 'systemctl', ['--user', 'disable', '--now', UNIT]);
125
+ rmSync(file);
126
+ if (!darwin)
127
+ run('systemctl', ['--user', 'daemon-reload']);
128
+ return `Removed ${file}. Sessions in tmux keep running.`;
129
+ },
130
+ active(home) {
131
+ const unit = text();
132
+ const forHome = darwin ? `<string>${xml(home)}</string>` : `SHUB_HOME=${home}"`;
133
+ if (!unit?.includes(MARKER) || !unit.includes(forHome))
134
+ return false;
135
+ return darwin
136
+ ? succeeds(run, 'launchctl', ['print', `${target}/${LABEL}`])
137
+ : succeeds(run, 'systemctl', ['--user', 'is-active', '--quiet', UNIT]);
138
+ },
139
+ restart() {
140
+ if (darwin)
141
+ run('launchctl', ['kickstart', '-k', `${target}/${LABEL}`]);
142
+ else
143
+ run('systemctl', ['--user', 'restart', UNIT]);
144
+ },
145
+ };
146
+ }
package/dist/shub.js ADDED
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env node
2
+ // The shub launcher: installs signed shub releases side by side, starts the active one and updates it.
3
+ // Commands it does not know go to the CLI of the installed app version (`shub status`, `shub session ...`).
4
+ import { readFileSync, realpathSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { doctor, realDoctorDeps } from "./doctor.js";
9
+ import { source } from "./releases.js";
10
+ import { service } from "./service.js";
11
+ import { channelFor, check, healthCheck, rollback, update } from "./update.js";
12
+ import { currentVersion, readState, shubHome, writeState } from "./versions.js";
13
+ const env = process.env;
14
+ const self = fileURLToPath(import.meta.url);
15
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
16
+ const HELP = `shub — your session hub (launcher ${pkg.version})
17
+
18
+ Usage: shub [command]
19
+
20
+ start run the installed shub (the default)
21
+ install [--channel stable|beta]
22
+ download and install the latest release
23
+ update [--to <version>] [--channel stable|beta] [--check] [--json]
24
+ install a newer release and restart shub
25
+ rollback switch back to the previous version
26
+ service install|uninstall run shub as a systemd user unit (Linux) or launchd agent (macOS)
27
+ doctor check Node, tmux, Claude Code, Docker and the port
28
+ --version launcher and app version
29
+
30
+ Every other command goes to the installed app: shub status, shub project, shub session, shub open, ...
31
+ ("shub <command> --help" for its help).
32
+
33
+ Environment: SHUB_HOME (default ~/.local/share/shub), SHUB_PORT (default 4780).`;
34
+ class UsageError extends Error {
35
+ }
36
+ function flags(args, valued) {
37
+ const out = {};
38
+ for (let i = 0; i < args.length; i++) {
39
+ const m = /^--([a-z-]+)(?:=(.*))?$/.exec(args[i]);
40
+ if (!m)
41
+ throw new UsageError(`unexpected argument "${args[i]}"`);
42
+ if (m[2] !== undefined)
43
+ out[m[1]] = m[2];
44
+ else if (valued.includes(m[1])) {
45
+ if (args[i + 1] === undefined)
46
+ throw new UsageError(`--${m[1]} needs a value`);
47
+ out[m[1]] = args[++i];
48
+ }
49
+ else
50
+ out[m[1]] = true;
51
+ }
52
+ return out;
53
+ }
54
+ const str = (v) => (typeof v === 'string' ? v : undefined);
55
+ /** The channel picked in the app's Settings (config.json); the launcher never writes that file. */
56
+ function configuredChannel() {
57
+ const dir = env.SHUB_CONFIG_DIR ?? join(env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'shub');
58
+ try {
59
+ return JSON.parse(readFileSync(join(dir, 'config.json'), 'utf8')).updateChannel;
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }
65
+ function deps(home) {
66
+ if (env.SHUB_TEST_RELEASES)
67
+ console.error(`shub: test releases from ${env.SHUB_TEST_RELEASES}, not GitHub`);
68
+ const port = Number(env.SHUB_PORT ?? readState(home).port ?? 4780);
69
+ return { home, source: source(env), service: service(), health: healthCheck(port), log: (l) => console.log(l) };
70
+ }
71
+ function currentDir(home) {
72
+ try {
73
+ return realpathSync(join(home, 'current'));
74
+ }
75
+ catch {
76
+ throw new UsageError('shub is not installed yet. Run: shub install');
77
+ }
78
+ }
79
+ /** Replace this process with the app (same PID, so systemd and launchd see the app itself). */
80
+ function exec(home, args) {
81
+ const dir = currentDir(home);
82
+ process.chdir(dir);
83
+ return process.execve(process.execPath, [process.execPath, ...args(dir)], {
84
+ ...env,
85
+ SHUB_HOME: home,
86
+ SHUB_LAUNCHER: self,
87
+ });
88
+ }
89
+ async function main(argv) {
90
+ if (process.platform === 'win32') {
91
+ throw new UsageError('shub runs on Linux and macOS. On Windows, install it inside WSL2 (wsl --install) and run it there.');
92
+ }
93
+ const home = shubHome(env);
94
+ const [command = 'start', ...rest] = argv;
95
+ switch (command) {
96
+ case 'start': {
97
+ writeState(home, { port: Number(env.SHUB_PORT ?? 4780) });
98
+ return exec(home, (dir) => [join(dir, 'server', 'main.ts')]);
99
+ }
100
+ case 'install': {
101
+ const f = flags(rest, ['channel']);
102
+ const current = currentVersion(home);
103
+ if (current)
104
+ return void console.log(`shub ${current} is installed. Newer versions: shub update`);
105
+ await update(deps(home), { channel: channelFor(str(f.channel), configuredChannel(), null) });
106
+ return void console.log('Next: shub service install (runs shub in the background), or shub start');
107
+ }
108
+ case 'update': {
109
+ const f = flags(rest, ['to', 'channel']);
110
+ const channel = channelFor(str(f.channel), configuredChannel(), currentVersion(home));
111
+ if (f.check) {
112
+ const result = await check(deps(home), channel);
113
+ if (f.json)
114
+ return void console.log(JSON.stringify(result));
115
+ return void console.log(result.latest
116
+ ? `shub ${result.latest} is available:\n\n${result.changelog}`
117
+ : `shub ${result.current} is up to date (${channel}).`);
118
+ }
119
+ const result = await update(deps(home), { to: str(f.to), channel });
120
+ if (result && !result.ok)
121
+ process.exitCode = 1;
122
+ return;
123
+ }
124
+ case 'rollback': {
125
+ flags(rest, []);
126
+ const result = await rollback(deps(home));
127
+ if (!result.ok)
128
+ process.exitCode = 1;
129
+ return;
130
+ }
131
+ case 'service': {
132
+ const [action, ...more] = rest;
133
+ const f = flags(more, []);
134
+ const s = service();
135
+ if (action === 'install') {
136
+ currentDir(home);
137
+ return void console.log(s.install({ node: process.execPath, launcher: self, home, env }, !!f.force));
138
+ }
139
+ if (action === 'uninstall')
140
+ return void console.log(s.uninstall());
141
+ throw new UsageError('usage: shub service install|uninstall');
142
+ }
143
+ case 'doctor': {
144
+ flags(rest, []);
145
+ const findings = await doctor(realDoctorDeps(env, Number(env.SHUB_PORT ?? 4780)));
146
+ for (const f of findings)
147
+ console.log(`${f.ok ? 'ok ' : f.optional ? 'note' : 'FAIL'} ${f.name.padEnd(12)}${f.detail}`);
148
+ if (findings.some((f) => !f.ok && !f.optional))
149
+ process.exitCode = 1;
150
+ return;
151
+ }
152
+ case '--version':
153
+ case '-v':
154
+ return void console.log(`launcher ${pkg.version}\napp ${currentVersion(home) ?? 'not installed (shub install)'}`);
155
+ case '--help':
156
+ case '-h':
157
+ case 'help':
158
+ return void console.log(HELP);
159
+ default:
160
+ return exec(home, (dir) => ['--no-warnings', join(dir, 'cli', 'shub.ts'), ...argv]);
161
+ }
162
+ }
163
+ main(process.argv.slice(2)).catch((err) => {
164
+ console.error(`shub: ${err instanceof Error && !(err instanceof TypeError) ? err.message : err.stack}`);
165
+ process.exitCode = 1;
166
+ });
@@ -0,0 +1,69 @@
1
+ // Release integrity: SHA256SUMS lists the archives, SHA256SUMS.sig is an ed25519 signature of it (base64).
2
+ // An archive is installed only if its hash is in a SHA256SUMS that one of the trusted keys signed.
3
+ import { createHash, createPublicKey, verify } from 'node:crypto';
4
+ import { createReadStream, readFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ /** The PEM public key blocks in a text (release-keys.pem allows comment lines around them). */
7
+ export function pemKeys(text) {
8
+ return text.match(/-----BEGIN PUBLIC KEY-----[\s\S]+?-----END PUBLIC KEY-----/g) ?? [];
9
+ }
10
+ function readKeys(file) {
11
+ try {
12
+ return pemKeys(readFileSync(file, 'utf8'));
13
+ }
14
+ catch {
15
+ return [];
16
+ }
17
+ }
18
+ /**
19
+ * Keys shipped with this launcher plus the ones in the installed app version, so a release signed with the old
20
+ * key can introduce a new one (docs/RELEASING.md).
21
+ */
22
+ export function trustedKeys(currentDir) {
23
+ const bundled = readKeys(new URL('../release-keys.pem', import.meta.url));
24
+ return [...new Set([...bundled, ...(currentDir ? readKeys(join(currentDir, 'release-keys.pem')) : [])])];
25
+ }
26
+ export function sha256(file) {
27
+ return new Promise((resolve, reject) => {
28
+ const hash = createHash('sha256');
29
+ createReadStream(file)
30
+ .on('data', (chunk) => hash.update(chunk))
31
+ .on('end', () => resolve(hash.digest('hex')))
32
+ .on('error', reject);
33
+ });
34
+ }
35
+ /** `sha256sum` format: "<hex> <name>" per line. */
36
+ export function parseSums(text) {
37
+ const sums = new Map();
38
+ for (const line of text.split('\n')) {
39
+ const m = /^([0-9a-f]{64}) [ *](.+)$/.exec(line.trim());
40
+ if (m)
41
+ sums.set(m[2], m[1]);
42
+ }
43
+ return sums;
44
+ }
45
+ export function signatureValid(sums, signatureBase64, keys) {
46
+ const signature = Buffer.from(signatureBase64.trim(), 'base64');
47
+ return keys.some((pem) => {
48
+ try {
49
+ return verify(null, sums, createPublicKey(pem), signature);
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ });
55
+ }
56
+ /** Throws unless `archive` (published as `name`) is listed with its hash in a validly signed SHA256SUMS. */
57
+ export async function verifyArchive(opts) {
58
+ if (!opts.keys.length)
59
+ throw new Error('no release keys to verify with');
60
+ if (!signatureValid(opts.sums, opts.signature, opts.keys)) {
61
+ throw new Error('SHA256SUMS is not signed by a shub release key; refusing to install');
62
+ }
63
+ const expected = parseSums(opts.sums.toString('utf8')).get(opts.name);
64
+ if (!expected)
65
+ throw new Error(`${opts.name} is not listed in SHA256SUMS; refusing to install`);
66
+ if ((await sha256(opts.archive)) !== expected) {
67
+ throw new Error(`${opts.name} does not match its SHA256SUMS entry; refusing to install`);
68
+ }
69
+ }
package/dist/update.js ADDED
@@ -0,0 +1,149 @@
1
+ // install / update / rollback: download, verify, unpack next to the old version, switch `current`, restart the
2
+ // service and wait for the new version's /api/health. A version that does not come up within 30 s is rolled back.
3
+ import { execFileSync } from 'node:child_process';
4
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { archiveName, changelog, compareVersions, isVersion, latest } from "./releases.js";
7
+ import { trustedKeys, verifyArchive } from "./signing.js";
8
+ import { currentVersion, lock, prune, readState, switchTo, versionDir, writeState, } from "./versions.js";
9
+ /** Channel to follow: the flag, else the app's setting, else the kind of version that runs now. */
10
+ export function channelFor(flag, configured, current) {
11
+ const pick = flag ?? configured;
12
+ if (pick === 'stable' || pick === 'beta')
13
+ return pick;
14
+ if (pick !== undefined)
15
+ throw new Error(`unknown channel "${pick}" (stable or beta)`);
16
+ return current?.includes('-') ? 'beta' : 'stable';
17
+ }
18
+ export async function check(deps, channel) {
19
+ const current = currentVersion(deps.home);
20
+ const releases = await deps.source.releases();
21
+ const newest = latest(releases, channel);
22
+ if (!newest || (current && compareVersions(newest.version, current) <= 0))
23
+ return { current, channel };
24
+ return { current, channel, latest: newest.version, changelog: changelog(releases, channel, current, newest.version) };
25
+ }
26
+ async function fetchVerified(deps, version) {
27
+ const release = (await deps.source.releases()).find((r) => r.version === version);
28
+ if (!release)
29
+ throw new Error(`there is no release ${version}`);
30
+ const name = archiveName(version, deps.platform);
31
+ const urls = [name, 'SHA256SUMS', 'SHA256SUMS.sig'].map((n) => {
32
+ const url = release.assets.get(n);
33
+ if (!url)
34
+ throw new Error(`release ${version} has no ${n}${n === name ? ' (no build for this platform)' : ''}`);
35
+ return url;
36
+ });
37
+ const dir = join(deps.home, 'downloads', `${version}.${process.pid}`);
38
+ const partial = join(deps.home, 'versions', `.${version}.${process.pid}.partial`);
39
+ mkdirSync(dir, { recursive: true });
40
+ try {
41
+ const [archive, sums, sig] = [name, 'SHA256SUMS', 'SHA256SUMS.sig'].map((n) => join(dir, n));
42
+ deps.log(`Downloading ${name}`);
43
+ await deps.source.download(urls[0], archive);
44
+ await deps.source.download(urls[1], sums);
45
+ await deps.source.download(urls[2], sig);
46
+ const current = currentVersion(deps.home);
47
+ const keys = (await deps.source.testKeys()) ?? trustedKeys(current ? versionDir(deps.home, current) : null);
48
+ await verifyArchive({ archive, name, sums: readFileSync(sums), signature: readFileSync(sig, 'utf8'), keys });
49
+ deps.log('Signature and checksum verified');
50
+ mkdirSync(partial, { recursive: true });
51
+ execFileSync('tar', ['-xzf', archive, '-C', partial, '--no-same-owner'], { stdio: 'ignore' });
52
+ if (!existsSync(join(partial, 'server', 'main.ts')))
53
+ throw new Error(`${name} has no server/main.ts`);
54
+ renameSync(partial, versionDir(deps.home, version));
55
+ }
56
+ finally {
57
+ rmSync(dir, { recursive: true, force: true });
58
+ rmSync(partial, { recursive: true, force: true });
59
+ }
60
+ }
61
+ function record(deps, last, patch = {}) {
62
+ writeState(deps.home, { ...patch, last });
63
+ deps.log(last.message);
64
+ return last;
65
+ }
66
+ /** Switch to `to`, restart and health-check it when shub runs as a service; switch back if it does not come up. */
67
+ async function activate(deps, to, verb) {
68
+ const from = currentVersion(deps.home);
69
+ const before = readState(deps.home).previous;
70
+ const at = new Date().toISOString();
71
+ switchTo(deps.home, to);
72
+ writeState(deps.home, { previous: from ?? undefined });
73
+ if (!deps.service.active(deps.home)) {
74
+ const message = from ? `${verb} ${to}. Restart shub to use it.` : `Installed ${to}.`;
75
+ return record(deps, { at, from, to, ok: true, message });
76
+ }
77
+ deps.log(`Restarting shub with ${to}`);
78
+ deps.service.restart();
79
+ const timeout = deps.healthTimeoutMs ?? 30_000;
80
+ if (await deps.health(to, timeout))
81
+ return record(deps, { at, from, to, ok: true, message: `${verb} ${to}.` });
82
+ const seconds = Math.round(timeout / 1000);
83
+ if (!from)
84
+ return record(deps, { at, from, to, ok: false, message: `${to} did not answer within ${seconds} s.` });
85
+ switchTo(deps.home, from);
86
+ deps.service.restart();
87
+ return record(deps, {
88
+ at,
89
+ from,
90
+ to,
91
+ ok: false,
92
+ message: `${to} did not answer /api/health within ${seconds} s; rolled back to ${from}.`,
93
+ }, { previous: before });
94
+ }
95
+ /** Install `to` (or the newest release on the channel) and switch to it. */
96
+ export async function update(deps, opts) {
97
+ if (opts.to !== undefined && !isVersion(opts.to))
98
+ throw new Error(`not a version: ${opts.to}`);
99
+ const unlock = lock(deps.home);
100
+ try {
101
+ const current = currentVersion(deps.home);
102
+ const target = opts.to ?? (await check(deps, opts.channel)).latest;
103
+ if (!target && !current)
104
+ throw new Error(`there is no ${opts.channel} release yet`);
105
+ if (!target || target === current) {
106
+ deps.log(`shub ${current} is up to date (${opts.channel}).`);
107
+ return null;
108
+ }
109
+ if (!existsSync(versionDir(deps.home, target)))
110
+ await fetchVerified(deps, target);
111
+ const result = await activate(deps, target, compareVersions(target, current ?? target) < 0 ? 'Switched to' : 'Updated to');
112
+ for (const v of prune(deps.home))
113
+ deps.log(`Removed old version ${v}`);
114
+ return result;
115
+ }
116
+ finally {
117
+ unlock();
118
+ }
119
+ }
120
+ export async function rollback(deps) {
121
+ const unlock = lock(deps.home);
122
+ try {
123
+ const previous = readState(deps.home).previous;
124
+ if (!previous || !existsSync(versionDir(deps.home, previous)))
125
+ throw new Error('there is no previous version to roll back to');
126
+ return await activate(deps, previous, 'Rolled back to');
127
+ }
128
+ finally {
129
+ unlock();
130
+ }
131
+ }
132
+ /** Polls http://127.0.0.1:<port>/api/health until it reports `version`. */
133
+ export function healthCheck(port) {
134
+ return async (version, timeoutMs) => {
135
+ const until = Date.now() + timeoutMs;
136
+ while (Date.now() < until) {
137
+ try {
138
+ const res = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(2000) });
139
+ if (res.ok && (await res.json()).version === version)
140
+ return true;
141
+ }
142
+ catch {
143
+ // not up yet
144
+ }
145
+ await new Promise((r) => setTimeout(r, 500));
146
+ }
147
+ return false;
148
+ };
149
+ }
@@ -0,0 +1,85 @@
1
+ // App versions side by side: <home>/versions/<version>/, <home>/current -> versions/<version>.
2
+ // <home>/launcher.json remembers the previous version and the last update result (the app shows it in Settings).
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, writeFileSync, } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { basename, join } from 'node:path';
6
+ import { compareVersions, isVersion } from "./releases.js";
7
+ export function shubHome(env = process.env) {
8
+ return env.SHUB_HOME ?? join(env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'shub');
9
+ }
10
+ export const versionDir = (home, version) => join(home, 'versions', version);
11
+ export function currentVersion(home) {
12
+ try {
13
+ return basename(readlinkSync(join(home, 'current')));
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ export function installedVersions(home) {
20
+ const dir = join(home, 'versions');
21
+ if (!existsSync(dir))
22
+ return [];
23
+ return readdirSync(dir)
24
+ .filter(isVersion)
25
+ .sort((a, b) => compareVersions(b, a));
26
+ }
27
+ export function readState(home) {
28
+ try {
29
+ return JSON.parse(readFileSync(join(home, 'launcher.json'), 'utf8'));
30
+ }
31
+ catch {
32
+ return {};
33
+ }
34
+ }
35
+ export function writeState(home, patch) {
36
+ const next = { ...readState(home), ...patch };
37
+ const file = join(home, 'launcher.json');
38
+ mkdirSync(home, { recursive: true });
39
+ writeFileSync(`${file}.${process.pid}.tmp`, JSON.stringify(next, null, 2) + '\n');
40
+ renameSync(`${file}.${process.pid}.tmp`, file);
41
+ return next;
42
+ }
43
+ /** Points `current` at an installed version in one rename, so a reader never sees a missing link. */
44
+ export function switchTo(home, version) {
45
+ if (!existsSync(versionDir(home, version)))
46
+ throw new Error(`version ${version} is not installed`);
47
+ const tmp = join(home, `current.${process.pid}.tmp`);
48
+ rmSync(tmp, { force: true });
49
+ symlinkSync(join('versions', version), tmp);
50
+ renameSync(tmp, join(home, 'current'));
51
+ }
52
+ /** Keeps current, previous and the newest others, three in all (the last two plus current); returns the removed. */
53
+ export function prune(home) {
54
+ const pinned = new Set([currentVersion(home), readState(home).previous].filter((v) => !!v));
55
+ const removed = installedVersions(home)
56
+ .filter((v) => !pinned.has(v))
57
+ .slice(Math.max(0, 3 - pinned.size));
58
+ for (const v of removed)
59
+ rmSync(versionDir(home, v), { recursive: true, force: true });
60
+ return removed;
61
+ }
62
+ function running(pid) {
63
+ try {
64
+ process.kill(pid, 0);
65
+ return true;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ /** One update at a time; a lock left by a dead process is taken over. */
72
+ export function lock(home) {
73
+ mkdirSync(home, { recursive: true });
74
+ const file = join(home, 'update.lock');
75
+ try {
76
+ writeFileSync(file, String(process.pid), { flag: 'wx' });
77
+ }
78
+ catch {
79
+ const pid = Number(readFileSync(file, 'utf8'));
80
+ if (pid > 0 && pid !== process.pid && running(pid))
81
+ throw new Error(`another update is running (pid ${pid})`);
82
+ writeFileSync(file, String(process.pid));
83
+ }
84
+ return () => rmSync(file, { force: true });
85
+ }
package/package.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "name": "@christoph-teichmeister/shub",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Launcher for shub, your session hub: installs, updates and starts signed shub releases.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/christoph-teichmeister/shub.git",
10
+ "directory": "launcher"
11
+ },
12
+ "bin": {
13
+ "shub": "dist/shub.js"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "release-keys.pem"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.json",
21
+ "prepack": "npm run build",
22
+ "test": "node --test \"src/**/*.test.ts\"",
23
+ "typecheck": "tsc -p tsconfig.json --noEmit"
24
+ },
25
+ "engines": {
26
+ "node": ">=24"
27
+ }
28
+ }
@@ -0,0 +1,7 @@
1
+ Public keys that sign shub releases (ed25519). An archive is installed only when SHA256SUMS carries a valid
2
+ signature from one of these keys, or from one in the release-keys.pem of the installed app version.
3
+ Rotation: docs/RELEASING.md.
4
+
5
+ -----BEGIN PUBLIC KEY-----
6
+ MCowBQYDK2VwAyEA3GqPSkgj4f9Unv1UYF8kHutUYzk3r/IZhVauvoa7h5U=
7
+ -----END PUBLIC KEY-----