conductor-remote 1.6.0 → 1.8.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 +38 -12
- package/bin/cli.js +11 -1
- package/dist/assets/index-Gog06HCM.css +1 -0
- package/dist/assets/index-McPNxH2y.js +40 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/package.json +1 -1
- package/scripts/qr.ts +7 -2
- package/scripts/service.ts +79 -8
- package/src/autoupdate.ts +179 -0
- package/src/server.ts +7 -1
- package/dist/assets/index-B9UZU-3v.js +0 -40
- package/dist/assets/index-BpQC-cUi.css +0 -1
package/dist/index.html
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
12
12
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
|
13
13
|
<title>Conductor Remote</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-McPNxH2y.js"></script>
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Gog06HCM.css">
|
|
16
16
|
<link rel="manifest" href="/manifest.webmanifest"></head>
|
|
17
17
|
<body>
|
|
18
18
|
<div id="root"></div>
|
package/dist/sw.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
if(!self.define){let e,i={};const n=(n,s)=>(n=new URL(n+".js",s).href,i[n]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=i,document.head.appendChild(e)}else e=n,importScripts(n),i()}).then(()=>{let e=i[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(s,o)=>{const r=e||("document"in self?document.currentScript.src:"")||location.href;if(i[r])return;let
|
|
1
|
+
if(!self.define){let e,i={};const n=(n,s)=>(n=new URL(n+".js",s).href,i[n]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=i,document.head.appendChild(e)}else e=n,importScripts(n),i()}).then(()=>{let e=i[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(s,o)=>{const r=e||("document"in self?document.currentScript.src:"")||location.href;if(i[r])return;let c={};const t=e=>n(e,r),l={module:{uri:r},exports:c,require:t};i[r]=Promise.all(s.map(e=>l[e]||t(e))).then(e=>(o(...e),c))}}define(["./workbox-9c191d2f"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"index.html",revision:"9b2a23f2db3c39277979e2a1d9c43902"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-McPNxH2y.js",revision:null},{url:"assets/index-Gog06HCM.css",revision:null},{url:"apple-touch-icon.png",revision:"2b9301416b880d45d4bb655f2600d1f2"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
|
package/package.json
CHANGED
package/scripts/qr.ts
CHANGED
|
@@ -381,8 +381,13 @@ function render(m: boolean[][], indent: string): string[] {
|
|
|
381
381
|
return lines
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
+
/** Encode `text` into its QR module matrix (`true` = dark). Shared by the terminal and web SVG renderers. */
|
|
385
|
+
export function qrMatrix(text: string): boolean[][] {
|
|
386
|
+
const { version, bits } = encodeText(text)
|
|
387
|
+
return buildMatrix(version, bits)
|
|
388
|
+
}
|
|
389
|
+
|
|
384
390
|
/** Encode `text` and return the terminal lines that draw its QR code (each already indented). */
|
|
385
391
|
export function qrLines(text: string, indent = ' '): string[] {
|
|
386
|
-
|
|
387
|
-
return render(buildMatrix(version, bits), indent)
|
|
392
|
+
return render(qrMatrix(text), indent)
|
|
388
393
|
}
|
package/scripts/service.ts
CHANGED
|
@@ -21,6 +21,45 @@ const logDir = path.join(os.homedir(), 'Library', 'Logs', 'conductor-remote')
|
|
|
21
21
|
const uid = process.getuid?.() ?? 0
|
|
22
22
|
const domain = `gui/${uid}`
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Install-time knobs are accepted as documented CLI flags OR the matching env var — a flag wins over the
|
|
26
|
+
* ambient env. Parsed flags are folded back into process.env so everything downstream (and the plist we
|
|
27
|
+
* bake) keeps reading a single source. Runs before any module-level env read below.
|
|
28
|
+
*/
|
|
29
|
+
const FLAG_ENV: Record<string, string> = {
|
|
30
|
+
'--expose': 'EXPOSE',
|
|
31
|
+
'--port': 'RELAY_PORT',
|
|
32
|
+
'--host': 'RELAY_HOST',
|
|
33
|
+
'--token': 'RELAY_TOKEN',
|
|
34
|
+
'--write-strategy': 'WRITE_STRATEGY',
|
|
35
|
+
'--auto-update': 'AUTO_UPDATE',
|
|
36
|
+
'--db': 'CONDUCTOR_DB',
|
|
37
|
+
'--workspaces': 'CONDUCTOR_WORKSPACES'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function applyFlags(argv: string[]): void {
|
|
41
|
+
for (let i = 0; i < argv.length; i++) {
|
|
42
|
+
const arg = argv[i]
|
|
43
|
+
if (!arg.startsWith('--')) continue
|
|
44
|
+
const eq = arg.indexOf('=')
|
|
45
|
+
const name = eq === -1 ? arg : arg.slice(0, eq)
|
|
46
|
+
const envKey = FLAG_ENV[name]
|
|
47
|
+
if (!envKey) {
|
|
48
|
+
console.error(`unknown flag: ${name}\n known: ${Object.keys(FLAG_ENV).join(', ')}`)
|
|
49
|
+
process.exit(1)
|
|
50
|
+
}
|
|
51
|
+
const value = eq === -1 ? argv[++i] : arg.slice(eq + 1)
|
|
52
|
+
if (value === undefined) {
|
|
53
|
+
console.error(`flag ${name} needs a value (e.g. ${name} <value>)`)
|
|
54
|
+
process.exit(1)
|
|
55
|
+
}
|
|
56
|
+
process.env[envKey] = value
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// argv[2] is the subcommand (see bottom); flags follow it.
|
|
61
|
+
applyFlags(process.argv.slice(3))
|
|
62
|
+
|
|
24
63
|
function xml(s: string): string {
|
|
25
64
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
26
65
|
}
|
|
@@ -81,11 +120,22 @@ function buildPlist(): string {
|
|
|
81
120
|
const proj = xml(projectDir)
|
|
82
121
|
const out = xml(path.join(logDir, 'relay.log'))
|
|
83
122
|
const err = xml(path.join(logDir, 'relay.err.log'))
|
|
84
|
-
//
|
|
85
|
-
|
|
123
|
+
// node's own dir leads so the daemon can find `npm` (adjacent to node) for self-update under launchd's
|
|
124
|
+
// bare PATH; Homebrew's bin is appended for tailscale/node on Apple Silicon.
|
|
125
|
+
const nodeDir = path.dirname(process.execPath)
|
|
126
|
+
const daemonPath = `${nodeDir}:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin`
|
|
127
|
+
// MANAGED marks this as the launchd-supervised instance: autoupdate.ts only self-restarts (exit →
|
|
128
|
+
// KeepAlive respawn) when it sees this, so a dev `yarn start` or worktree run never auto-updates.
|
|
129
|
+
const envEntries: Array<[string, string]> = [
|
|
130
|
+
['PATH', daemonPath],
|
|
131
|
+
['CONDUCTOR_REMOTE_MANAGED', '1']
|
|
132
|
+
]
|
|
86
133
|
if (process.env.WRITE_STRATEGY) envEntries.push(['WRITE_STRATEGY', process.env.WRITE_STRATEGY])
|
|
87
134
|
if (process.env.RELAY_HOST) envEntries.push(['RELAY_HOST', process.env.RELAY_HOST])
|
|
88
135
|
if (process.env.RELAY_PORT) envEntries.push(['RELAY_PORT', process.env.RELAY_PORT])
|
|
136
|
+
if (process.env.AUTO_UPDATE) envEntries.push(['AUTO_UPDATE', process.env.AUTO_UPDATE])
|
|
137
|
+
if (process.env.CONDUCTOR_DB) envEntries.push(['CONDUCTOR_DB', process.env.CONDUCTOR_DB])
|
|
138
|
+
if (process.env.CONDUCTOR_WORKSPACES) envEntries.push(['CONDUCTOR_WORKSPACES', process.env.CONDUCTOR_WORKSPACES])
|
|
89
139
|
const envXml = envEntries.map(([k, v]) => `\t\t<key>${xml(k)}</key>\n\t\t<string>${xml(v)}</string>`).join('\n')
|
|
90
140
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
91
141
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -123,20 +173,36 @@ function distBuilt(): boolean {
|
|
|
123
173
|
return fs.existsSync(path.join(projectDir, 'dist', 'index.html'))
|
|
124
174
|
}
|
|
125
175
|
|
|
176
|
+
function tokenStorePath(): string {
|
|
177
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'conductor-remote', 'token')
|
|
178
|
+
}
|
|
179
|
+
|
|
126
180
|
/** Read the persisted token (or env override) purely to print the phone URL — never mints one. */
|
|
127
181
|
function currentToken(): string | null {
|
|
128
182
|
if (process.env.RELAY_TOKEN) return process.env.RELAY_TOKEN
|
|
129
183
|
try {
|
|
130
|
-
return (
|
|
131
|
-
fs
|
|
132
|
-
.readFileSync(path.join(os.homedir(), 'Library', 'Application Support', 'conductor-remote', 'token'), 'utf8')
|
|
133
|
-
.trim() || null
|
|
134
|
-
)
|
|
184
|
+
return fs.readFileSync(tokenStorePath(), 'utf8').trim() || null
|
|
135
185
|
} catch {
|
|
136
186
|
return null
|
|
137
187
|
}
|
|
138
188
|
}
|
|
139
189
|
|
|
190
|
+
/**
|
|
191
|
+
* A pinned token (`--token` / `RELAY_TOKEN`) is persisted to the token file, not baked into the plist —
|
|
192
|
+
* the launchd daemon has no such env, so it resolves the secret from this file (config.ts ▸ resolveToken).
|
|
193
|
+
* Writing it here keeps the daemon, the printed URL, and later `status` all in agreement.
|
|
194
|
+
*/
|
|
195
|
+
function persistPinnedToken(): void {
|
|
196
|
+
const token = process.env.RELAY_TOKEN
|
|
197
|
+
if (!token) return
|
|
198
|
+
try {
|
|
199
|
+
fs.mkdirSync(path.dirname(tokenStorePath()), { recursive: true })
|
|
200
|
+
fs.writeFileSync(tokenStorePath(), token, { mode: 0o600 })
|
|
201
|
+
} catch (err) {
|
|
202
|
+
console.info(` ⚠ could not persist --token (${err instanceof Error ? err.message : err})`)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
140
206
|
const RELAY_PORT = process.env.RELAY_PORT ?? '8787'
|
|
141
207
|
|
|
142
208
|
/** Locate the tailscale CLI: PATH first, then the common macOS install locations. Null if absent. */
|
|
@@ -345,6 +411,7 @@ function install(): void {
|
|
|
345
411
|
console.error('✗ dist/ not built. Run `yarn build` first (or use `yarn deploy`, which builds).')
|
|
346
412
|
process.exit(1)
|
|
347
413
|
}
|
|
414
|
+
persistPinnedToken()
|
|
348
415
|
fs.mkdirSync(path.dirname(plistPath), { recursive: true })
|
|
349
416
|
fs.mkdirSync(logDir, { recursive: true })
|
|
350
417
|
fs.writeFileSync(plistPath, buildPlist())
|
|
@@ -409,6 +476,10 @@ switch (cmd) {
|
|
|
409
476
|
status()
|
|
410
477
|
break
|
|
411
478
|
default:
|
|
412
|
-
console.error(
|
|
479
|
+
console.error(
|
|
480
|
+
`unknown command: ${cmd}\n` +
|
|
481
|
+
'usage: service.ts <install|uninstall|restart|status> [flags]\n' +
|
|
482
|
+
` flags (install): ${Object.keys(FLAG_ENV).join(', ')}`
|
|
483
|
+
)
|
|
413
484
|
process.exit(1)
|
|
414
485
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-update for the globally-installed relay daemon.
|
|
3
|
+
*
|
|
4
|
+
* The relay ships as a global npm package driven by a KeepAlive LaunchAgent. Without this, staying
|
|
5
|
+
* current means the user manually re-running `npm i -g conductor-remote && conductor-remote service
|
|
6
|
+
* install`. Here the running daemon periodically asks the npm registry for the latest published
|
|
7
|
+
* version and, when it's newer, runs `npm i -g conductor-remote@latest` and exits — launchd's
|
|
8
|
+
* KeepAlive restarts it into the freshly-installed code (the plist's baked `bin/cli.js` path is stable
|
|
9
|
+
* across a global reinstall, so no re-`service install` is needed).
|
|
10
|
+
*
|
|
11
|
+
* Two hard gates keep this from firing where it shouldn't:
|
|
12
|
+
* - CONDUCTOR_REMOTE_MANAGED=1 — set only by `service install` in the plist, so it proves we are the
|
|
13
|
+
* launchd-managed daemon and that exit()→KeepAlive-restart is a safe way to reload.
|
|
14
|
+
* - projectDir has no `.git` — proves we're the published tarball, not a dev worktree. A worktree's
|
|
15
|
+
* LaunchAgent runs from the worktree path, so `npm i -g` wouldn't even swap its code; never touch it.
|
|
16
|
+
*
|
|
17
|
+
* `AUTO_UPDATE` overrides the default: `off` disables entirely; `check` polls and reports availability
|
|
18
|
+
* (via /api/state and the log) but never installs; `on` forces auto when the gates allow it.
|
|
19
|
+
*
|
|
20
|
+
* Stdlib + global fetch only — no runtime deps, no transform-requiring syntax (keeps the relay strip-clean).
|
|
21
|
+
*/
|
|
22
|
+
import { execFile } from 'node:child_process'
|
|
23
|
+
import fs from 'node:fs'
|
|
24
|
+
import path from 'node:path'
|
|
25
|
+
import { promisify } from 'node:util'
|
|
26
|
+
|
|
27
|
+
const execFileP = promisify(execFile)
|
|
28
|
+
|
|
29
|
+
const NAME = 'conductor-remote'
|
|
30
|
+
const projectDir = path.resolve(import.meta.dirname, '..')
|
|
31
|
+
const REGISTRY = process.env.NPM_REGISTRY ?? 'https://registry.npmjs.org'
|
|
32
|
+
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000
|
|
33
|
+
const FIRST_DELAY_MS = 90 * 1000
|
|
34
|
+
|
|
35
|
+
export type UpdateMode = 'off' | 'check' | 'auto'
|
|
36
|
+
|
|
37
|
+
export interface UpdateStatus {
|
|
38
|
+
/** Version this process is running (from the package's own package.json). */
|
|
39
|
+
current: string
|
|
40
|
+
/** Latest version seen on the registry, or null before the first successful check. */
|
|
41
|
+
latest: string | null
|
|
42
|
+
/** True when `latest` is a strictly higher release than `current`. */
|
|
43
|
+
available: boolean
|
|
44
|
+
/** Epoch ms of the last successful registry check, or null. */
|
|
45
|
+
checkedAt: number | null
|
|
46
|
+
/** Effective mode after the gates are applied. */
|
|
47
|
+
mode: UpdateMode
|
|
48
|
+
/** Last check/install error message, or null. */
|
|
49
|
+
lastError: string | null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readVersion(): string {
|
|
53
|
+
try {
|
|
54
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(projectDir, 'package.json'), 'utf8')) as { version?: string }
|
|
55
|
+
return pkg.version ?? '0.0.0'
|
|
56
|
+
} catch {
|
|
57
|
+
return '0.0.0'
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const CURRENT = readVersion()
|
|
62
|
+
|
|
63
|
+
const status: UpdateStatus = {
|
|
64
|
+
current: CURRENT,
|
|
65
|
+
latest: null,
|
|
66
|
+
available: false,
|
|
67
|
+
checkedAt: null,
|
|
68
|
+
mode: 'off',
|
|
69
|
+
lastError: null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Snapshot of the updater state, surfaced on /api/state so the phone can show the version and any update. */
|
|
73
|
+
export function updateStatus(): UpdateStatus {
|
|
74
|
+
return { ...status }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Parse `x.y.z` (ignoring any prerelease/build suffix) into a comparable tuple; null if unparseable. */
|
|
78
|
+
function parseVersion(v: string): [number, number, number] | null {
|
|
79
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim())
|
|
80
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** True when `candidate` is a strictly higher release than `base` (prereleases collapse to their x.y.z). */
|
|
84
|
+
function isNewer(candidate: string, base: string): boolean {
|
|
85
|
+
const a = parseVersion(candidate)
|
|
86
|
+
const b = parseVersion(base)
|
|
87
|
+
if (!a || !b) return false
|
|
88
|
+
for (let i = 0; i < 3; i++) {
|
|
89
|
+
if (a[i] > b[i]) return true
|
|
90
|
+
if (a[i] < b[i]) return false
|
|
91
|
+
}
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function fetchLatest(): Promise<string | null> {
|
|
96
|
+
// The `/latest` sub-endpoint serves the full manifest as application/json; the abbreviated
|
|
97
|
+
// `vnd.npm.install-v1+json` media type is only valid on the packument root and 406s here.
|
|
98
|
+
const res = await fetch(`${REGISTRY}/${NAME}/latest`, {
|
|
99
|
+
headers: { accept: 'application/json' },
|
|
100
|
+
signal: AbortSignal.timeout(10_000)
|
|
101
|
+
})
|
|
102
|
+
if (!res.ok) throw new Error(`registry ${res.status}`)
|
|
103
|
+
const body = (await res.json()) as { version?: string }
|
|
104
|
+
return body.version ?? null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Resolve npm next to the running node (Homebrew/nvm keep them in one bin dir); fall back to PATH. */
|
|
108
|
+
function npmBin(): string {
|
|
109
|
+
const adjacent = path.join(path.dirname(process.execPath), 'npm')
|
|
110
|
+
return fs.existsSync(adjacent) ? adjacent : 'npm'
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function installLatest(): Promise<void> {
|
|
114
|
+
await execFileP(npmBin(), ['install', '-g', `${NAME}@latest`], {
|
|
115
|
+
timeout: 300_000,
|
|
116
|
+
env: { ...process.env, npm_config_yes: 'true' }
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function log(msg: string): void {
|
|
121
|
+
console.info(`[auto-update] ${msg}`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Effective mode. Precedence: explicit AUTO_UPDATE > gated default.
|
|
126
|
+
* off — disabled. check — poll + report, never install. auto — poll + install + self-restart.
|
|
127
|
+
* `auto` (default when unset, or when AUTO_UPDATE=on) requires BOTH gates; without them, an explicit
|
|
128
|
+
* `on` degrades to `check` (visibility without an unsafe install) and an unset default degrades to `off`
|
|
129
|
+
* (a dev `yarn start` or worktree daemon stays silent).
|
|
130
|
+
*/
|
|
131
|
+
function resolveMode(): UpdateMode {
|
|
132
|
+
const raw = process.env.AUTO_UPDATE?.trim().toLowerCase()
|
|
133
|
+
if (raw === 'off' || raw === 'false' || raw === '0') return 'off'
|
|
134
|
+
if (raw === 'check' || raw === 'notify') return 'check'
|
|
135
|
+
const managed = process.env.CONDUCTOR_REMOTE_MANAGED === '1'
|
|
136
|
+
const published = !fs.existsSync(path.join(projectDir, '.git'))
|
|
137
|
+
const canAuto = managed && published
|
|
138
|
+
if (raw === 'on' || raw === 'auto' || raw === '1' || raw === 'true') return canAuto ? 'auto' : 'check'
|
|
139
|
+
return canAuto ? 'auto' : 'off'
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let inFlight = false
|
|
143
|
+
|
|
144
|
+
async function runCheck(mode: 'check' | 'auto'): Promise<void> {
|
|
145
|
+
if (inFlight) return
|
|
146
|
+
inFlight = true
|
|
147
|
+
try {
|
|
148
|
+
const latest = await fetchLatest()
|
|
149
|
+
status.latest = latest
|
|
150
|
+
status.checkedAt = Date.now()
|
|
151
|
+
status.available = latest != null && isNewer(latest, CURRENT)
|
|
152
|
+
status.lastError = null
|
|
153
|
+
if (!status.available || latest == null) return
|
|
154
|
+
if (mode === 'check') {
|
|
155
|
+
log(`update available: ${CURRENT} → ${latest} (AUTO_UPDATE=check — not installing)`)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
log(`updating ${CURRENT} → ${latest} via \`npm i -g ${NAME}@latest\`…`)
|
|
159
|
+
await installLatest()
|
|
160
|
+
log(`installed ${latest}; restarting to apply (launchd KeepAlive brings the relay back).`)
|
|
161
|
+
// Let the log line flush, then exit; KeepAlive respawns us into the new code.
|
|
162
|
+
setTimeout(() => process.exit(0), 500).unref()
|
|
163
|
+
} catch (err) {
|
|
164
|
+
status.lastError = err instanceof Error ? err.message : String(err)
|
|
165
|
+
log(`check/update failed: ${status.lastError}`)
|
|
166
|
+
} finally {
|
|
167
|
+
inFlight = false
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Start the periodic self-updater. Safe to call unconditionally — it no-ops unless the gates pass. */
|
|
172
|
+
export function startAutoUpdate(): void {
|
|
173
|
+
const mode = resolveMode()
|
|
174
|
+
status.mode = mode
|
|
175
|
+
if (mode === 'off') return
|
|
176
|
+
log(`enabled (mode=${mode}, current=${CURRENT}); first check in ${FIRST_DELAY_MS / 1000}s, then every 6h.`)
|
|
177
|
+
setTimeout(() => void runCheck(mode), FIRST_DELAY_MS).unref()
|
|
178
|
+
setInterval(() => void runCheck(mode), CHECK_INTERVAL_MS).unref()
|
|
179
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -2,6 +2,7 @@ import crypto from 'node:crypto'
|
|
|
2
2
|
import fs from 'node:fs'
|
|
3
3
|
import http from 'node:http'
|
|
4
4
|
import path from 'node:path'
|
|
5
|
+
import { startAutoUpdate, updateStatus } from './autoupdate.ts'
|
|
5
6
|
import { loadConfig } from './config.ts'
|
|
6
7
|
import { ConductorDb } from './db.ts'
|
|
7
8
|
import { workspaceDiff } from './git.ts'
|
|
@@ -92,9 +93,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
92
93
|
try {
|
|
93
94
|
// GET /api/state — workspace list with active-session status
|
|
94
95
|
if (req.method === 'GET' && pathname === '/api/state') {
|
|
96
|
+
const update = updateStatus()
|
|
95
97
|
return json(res, 200, {
|
|
96
98
|
workspaces: reads.listWorkspaces(),
|
|
97
|
-
actuator: await describeActuator(actuator)
|
|
99
|
+
actuator: await describeActuator(actuator),
|
|
100
|
+
version: update.current,
|
|
101
|
+
update
|
|
98
102
|
})
|
|
99
103
|
}
|
|
100
104
|
|
|
@@ -185,4 +189,6 @@ server.listen(cfg.port, cfg.host, () => {
|
|
|
185
189
|
' Phone: fronted by `tailscale funnel`/`serve` — run `yarn service status` for the HTTPS URL'
|
|
186
190
|
].join('\n')
|
|
187
191
|
)
|
|
192
|
+
// Keep the managed global daemon current — no-ops for dev checkouts / unmanaged runs (see autoupdate.ts).
|
|
193
|
+
startAutoUpdate()
|
|
188
194
|
})
|