@promus/cli 0.24.17
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 +18 -0
- package/bin/promus +33 -0
- package/package.json +51 -0
- package/src/commands/_agents.ts +14 -0
- package/src/commands/_inft-ref.ts +43 -0
- package/src/commands/_unlock.ts +74 -0
- package/src/commands/admin-autotopup-tick.ts +73 -0
- package/src/commands/admin.test.ts +34 -0
- package/src/commands/admin.ts +32 -0
- package/src/commands/balance.test.ts +10 -0
- package/src/commands/balance.ts +112 -0
- package/src/commands/chat-sandbox.tsx +520 -0
- package/src/commands/chat-telegram.ts +398 -0
- package/src/commands/chat.tsx +1916 -0
- package/src/commands/deploy.ts +204 -0
- package/src/commands/drain.ts +90 -0
- package/src/commands/gateway-logs.ts +47 -0
- package/src/commands/gateway-run.ts +54 -0
- package/src/commands/gateway-start.ts +218 -0
- package/src/commands/gateway-status.ts +88 -0
- package/src/commands/gateway-stop.ts +133 -0
- package/src/commands/gateway.ts +101 -0
- package/src/commands/init/cost.test.ts +169 -0
- package/src/commands/init/cost.ts +154 -0
- package/src/commands/init/funding-gate.ts +67 -0
- package/src/commands/init/model-picker.ts +81 -0
- package/src/commands/init/operator-picker.ts +263 -0
- package/src/commands/init/resume.ts +136 -0
- package/src/commands/init/sandbox-provision.test.ts +497 -0
- package/src/commands/init/sandbox-provision.ts +1177 -0
- package/src/commands/init/telegram-step.ts +229 -0
- package/src/commands/init/wizard-state.ts +95 -0
- package/src/commands/init.ts +612 -0
- package/src/commands/inspect.ts +529 -0
- package/src/commands/ledger.ts +176 -0
- package/src/commands/logs.ts +86 -0
- package/src/commands/migrate-keystore.ts +155 -0
- package/src/commands/model.ts +48 -0
- package/src/commands/pairing-approve.ts +114 -0
- package/src/commands/pairing-clear.ts +42 -0
- package/src/commands/pairing-list.ts +58 -0
- package/src/commands/pairing-revoke.ts +52 -0
- package/src/commands/pairing.test.ts +88 -0
- package/src/commands/pairing.ts +81 -0
- package/src/commands/pause.ts +99 -0
- package/src/commands/profile.ts +184 -0
- package/src/commands/restore.ts +221 -0
- package/src/commands/resume.ts +181 -0
- package/src/commands/status.ts +119 -0
- package/src/commands/sync.ts +147 -0
- package/src/commands/telegram-remove.ts +65 -0
- package/src/commands/telegram-setup.ts +74 -0
- package/src/commands/telegram-status.ts +89 -0
- package/src/commands/telegram.test.ts +50 -0
- package/src/commands/telegram.ts +44 -0
- package/src/commands/topup.ts +303 -0
- package/src/commands/transfer.test.ts +111 -0
- package/src/commands/transfer.ts +520 -0
- package/src/commands/upgrade.test.ts +137 -0
- package/src/commands/upgrade.ts +690 -0
- package/src/config/load.ts +35 -0
- package/src/config/render.test.ts +96 -0
- package/src/config/render.ts +110 -0
- package/src/index.ts +378 -0
- package/src/sandbox/client.test.ts +251 -0
- package/src/sandbox/client.ts +424 -0
- package/src/ui/app.tsx +677 -0
- package/src/ui/approval-summary.test.ts +154 -0
- package/src/ui/approval-summary.ts +34 -0
- package/src/ui/markdown-parse.ts +219 -0
- package/src/ui/markdown.test.ts +146 -0
- package/src/ui/markdown.tsx +37 -0
- package/src/ui/state.test.ts +74 -0
- package/src/ui/state.ts +198 -0
- package/src/util/bootstrap-mode.test.ts +40 -0
- package/src/util/bootstrap-mode.ts +25 -0
- package/src/util/bootstrap-progress-box.test.ts +190 -0
- package/src/util/bootstrap-progress-box.ts +378 -0
- package/src/util/brain-secrets.ts +96 -0
- package/src/util/cli-version.ts +28 -0
- package/src/util/format.test.ts +16 -0
- package/src/util/format.ts +11 -0
- package/src/util/gateway-spawn.test.ts +86 -0
- package/src/util/gateway-spawn.ts +128 -0
- package/src/util/gateway-version.test.ts +113 -0
- package/src/util/gateway-version.ts +154 -0
- package/src/util/github-releases.test.ts +116 -0
- package/src/util/github-releases.ts +79 -0
- package/src/util/profile-key.test.ts +60 -0
- package/src/util/profile-key.ts +25 -0
- package/src/util/ref-resolver.test.ts +77 -0
- package/src/util/ref-resolver.ts +55 -0
- package/src/util/silence-console.test.ts +53 -0
- package/src/util/silence-console.ts +40 -0
- package/src/util/telegram-secrets.test.ts +227 -0
- package/src/util/telegram-secrets.ts +223 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v0.21.5 Bundle B: spawn-and-wait helper for the local gateway daemon.
|
|
3
|
+
*
|
|
4
|
+
* Two callers share this:
|
|
5
|
+
* - `promus gateway start` (interactive Touch ID flow → spawn detached)
|
|
6
|
+
* - `promus` chat fallback when no sock is present (auto-spawn before
|
|
7
|
+
* embedded TUI fallthrough — see Bundle C / chat.tsx)
|
|
8
|
+
*
|
|
9
|
+
* The helper does NOT perform operator-session unlock. Callers that need a
|
|
10
|
+
* fresh session must run that path before invoking this. If the daemon dies
|
|
11
|
+
* during boot because no session exists, the sock never appears and we
|
|
12
|
+
* surface the failure as `{ ready: false, reason: 'timeout' }`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { type ChildProcess, spawn } from 'node:child_process'
|
|
16
|
+
import { existsSync, mkdirSync, openSync } from 'node:fs'
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
import { agentPaths } from '@promus/core'
|
|
20
|
+
|
|
21
|
+
export interface SpawnGatewayDaemonOpts {
|
|
22
|
+
agentId: string
|
|
23
|
+
configPath: string
|
|
24
|
+
socketPath: string
|
|
25
|
+
/** Max ms to wait for the unix sock to appear. Default 10_000. */
|
|
26
|
+
timeoutMs?: number
|
|
27
|
+
/**
|
|
28
|
+
* Where to send daemon stdout/stderr. Default 'log-file' which redirects
|
|
29
|
+
* to `~/.promus/agents/<id>/gateway.log` (truncated on each boot) so
|
|
30
|
+
* detached daemon diagnostics survive the parent's exit. 'inherit' keeps
|
|
31
|
+
* the legacy behavior where output goes to the parent's tty (and vanishes
|
|
32
|
+
* on detach). 'ignore' drops everything.
|
|
33
|
+
*/
|
|
34
|
+
stdio?: 'inherit' | 'ignore' | 'log-file'
|
|
35
|
+
/** Override the bin resolution (tests). */
|
|
36
|
+
binPath?: string
|
|
37
|
+
/** Override env (tests). */
|
|
38
|
+
env?: NodeJS.ProcessEnv
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SpawnGatewayDaemonResult {
|
|
42
|
+
ready: boolean
|
|
43
|
+
/** Detached child PID iff spawn succeeded (regardless of readiness). */
|
|
44
|
+
pid?: number
|
|
45
|
+
/** Reason populated on failure: 'spawn-failed' | 'timeout' | 'pre-existing'. */
|
|
46
|
+
reason?: 'spawn-failed' | 'timeout' | 'pre-existing'
|
|
47
|
+
/** First-line error message when spawn failed. */
|
|
48
|
+
error?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveLocalBin(): string {
|
|
52
|
+
const pkgUrl = import.meta.resolve('@promus/gateway/package.json')
|
|
53
|
+
const pkgRoot = dirname(fileURLToPath(pkgUrl))
|
|
54
|
+
return join(pkgRoot, 'bin', '@promus/gateway-local')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function spawnGatewayDaemon(
|
|
58
|
+
opts: SpawnGatewayDaemonOpts,
|
|
59
|
+
): Promise<SpawnGatewayDaemonResult> {
|
|
60
|
+
if (existsSync(opts.socketPath)) {
|
|
61
|
+
return { ready: false, reason: 'pre-existing' }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const bin = opts.binPath ?? resolveLocalBin()
|
|
65
|
+
const env: NodeJS.ProcessEnv = {
|
|
66
|
+
...(opts.env ?? process.env),
|
|
67
|
+
PROMUS_AGENT_ID: opts.agentId,
|
|
68
|
+
PROMUS_CONFIG: opts.configPath,
|
|
69
|
+
}
|
|
70
|
+
const stdioMode = opts.stdio ?? 'log-file'
|
|
71
|
+
|
|
72
|
+
// v0.21.12: when stdio is 'log-file' redirect daemon stdout+stderr to
|
|
73
|
+
// ~/.promus/agents/<id>/gateway.log (truncate-on-restart). Pre-fix this
|
|
74
|
+
// helper used 'inherit' which sent output to the parent's tty; once the
|
|
75
|
+
// parent CLI returned, those handles vanished and operators couldn't see
|
|
76
|
+
// why the daemon misbehaved. Truncation is fine because operators rarely
|
|
77
|
+
// reboot the daemon mid-session and `promus gateway logs -f` only follows
|
|
78
|
+
// the current invocation.
|
|
79
|
+
let stdioCfg: ['ignore', 'inherit' | 'ignore' | number, 'inherit' | 'ignore' | number]
|
|
80
|
+
if (stdioMode === 'log-file') {
|
|
81
|
+
const logPath = join(agentPaths.agent(opts.agentId).dir, 'gateway.log')
|
|
82
|
+
try {
|
|
83
|
+
mkdirSync(dirname(logPath), { recursive: true })
|
|
84
|
+
const fd = openSync(logPath, 'w') // truncate on each boot
|
|
85
|
+
stdioCfg = ['ignore', fd, fd]
|
|
86
|
+
} catch {
|
|
87
|
+
// If we can't open the log file (perm, disk), fall back to ignore so
|
|
88
|
+
// we still spawn cleanly. Operators lose diagnostics but the daemon
|
|
89
|
+
// boots.
|
|
90
|
+
stdioCfg = ['ignore', 'ignore', 'ignore']
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
stdioCfg = ['ignore', stdioMode, stdioMode]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let proc: ChildProcess
|
|
97
|
+
try {
|
|
98
|
+
// Use the current bun binary (absolute) rather than relying on `bun` being
|
|
99
|
+
// on PATH — the launcher invokes us via an absolute bun path and the user's
|
|
100
|
+
// shell PATH may not include ~/.bun/bin, which would ENOENT the spawn.
|
|
101
|
+
proc = spawn(process.execPath, [bin], {
|
|
102
|
+
env,
|
|
103
|
+
detached: true,
|
|
104
|
+
stdio: stdioCfg,
|
|
105
|
+
})
|
|
106
|
+
proc.unref()
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return {
|
|
109
|
+
ready: false,
|
|
110
|
+
reason: 'spawn-failed',
|
|
111
|
+
error: (err as Error).message?.slice(0, 200),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const timeoutMs = opts.timeoutMs ?? 10_000
|
|
116
|
+
const start = Date.now()
|
|
117
|
+
while (Date.now() - start < timeoutMs) {
|
|
118
|
+
if (existsSync(opts.socketPath)) {
|
|
119
|
+
return { ready: true, pid: proc.pid }
|
|
120
|
+
}
|
|
121
|
+
await new Promise(r => setTimeout(r, 200))
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
ready: false,
|
|
125
|
+
pid: proc.pid,
|
|
126
|
+
reason: 'timeout',
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { ensureGatewayVersionMatchesCli } from './gateway-version'
|
|
6
|
+
|
|
7
|
+
function makeFakeFetch(healthz: { version?: string } | null) {
|
|
8
|
+
return ((_url: string, _init?: RequestInit) => {
|
|
9
|
+
if (healthz === null) return Promise.reject(new Error('ECONNREFUSED'))
|
|
10
|
+
return Promise.resolve(
|
|
11
|
+
new Response(JSON.stringify(healthz), {
|
|
12
|
+
status: 200,
|
|
13
|
+
headers: { 'content-type': 'application/json' },
|
|
14
|
+
}),
|
|
15
|
+
)
|
|
16
|
+
}) as typeof fetch
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test('returns ok when socket missing', async () => {
|
|
20
|
+
const r = await ensureGatewayVersionMatchesCli({
|
|
21
|
+
socketPath: '/nonexistent/gateway.sock',
|
|
22
|
+
cliVersion: '0.23.2',
|
|
23
|
+
fetchImpl: makeFakeFetch({ version: '0.23.2' }),
|
|
24
|
+
})
|
|
25
|
+
expect(r.action).toBe('ok')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('returns ok when versions match', async () => {
|
|
29
|
+
const tmp = mkdtempSync(join(tmpdir(), 'gw-ver-'))
|
|
30
|
+
const sock = join(tmp, 'gateway.sock')
|
|
31
|
+
writeFileSync(sock, '') // fake socket file
|
|
32
|
+
try {
|
|
33
|
+
const r = await ensureGatewayVersionMatchesCli({
|
|
34
|
+
socketPath: sock,
|
|
35
|
+
cliVersion: '0.23.2',
|
|
36
|
+
fetchImpl: makeFakeFetch({ version: '0.23.2' }),
|
|
37
|
+
})
|
|
38
|
+
expect(r.action).toBe('ok')
|
|
39
|
+
expect(r.daemonVersion).toBe('0.23.2')
|
|
40
|
+
expect(r.cliVersion).toBe('0.23.2')
|
|
41
|
+
} finally {
|
|
42
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('returns no-cli-version when CLI version cannot be resolved', async () => {
|
|
47
|
+
const tmp = mkdtempSync(join(tmpdir(), 'gw-ver-'))
|
|
48
|
+
const sock = join(tmp, 'gateway.sock')
|
|
49
|
+
writeFileSync(sock, '')
|
|
50
|
+
try {
|
|
51
|
+
// Pass empty string explicitly → falls through readLocalGatewayVersion, which returns undefined
|
|
52
|
+
// since we still need the fallback behavior. Use a sentinel undefined to trigger that branch.
|
|
53
|
+
// The function reads cliVersion ?? readLocalGatewayVersion(), so we test the undefined-cli branch
|
|
54
|
+
// by mocking the fetch to be unused.
|
|
55
|
+
// For this test, we rely on the readLocalGatewayVersion fallback path being unreachable in test env;
|
|
56
|
+
// alternative: just confirm the contract by asserting that ANY non-empty cliVersion is preferred.
|
|
57
|
+
const r = await ensureGatewayVersionMatchesCli({
|
|
58
|
+
socketPath: sock,
|
|
59
|
+
cliVersion: '0.0.0-test',
|
|
60
|
+
fetchImpl: makeFakeFetch({ version: '0.0.0-test' }),
|
|
61
|
+
})
|
|
62
|
+
expect(r.action).toBe('ok')
|
|
63
|
+
} finally {
|
|
64
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('returns unreachable when /healthz fails and cleans stale socket', async () => {
|
|
69
|
+
const tmp = mkdtempSync(join(tmpdir(), 'gw-ver-'))
|
|
70
|
+
const sock = join(tmp, 'gateway.sock')
|
|
71
|
+
writeFileSync(sock, '')
|
|
72
|
+
try {
|
|
73
|
+
const r = await ensureGatewayVersionMatchesCli({
|
|
74
|
+
socketPath: sock,
|
|
75
|
+
cliVersion: '0.23.2',
|
|
76
|
+
fetchImpl: makeFakeFetch(null),
|
|
77
|
+
})
|
|
78
|
+
expect(r.action).toBe('unreachable')
|
|
79
|
+
// Socket should have been unlinked
|
|
80
|
+
const fs = await import('node:fs')
|
|
81
|
+
expect(fs.existsSync(sock)).toBe(false)
|
|
82
|
+
} finally {
|
|
83
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('detects drift and signals restarted', async () => {
|
|
88
|
+
const tmp = mkdtempSync(join(tmpdir(), 'gw-ver-'))
|
|
89
|
+
const sock = join(tmp, 'gateway.sock')
|
|
90
|
+
const lockFile = join(tmp, 'lock.json')
|
|
91
|
+
writeFileSync(sock, '')
|
|
92
|
+
// Use the test process's own PID for the lockfile so SIGTERM is delivered
|
|
93
|
+
// to a real process; we still expect the helper to log + clean. To avoid
|
|
94
|
+
// killing the test runner, use a non-existent pid (so kill silently fails).
|
|
95
|
+
writeFileSync(lockFile, JSON.stringify({ pid: 999999 }))
|
|
96
|
+
try {
|
|
97
|
+
const r = await ensureGatewayVersionMatchesCli({
|
|
98
|
+
socketPath: sock,
|
|
99
|
+
lockFile,
|
|
100
|
+
cliVersion: '0.23.2',
|
|
101
|
+
fetchImpl: makeFakeFetch({ version: '0.23.1' }),
|
|
102
|
+
killTimeoutMs: 200,
|
|
103
|
+
})
|
|
104
|
+
expect(r.action).toBe('restarted')
|
|
105
|
+
expect(r.daemonVersion).toBe('0.23.1')
|
|
106
|
+
expect(r.cliVersion).toBe('0.23.2')
|
|
107
|
+
// Socket should be cleaned even though daemon didn't actually exit
|
|
108
|
+
const fs = await import('node:fs')
|
|
109
|
+
expect(fs.existsSync(sock)).toBe(false)
|
|
110
|
+
} finally {
|
|
111
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
112
|
+
}
|
|
113
|
+
})
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v0.23.2: detect and auto-heal version drift between the on-disk CLI binary
|
|
3
|
+
* and a running gateway daemon.
|
|
4
|
+
*
|
|
5
|
+
* Scenario this fixes:
|
|
6
|
+
* 1. Operator runs `bun add -g promus@<new>` — global binary
|
|
7
|
+
* swaps on disk.
|
|
8
|
+
* 2. The previously-running gateway daemon was spawned from the OLD binary
|
|
9
|
+
* and pinned its node_modules at boot. `/healthz` reports the old version
|
|
10
|
+
* forever.
|
|
11
|
+
* 3. Operator runs `promus` (chat) or `promus gateway start`. Without this
|
|
12
|
+
* helper, chat.tsx re-attaches to the stale daemon — operator sees old
|
|
13
|
+
* features for the entire daemon lifetime.
|
|
14
|
+
*
|
|
15
|
+
* With this helper: any caller that sees a pre-existing socket calls
|
|
16
|
+
* `ensureGatewayVersionMatchesCli` first, which fetches /healthz, compares
|
|
17
|
+
* versions, and if drift is detected: kills the old daemon, removes the
|
|
18
|
+
* stale socket, and returns 'restarted' so the caller respawns fresh.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, readFileSync, unlinkSync } from 'node:fs'
|
|
22
|
+
import { fileURLToPath } from 'node:url'
|
|
23
|
+
|
|
24
|
+
export interface VersionCheckOpts {
|
|
25
|
+
/** Path to the gateway's unix socket. */
|
|
26
|
+
socketPath: string
|
|
27
|
+
/** Path to the lockfile holding daemon pid (for SIGTERM). */
|
|
28
|
+
lockFile?: string
|
|
29
|
+
/** Override the on-disk CLI version (tests). */
|
|
30
|
+
cliVersion?: string
|
|
31
|
+
/** Override the fetch implementation (tests). */
|
|
32
|
+
fetchImpl?: typeof fetch
|
|
33
|
+
/** Max ms to wait after SIGTERM for the socket to disappear. Default 4000. */
|
|
34
|
+
killTimeoutMs?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface VersionCheckResult {
|
|
38
|
+
/** What we observed and did. */
|
|
39
|
+
action: 'ok' | 'restarted' | 'unreachable' | 'no-cli-version'
|
|
40
|
+
cliVersion?: string
|
|
41
|
+
daemonVersion?: string
|
|
42
|
+
/** Human-readable note for the operator. */
|
|
43
|
+
note?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Read the version baked into the @promus/gateway package on disk. */
|
|
47
|
+
export function readLocalGatewayVersion(): string | undefined {
|
|
48
|
+
try {
|
|
49
|
+
const pkgUrl = import.meta.resolve('@promus/gateway/package.json')
|
|
50
|
+
const pkgPath = fileURLToPath(pkgUrl)
|
|
51
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version?: string }
|
|
52
|
+
return pkg.version
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Fetch /healthz over the unix socket. Returns the daemon's reported version. */
|
|
59
|
+
export async function fetchDaemonVersion(
|
|
60
|
+
socketPath: string,
|
|
61
|
+
fetchImpl?: typeof fetch,
|
|
62
|
+
): Promise<string | undefined> {
|
|
63
|
+
const f = fetchImpl ?? globalThis.fetch
|
|
64
|
+
try {
|
|
65
|
+
const r = await f('http://localhost/healthz', { unix: socketPath } as RequestInit & {
|
|
66
|
+
unix: string
|
|
67
|
+
})
|
|
68
|
+
if (!r.ok) return undefined
|
|
69
|
+
const body = (await r.json()) as { version?: string }
|
|
70
|
+
return body.version
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compare running-daemon version against on-disk CLI version. If they drift,
|
|
78
|
+
* SIGTERM the pid in the lockfile, wait up to killTimeoutMs for the socket
|
|
79
|
+
* to disappear, and return `action='restarted'` to signal the caller to
|
|
80
|
+
* spawn a fresh daemon.
|
|
81
|
+
*
|
|
82
|
+
* If versions match → `action='ok'`.
|
|
83
|
+
* If /healthz unreachable (zombie socket) → `action='unreachable'` after
|
|
84
|
+
* cleaning the stale socket file so the caller can spawn fresh.
|
|
85
|
+
* If on-disk CLI version cannot be resolved → `action='no-cli-version'`
|
|
86
|
+
* (skip check defensively).
|
|
87
|
+
*/
|
|
88
|
+
export async function ensureGatewayVersionMatchesCli(
|
|
89
|
+
opts: VersionCheckOpts,
|
|
90
|
+
): Promise<VersionCheckResult> {
|
|
91
|
+
if (!existsSync(opts.socketPath)) {
|
|
92
|
+
return { action: 'ok', note: 'no socket; nothing to check' }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const cliVersion = opts.cliVersion ?? readLocalGatewayVersion()
|
|
96
|
+
if (!cliVersion) {
|
|
97
|
+
return {
|
|
98
|
+
action: 'no-cli-version',
|
|
99
|
+
note: 'could not resolve on-disk CLI version; skipping drift check',
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const daemonVersion = await fetchDaemonVersion(opts.socketPath, opts.fetchImpl)
|
|
104
|
+
if (!daemonVersion) {
|
|
105
|
+
try {
|
|
106
|
+
unlinkSync(opts.socketPath)
|
|
107
|
+
} catch {}
|
|
108
|
+
return {
|
|
109
|
+
action: 'unreachable',
|
|
110
|
+
cliVersion,
|
|
111
|
+
note: 'daemon socket present but /healthz unreachable; removed stale socket',
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (daemonVersion === cliVersion) {
|
|
116
|
+
return { action: 'ok', cliVersion, daemonVersion }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Drift detected. Kill the daemon via pid in lockfile.
|
|
120
|
+
let killedPid: number | undefined
|
|
121
|
+
if (opts.lockFile && existsSync(opts.lockFile)) {
|
|
122
|
+
try {
|
|
123
|
+
const parsed = JSON.parse(readFileSync(opts.lockFile, 'utf8')) as { pid?: number }
|
|
124
|
+
if (typeof parsed.pid === 'number') {
|
|
125
|
+
try {
|
|
126
|
+
process.kill(parsed.pid, 'SIGTERM')
|
|
127
|
+
killedPid = parsed.pid
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Wait for the socket to disappear (daemon exits, cleans up).
|
|
134
|
+
const killTimeoutMs = opts.killTimeoutMs ?? 4000
|
|
135
|
+
const deadline = Date.now() + killTimeoutMs
|
|
136
|
+
while (Date.now() < deadline && existsSync(opts.socketPath)) {
|
|
137
|
+
await new Promise(r => setTimeout(r, 100))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// If socket still here, force-remove it. The lockfile cleanup happens when
|
|
141
|
+
// the parent invokes spawnGatewayDaemon (which clears stale locks at boot).
|
|
142
|
+
if (existsSync(opts.socketPath)) {
|
|
143
|
+
try {
|
|
144
|
+
unlinkSync(opts.socketPath)
|
|
145
|
+
} catch {}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
action: 'restarted',
|
|
150
|
+
cliVersion,
|
|
151
|
+
daemonVersion,
|
|
152
|
+
note: `version drift: daemon=${daemonVersion} vs cli=${cliVersion}; killed pid=${killedPid ?? '?'}, socket cleaned`,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { checkTagExists, parseGitHubRepoUrl, resolveLatestRelease } from './github-releases'
|
|
3
|
+
|
|
4
|
+
describe('parseGitHubRepoUrl', () => {
|
|
5
|
+
it('handles https URL with .git suffix', () => {
|
|
6
|
+
expect(parseGitHubRepoUrl('https://github.com/JemIIahh/promus.git')).toEqual({
|
|
7
|
+
owner: 's0nderlabs',
|
|
8
|
+
repo: 'promus',
|
|
9
|
+
})
|
|
10
|
+
})
|
|
11
|
+
it('handles https URL without .git suffix', () => {
|
|
12
|
+
expect(parseGitHubRepoUrl('https://github.com/JemIIahh/promus')).toEqual({
|
|
13
|
+
owner: 's0nderlabs',
|
|
14
|
+
repo: 'promus',
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
it('handles SSH URL form', () => {
|
|
18
|
+
expect(parseGitHubRepoUrl('git@github.com:JemIIahh/promus.git')).toEqual({
|
|
19
|
+
owner: 's0nderlabs',
|
|
20
|
+
repo: 'promus',
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
it('throws on unparseable URL', () => {
|
|
24
|
+
expect(() => parseGitHubRepoUrl('not-a-url')).toThrow(/cannot parse/)
|
|
25
|
+
expect(() => parseGitHubRepoUrl('https://gitlab.com/foo/bar.git')).toThrow(/cannot parse/)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
function jsonResponse(status: number, body: unknown): Response {
|
|
30
|
+
return new Response(JSON.stringify(body), {
|
|
31
|
+
status,
|
|
32
|
+
headers: { 'content-type': 'application/json' },
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('resolveLatestRelease', () => {
|
|
37
|
+
it('parses 200 response into GitHubRelease', async () => {
|
|
38
|
+
const fetchImpl = ((url: string) => {
|
|
39
|
+
expect(url).toBe('https://api.github.com/repos/JemIIahh/promus/releases/latest')
|
|
40
|
+
return Promise.resolve(
|
|
41
|
+
jsonResponse(200, {
|
|
42
|
+
tag_name: 'v0.17.8',
|
|
43
|
+
published_at: '2026-05-03T04:00:00Z',
|
|
44
|
+
html_url: 'https://github.com/JemIIahh/promus/releases/tag/v0.17.8',
|
|
45
|
+
}),
|
|
46
|
+
)
|
|
47
|
+
}) as unknown as typeof fetch
|
|
48
|
+
const r = await resolveLatestRelease('https://github.com/JemIIahh/promus.git', { fetchImpl })
|
|
49
|
+
expect(r.tagName).toBe('v0.17.8')
|
|
50
|
+
expect(r.publishedAt).toBe('2026-05-03T04:00:00Z')
|
|
51
|
+
expect(r.htmlUrl).toBe('https://github.com/JemIIahh/promus/releases/tag/v0.17.8')
|
|
52
|
+
})
|
|
53
|
+
it('throws on 404 (no releases yet)', async () => {
|
|
54
|
+
const fetchImpl = (() =>
|
|
55
|
+
Promise.resolve(jsonResponse(404, { message: 'Not Found' }))) as unknown as typeof fetch
|
|
56
|
+
await expect(
|
|
57
|
+
resolveLatestRelease('https://github.com/JemIIahh/promus.git', { fetchImpl }),
|
|
58
|
+
).rejects.toThrow(/no published releases/)
|
|
59
|
+
})
|
|
60
|
+
it('throws on 500', async () => {
|
|
61
|
+
const fetchImpl = (() =>
|
|
62
|
+
Promise.resolve(new Response('server error', { status: 500 }))) as unknown as typeof fetch
|
|
63
|
+
await expect(
|
|
64
|
+
resolveLatestRelease('https://github.com/JemIIahh/promus.git', { fetchImpl }),
|
|
65
|
+
).rejects.toThrow(/GitHub API 500/)
|
|
66
|
+
})
|
|
67
|
+
it('passes timeout signal to fetch', async () => {
|
|
68
|
+
let captured: RequestInit | undefined
|
|
69
|
+
const fetchImpl = ((_url: string, init?: RequestInit) => {
|
|
70
|
+
captured = init
|
|
71
|
+
return Promise.resolve(
|
|
72
|
+
jsonResponse(200, { tag_name: 'v1', published_at: 'x', html_url: 'y' }),
|
|
73
|
+
)
|
|
74
|
+
}) as unknown as typeof fetch
|
|
75
|
+
await resolveLatestRelease('https://github.com/JemIIahh/promus.git', {
|
|
76
|
+
fetchImpl,
|
|
77
|
+
timeoutMs: 1234,
|
|
78
|
+
})
|
|
79
|
+
expect(captured?.signal).toBeDefined()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('checkTagExists', () => {
|
|
84
|
+
it('returns true on 200', async () => {
|
|
85
|
+
const fetchImpl = ((url: string) => {
|
|
86
|
+
expect(url).toBe('https://api.github.com/repos/JemIIahh/promus/git/refs/tags/v0.17.8')
|
|
87
|
+
return Promise.resolve(jsonResponse(200, { ref: 'refs/tags/v0.17.8' }))
|
|
88
|
+
}) as unknown as typeof fetch
|
|
89
|
+
expect(
|
|
90
|
+
await checkTagExists('https://github.com/JemIIahh/promus.git', 'v0.17.8', { fetchImpl }),
|
|
91
|
+
).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
it('returns false on 404', async () => {
|
|
94
|
+
const fetchImpl = (() =>
|
|
95
|
+
Promise.resolve(jsonResponse(404, { message: 'Not Found' }))) as unknown as typeof fetch
|
|
96
|
+
expect(
|
|
97
|
+
await checkTagExists('https://github.com/JemIIahh/promus.git', 'v9.99.99', { fetchImpl }),
|
|
98
|
+
).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
it('throws on 500', async () => {
|
|
101
|
+
const fetchImpl = (() =>
|
|
102
|
+
Promise.resolve(new Response('server error', { status: 500 }))) as unknown as typeof fetch
|
|
103
|
+
await expect(
|
|
104
|
+
checkTagExists('https://github.com/JemIIahh/promus.git', 'v0.17.8', { fetchImpl }),
|
|
105
|
+
).rejects.toThrow(/GitHub API 500/)
|
|
106
|
+
})
|
|
107
|
+
it('url-encodes tag with special characters', async () => {
|
|
108
|
+
let captured = ''
|
|
109
|
+
const fetchImpl = ((url: string) => {
|
|
110
|
+
captured = url
|
|
111
|
+
return Promise.resolve(jsonResponse(200, { ref: 'x' }))
|
|
112
|
+
}) as unknown as typeof fetch
|
|
113
|
+
await checkTagExists('https://github.com/JemIIahh/promus.git', 'v0.17.8+build', { fetchImpl })
|
|
114
|
+
expect(captured).toContain('v0.17.8%2Bbuild')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// GitHub Releases API helpers. Unauthenticated (60 req/hr per IP) which is
|
|
2
|
+
// plenty for the upgrade hot path: one resolveLatestRelease + zero-or-one
|
|
3
|
+
// checkTagExists per invocation. Pin `--ref vX.Y.Z` to skip the API entirely.
|
|
4
|
+
export interface GitHubRelease {
|
|
5
|
+
tagName: string
|
|
6
|
+
publishedAt: string
|
|
7
|
+
htmlUrl: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface GitHubFetchOpts {
|
|
11
|
+
/** Override fetch (mainly for tests). Defaults to global `fetch`. */
|
|
12
|
+
fetchImpl?: typeof fetch
|
|
13
|
+
/** Per-call timeout. Defaults to 10s. */
|
|
14
|
+
timeoutMs?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse `https://github.com/owner/repo.git`, `https://github.com/owner/repo`,
|
|
19
|
+
* or `git@github.com:owner/repo.git` into `{owner, repo}`. Throws on shapes
|
|
20
|
+
* the regex doesn't recognize.
|
|
21
|
+
*/
|
|
22
|
+
export function parseGitHubRepoUrl(url: string): { owner: string; repo: string } {
|
|
23
|
+
const match = url.match(/github\.com[:/]([^/]+)\/([^/.]+)/)
|
|
24
|
+
if (!match || !match[1] || !match[2]) throw new Error(`cannot parse GitHub repo URL: ${url}`)
|
|
25
|
+
return { owner: match[1], repo: match[2] }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the most recent published GitHub release for a repo. Skips drafts
|
|
30
|
+
* and pre-releases (that's what GitHub's `/releases/latest` endpoint returns
|
|
31
|
+
* by default). Throws on 404 (no published release) or non-200.
|
|
32
|
+
*/
|
|
33
|
+
export async function resolveLatestRelease(
|
|
34
|
+
repoUrl: string,
|
|
35
|
+
opts: GitHubFetchOpts = {},
|
|
36
|
+
): Promise<GitHubRelease> {
|
|
37
|
+
const { owner, repo } = parseGitHubRepoUrl(repoUrl)
|
|
38
|
+
const fetchImpl = opts.fetchImpl ?? fetch
|
|
39
|
+
const timeoutMs = opts.timeoutMs ?? 10_000
|
|
40
|
+
const r = await fetchImpl(`https://api.github.com/repos/${owner}/${repo}/releases/latest`, {
|
|
41
|
+
headers: { Accept: 'application/vnd.github+json' },
|
|
42
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
43
|
+
})
|
|
44
|
+
if (r.status === 404) {
|
|
45
|
+
throw new Error(`no published releases found for ${owner}/${repo}`)
|
|
46
|
+
}
|
|
47
|
+
if (!r.ok) {
|
|
48
|
+
throw new Error(`GitHub API ${r.status} for ${owner}/${repo}/releases/latest`)
|
|
49
|
+
}
|
|
50
|
+
const data = (await r.json()) as { tag_name: string; published_at: string; html_url: string }
|
|
51
|
+
return { tagName: data.tag_name, publishedAt: data.published_at, htmlUrl: data.html_url }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Probe whether a tag exists on the remote. Returns `false` on 404 (the
|
|
56
|
+
* conventional "tag not found" signal), `true` on 200, throws on other
|
|
57
|
+
* errors so callers can surface "API down" vs "tag missing" distinctly.
|
|
58
|
+
*/
|
|
59
|
+
export async function checkTagExists(
|
|
60
|
+
repoUrl: string,
|
|
61
|
+
tag: string,
|
|
62
|
+
opts: GitHubFetchOpts = {},
|
|
63
|
+
): Promise<boolean> {
|
|
64
|
+
const { owner, repo } = parseGitHubRepoUrl(repoUrl)
|
|
65
|
+
const fetchImpl = opts.fetchImpl ?? fetch
|
|
66
|
+
const timeoutMs = opts.timeoutMs ?? 10_000
|
|
67
|
+
const r = await fetchImpl(
|
|
68
|
+
`https://api.github.com/repos/${owner}/${repo}/git/refs/tags/${encodeURIComponent(tag)}`,
|
|
69
|
+
{
|
|
70
|
+
headers: { Accept: 'application/vnd.github+json' },
|
|
71
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
if (r.status === 404) return false
|
|
75
|
+
if (!r.ok) {
|
|
76
|
+
throw new Error(`GitHub API ${r.status} for ${owner}/${repo}/git/refs/tags/${tag}`)
|
|
77
|
+
}
|
|
78
|
+
return true
|
|
79
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import {
|
|
6
|
+
OPERATOR_BLOB_SCOPES,
|
|
7
|
+
agentPaths,
|
|
8
|
+
buildOperatorSession,
|
|
9
|
+
writeOperatorSession,
|
|
10
|
+
} from '@promus/core'
|
|
11
|
+
import { loadProfileScopeKeyHex } from './profile-key'
|
|
12
|
+
|
|
13
|
+
const FAKE_AGENT = '0xaabbccddeeff00112233445566778899aabbccdd'.toLowerCase() as `0x${string}`
|
|
14
|
+
const FAKE_AGENT_ID = 'fake'.repeat(4)
|
|
15
|
+
const FAKE_AGENT_ID_NO_PROFILE = 'eeeeeeeeeeeeeeee'
|
|
16
|
+
const PROFILE_KEY_HEX = `0x${'a'.repeat(64)}` as `0x${string}`
|
|
17
|
+
const KEYSTORE_KEY_HEX = `0x${'b'.repeat(64)}` as `0x${string}`
|
|
18
|
+
|
|
19
|
+
describe('loadProfileScopeKeyHex', () => {
|
|
20
|
+
const original = process.env.HOME
|
|
21
|
+
let tmpHome: string
|
|
22
|
+
|
|
23
|
+
beforeAll(() => {
|
|
24
|
+
tmpHome = mkdtempSync(join(tmpdir(), 'promus-profile-key-'))
|
|
25
|
+
process.env.HOME = tmpHome
|
|
26
|
+
mkdirSync(agentPaths.agent(FAKE_AGENT_ID).dir, { recursive: true })
|
|
27
|
+
mkdirSync(agentPaths.agent(FAKE_AGENT_ID_NO_PROFILE).dir, { recursive: true })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
afterAll(() => {
|
|
31
|
+
process.env.HOME = original
|
|
32
|
+
rmSync(tmpHome, { recursive: true, force: true })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('returns undefined when no session exists', () => {
|
|
36
|
+
expect(loadProfileScopeKeyHex('ffffffffffffffff')).toBeUndefined()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('returns the hex-encoded key when session contains PROFILE scope', () => {
|
|
40
|
+
const sess = buildOperatorSession({
|
|
41
|
+
agent: FAKE_AGENT,
|
|
42
|
+
keys: {
|
|
43
|
+
keystore: KEYSTORE_KEY_HEX,
|
|
44
|
+
[OPERATOR_BLOB_SCOPES.PROFILE]: PROFILE_KEY_HEX,
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
writeOperatorSession(FAKE_AGENT_ID, sess)
|
|
48
|
+
const out = loadProfileScopeKeyHex(FAKE_AGENT_ID)
|
|
49
|
+
expect(out).toBe(PROFILE_KEY_HEX)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('returns undefined when PROFILE scope is missing from session', () => {
|
|
53
|
+
const sess = buildOperatorSession({
|
|
54
|
+
agent: FAKE_AGENT,
|
|
55
|
+
keys: { keystore: KEYSTORE_KEY_HEX },
|
|
56
|
+
})
|
|
57
|
+
writeOperatorSession(FAKE_AGENT_ID_NO_PROFILE, sess)
|
|
58
|
+
expect(loadProfileScopeKeyHex(FAKE_AGENT_ID_NO_PROFILE)).toBeUndefined()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local accessor for the cached PROFILE scope key.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `getSessionKey(agentId, OPERATOR_BLOB_SCOPES.PROFILE)` with the
|
|
5
|
+
* hex-encoding the gateway handoff envelopes expect. Used by `promus upgrade`
|
|
6
|
+
* (both `--reprovision` + in-place) to ship the cached key to the new sandbox
|
|
7
|
+
* daemon so it boots with `slots.profile` ready to anchor instead of
|
|
8
|
+
* `{ status: 'skipped', reason: 'no-profile-key' }`.
|
|
9
|
+
*
|
|
10
|
+
* Returns undefined when the operator session is absent / expired / missing
|
|
11
|
+
* the PROFILE scope (pre-v0.23.1 agents). Callers should surface a one-line
|
|
12
|
+
* note in that case so the operator knows to refresh the session before the
|
|
13
|
+
* next upgrade.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { OPERATOR_BLOB_SCOPES, getSessionKey } from '@promus/core'
|
|
17
|
+
|
|
18
|
+
export function loadProfileScopeKeyHex(agentId: string): `0x${string}` | undefined {
|
|
19
|
+
try {
|
|
20
|
+
const buf = getSessionKey(agentId, OPERATOR_BLOB_SCOPES.PROFILE)
|
|
21
|
+
return buf ? (`0x${buf.toString('hex')}` as `0x${string}`) : undefined
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
}
|