spexcode 0.4.1 → 0.5.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 +37 -17
- package/package.json +9 -5
- package/spec-cli/README.md +23 -14
- package/spec-cli/hooks/dispatch.sh +7 -5
- package/spec-cli/hooks/harness.sh +19 -8
- package/spec-cli/src/anchors.ts +110 -18
- package/spec-cli/src/claude-headless.ts +271 -0
- package/spec-cli/src/cli.ts +112 -38
- package/spec-cli/src/client.ts +42 -11
- package/spec-cli/src/doctor.ts +174 -36
- package/spec-cli/src/gateway-auth.ts +194 -0
- package/spec-cli/src/gateway-hub.ts +360 -0
- package/spec-cli/src/gateway.ts +27 -19
- package/spec-cli/src/git.ts +12 -5
- package/spec-cli/src/graph.ts +54 -43
- package/spec-cli/src/graphStream.ts +265 -34
- package/spec-cli/src/guide.ts +115 -56
- package/spec-cli/src/harness-select.ts +20 -6
- package/spec-cli/src/harness.ts +295 -39
- package/spec-cli/src/help.ts +68 -28
- package/spec-cli/src/host.ts +610 -0
- package/spec-cli/src/identity-presets.d.ts +13 -0
- package/spec-cli/src/identity-presets.js +128 -0
- package/spec-cli/src/index.ts +158 -87
- package/spec-cli/src/init.ts +53 -25
- package/spec-cli/src/layout.ts +6 -5
- package/spec-cli/src/lint.ts +25 -121
- package/spec-cli/src/login-page.ts +12 -5
- package/spec-cli/src/materialize.ts +39 -13
- package/spec-cli/src/mentions.ts +16 -11
- package/spec-cli/src/message-stream.ts +147 -0
- package/spec-cli/src/opencode-headless.ts +95 -0
- package/spec-cli/src/opencode.ts +145 -0
- package/spec-cli/src/pi-harness.ts +124 -0
- package/spec-cli/src/pi-headless.ts +195 -0
- package/spec-cli/src/project-identity.ts +73 -0
- package/spec-cli/src/pty-bridge.ts +321 -521
- package/spec-cli/src/pty-helper.mjs +86 -0
- package/spec-cli/src/reaper.ts +16 -8
- package/spec-cli/src/reviewSnapshot.ts +23 -0
- package/spec-cli/src/reviews.ts +347 -0
- package/spec-cli/src/session-timeline.ts +14 -0
- package/spec-cli/src/sessions.ts +211 -75
- package/spec-cli/src/shim-runtime.ts +169 -0
- package/spec-cli/src/slash-commands.ts +76 -0
- package/spec-cli/src/source-files.ts +79 -0
- package/spec-cli/src/specs.ts +12 -7
- package/spec-cli/src/supervise.ts +37 -15
- package/spec-cli/src/tree.ts +8 -8
- package/spec-cli/src/uninstall.ts +56 -23
- package/spec-cli/templates/hooks/prepare-commit-msg +32 -18
- package/spec-cli/templates/spec/project/.plugins/commands/extract/spec.md +24 -18
- package/spec-cli/templates/spec/project/.plugins/commands/regroup/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
- package/spec-cli/templates/spec/project/.plugins/commands/spec.md +22 -8
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +29 -11
- package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
- package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +38 -50
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +9 -7
- package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
- package/spec-cli/templates/spec/project/spec.md +6 -5
- package/spec-cli/templates/spexcode.json +10 -2
- package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +2 -0
- package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +1 -0
- package/spec-dashboard/dist/assets/PageScroll-B_dKCuXx.js +1 -0
- package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +13 -0
- package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +1 -0
- package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +1 -0
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
- package/spec-dashboard/dist/assets/index-BKaTHjmU.js +41 -0
- package/spec-dashboard/dist/assets/index-DcnCaBAC.css +1 -0
- package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
- package/spec-dashboard/dist/index.html +10 -10
- package/spec-dashboard/src/reviewFilters.js +279 -0
- package/spec-dashboard/src/reviewQuery.js +174 -0
- package/spec-dashboard/src/session.js +167 -0
- package/spec-eval/src/cli.ts +49 -28
- package/spec-eval/src/humanok.ts +1 -1
- package/spec-eval/src/matrix.ts +693 -0
- package/spec-eval/src/sessioneval.ts +726 -66
- package/spec-cli/src/migrate-table.ts +0 -408
- package/spec-cli/src/migrate.ts +0 -386
- package/spec-dashboard/dist/assets/Dashboard-C_fGmOKK.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +0 -2
- package/spec-dashboard/dist/assets/FoldToggle-x9gtO1OQ.js +0 -1
- package/spec-dashboard/dist/assets/IssuesPage-5f_vL-JV.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +0 -66
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
- package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +0 -13
- package/spec-dashboard/dist/assets/Settings-DKb5Ji_X.js +0 -1
- package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +0 -41
- package/spec-dashboard/dist/assets/index-BbMkwuix.css +0 -1
- package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +0 -6
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { createConnection, createServer, type Server, type Socket } from 'node:net'
|
|
2
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
3
|
+
import { mkdirSync, rmSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import type { DispatchResult, HarnessDeliveryRecord } from './harness.js'
|
|
8
|
+
|
|
9
|
+
type ControlRequest = { type: 'deliver'; text: string }
|
|
10
|
+
type ChildTurn = { process: ChildProcess; exited: Promise<number | null> }
|
|
11
|
+
|
|
12
|
+
const PKG = fileURLToPath(new URL('..', import.meta.url))
|
|
13
|
+
const SPEX = join(PKG, 'bin', 'spex.mjs')
|
|
14
|
+
const CONTROL_TIMEOUT_MS = 30_000
|
|
15
|
+
const START_TIMEOUT_MS = 30_000
|
|
16
|
+
|
|
17
|
+
const shQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
|
|
18
|
+
|
|
19
|
+
/** The resident controller socket is distinct from pi's per-turn rendezvous socket. */
|
|
20
|
+
export const piHeadlessSock = (id: string) => join(tmpdir(), `spexcode-ph-${id}.sock`)
|
|
21
|
+
|
|
22
|
+
export function piHeadlessLaunchCommand(id: string, runtimeDir: string, piCmd: string): string {
|
|
23
|
+
return [shQuote(SPEX), 'internal', 'pi-headless-run', shQuote(id), shQuote(runtimeDir), shQuote(piCmd), '--'].join(' ')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const timer = setTimeout(() => reject(new Error(message)), ms)
|
|
29
|
+
promise.then(
|
|
30
|
+
(value) => { clearTimeout(timer); resolve(value) },
|
|
31
|
+
(error) => { clearTimeout(timer); reject(error) },
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function controlRequest(id: string, request: ControlRequest): Promise<DispatchResult> {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
let socket: Socket | undefined
|
|
39
|
+
let settled = false
|
|
40
|
+
let buffer = ''
|
|
41
|
+
const finish = (result: DispatchResult) => {
|
|
42
|
+
if (settled) return
|
|
43
|
+
settled = true
|
|
44
|
+
clearTimeout(timer)
|
|
45
|
+
socket?.destroy()
|
|
46
|
+
resolve(result)
|
|
47
|
+
}
|
|
48
|
+
const timer = setTimeout(() => finish({ ok: false, error: `pi-headless control timed out for session ${id}` }), CONTROL_TIMEOUT_MS)
|
|
49
|
+
try { socket = createConnection(piHeadlessSock(id)) } catch (error) {
|
|
50
|
+
finish({ ok: false, error: `pi-headless controller connect failed for session ${id}: ${(error as Error).message}` })
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
socket.setEncoding('utf8')
|
|
54
|
+
socket.on('connect', () => socket!.write(`${JSON.stringify(request)}\n`))
|
|
55
|
+
socket.on('data', (chunk) => {
|
|
56
|
+
buffer += chunk
|
|
57
|
+
const nl = buffer.indexOf('\n')
|
|
58
|
+
if (nl < 0) return
|
|
59
|
+
try {
|
|
60
|
+
const response = JSON.parse(buffer.slice(0, nl)) as DispatchResult
|
|
61
|
+
finish(response.ok ? { ok: true } : { ok: false, error: response.error || 'pi-headless controller rejected the request' })
|
|
62
|
+
} catch (error) {
|
|
63
|
+
finish({ ok: false, error: `pi-headless returned an invalid control response: ${(error as Error).message}` })
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
socket.on('error', (error) => finish({ ok: false, error: `pi-headless controller unreachable for session ${id}: ${error.message}` }))
|
|
67
|
+
socket.on('close', () => finish({ ok: false, error: `pi-headless controller closed before confirming session ${id}` }))
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const deliverViaPiHeadless = (rec: HarnessDeliveryRecord, text: string) =>
|
|
72
|
+
controlRequest(rec.session, { type: 'deliver', text })
|
|
73
|
+
|
|
74
|
+
export class PiHeadlessController {
|
|
75
|
+
private server: Server | null = null
|
|
76
|
+
private child: ChildTurn | null = null
|
|
77
|
+
private controlQueue: Promise<void> = Promise.resolve()
|
|
78
|
+
private closing = false
|
|
79
|
+
private readonly socketPath: string
|
|
80
|
+
|
|
81
|
+
constructor(
|
|
82
|
+
private readonly id: string,
|
|
83
|
+
_runtimeDir: string,
|
|
84
|
+
private readonly piCmd: string,
|
|
85
|
+
private readonly cwd = process.cwd(),
|
|
86
|
+
) {
|
|
87
|
+
this.socketPath = piHeadlessSock(id)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async start(initialPrompt?: string): Promise<void> {
|
|
91
|
+
try { rmSync(this.socketPath, { force: true }) } catch { /* stale control socket is replaced at startup */ }
|
|
92
|
+
this.server = createServer((socket) => this.accept(socket))
|
|
93
|
+
await new Promise<void>((resolve, reject) => {
|
|
94
|
+
const onError = (error: Error) => { this.server?.off('listening', onListening); reject(error) }
|
|
95
|
+
const onListening = () => { this.server?.off('error', onError); resolve() }
|
|
96
|
+
this.server!.once('error', onError)
|
|
97
|
+
this.server!.once('listening', onListening)
|
|
98
|
+
this.server!.listen(this.socketPath)
|
|
99
|
+
})
|
|
100
|
+
if (initialPrompt) void this.spawnTurn(initialPrompt, false).catch((error) => {
|
|
101
|
+
console.error(`[spex pi-headless] initial turn failed: ${(error as Error).message}`)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async close(): Promise<void> {
|
|
106
|
+
if (this.closing) return
|
|
107
|
+
this.closing = true
|
|
108
|
+
const child = this.child
|
|
109
|
+
if (child && child.process.exitCode === null) child.process.kill('SIGTERM')
|
|
110
|
+
await new Promise<void>((resolve) => {
|
|
111
|
+
if (!this.server) return resolve()
|
|
112
|
+
this.server.close(() => resolve())
|
|
113
|
+
})
|
|
114
|
+
try { rmSync(this.socketPath, { force: true }) } catch { /* best-effort cleanup after close */ }
|
|
115
|
+
const { rvSock } = await import('./harness.js')
|
|
116
|
+
try { rmSync(rvSock(this.id), { force: true }) } catch { /* pi may already have removed it */ }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private accept(socket: Socket): void {
|
|
120
|
+
socket.setEncoding('utf8')
|
|
121
|
+
let buffer = ''
|
|
122
|
+
let handled = false
|
|
123
|
+
socket.on('data', (chunk) => {
|
|
124
|
+
if (handled) return
|
|
125
|
+
buffer += chunk
|
|
126
|
+
const nl = buffer.indexOf('\n')
|
|
127
|
+
if (nl < 0) return
|
|
128
|
+
handled = true
|
|
129
|
+
let request: ControlRequest
|
|
130
|
+
try { request = JSON.parse(buffer.slice(0, nl)) as ControlRequest } catch (error) {
|
|
131
|
+
socket.end(`${JSON.stringify({ ok: false, error: `invalid control request: ${(error as Error).message}` })}\n`)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
this.controlQueue = this.controlQueue.then(async () => {
|
|
135
|
+
const result = await this.handle(request).catch((error) => ({ ok: false, error: (error as Error).message }))
|
|
136
|
+
socket.end(`${JSON.stringify(result)}\n`)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async handle(request: ControlRequest): Promise<DispatchResult> {
|
|
142
|
+
if (request.type !== 'deliver') return { ok: false, error: 'unknown pi-headless control request' }
|
|
143
|
+
if (!request.text) return { ok: false, error: 'empty prompt - nothing to deliver' }
|
|
144
|
+
|
|
145
|
+
// A live extension listener is an in-flight pi turn. The native steer path is parse-confirmed by the
|
|
146
|
+
// shared rendezvous protocol. Only a proven absent listener may cold-wake a saved session.
|
|
147
|
+
const { deliverViaRendezvous, rendezvousListening } = await import('./harness.js')
|
|
148
|
+
const listener = await rendezvousListening(this.id)
|
|
149
|
+
if (listener === 'live') return deliverViaRendezvous(this.id, request.text)
|
|
150
|
+
if (listener === 'unproven') return { ok: false, error: `could not determine whether pi turn ${this.id} is live — prompt NOT delivered` }
|
|
151
|
+
|
|
152
|
+
if (this.child) await withTimeout(this.child.exited, 5_000, `previous pi-headless turn did not exit for session ${this.id}`)
|
|
153
|
+
await this.spawnTurn(request.text, true)
|
|
154
|
+
return { ok: true }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async spawnTurn(text: string, resume: boolean): Promise<void> {
|
|
158
|
+
if (this.closing) throw new Error('pi-headless controller is closing')
|
|
159
|
+
const mode = resume ? ['--session', this.id] : ['--session-id', this.id]
|
|
160
|
+
// Keep pi's default text mode. `--mode json` is intentionally omitted: it can hang in this runtime.
|
|
161
|
+
const args = ['-p', ...mode, text]
|
|
162
|
+
const command = `exec ${this.piCmd} ${args.map(shQuote).join(' ')}`
|
|
163
|
+
const childProcess = spawn('/bin/sh', ['-lc', command], { cwd: this.cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
164
|
+
let resolveExit!: (code: number | null) => void
|
|
165
|
+
const exited = new Promise<number | null>((resolve) => { resolveExit = resolve })
|
|
166
|
+
const turn: ChildTurn = { process: childProcess, exited }
|
|
167
|
+
this.child = turn
|
|
168
|
+
childProcess.stdout?.pipe(process.stdout)
|
|
169
|
+
childProcess.stderr?.pipe(process.stderr)
|
|
170
|
+
childProcess.once('error', (error) => console.error(`[spex pi-headless] child spawn failed: ${error.message}`))
|
|
171
|
+
childProcess.once('close', (code) => {
|
|
172
|
+
if (this.child === turn) this.child = null
|
|
173
|
+
resolveExit(code)
|
|
174
|
+
})
|
|
175
|
+
await withTimeout(new Promise<void>((resolve, reject) => {
|
|
176
|
+
childProcess.once('spawn', () => resolve())
|
|
177
|
+
childProcess.once('error', reject)
|
|
178
|
+
}), START_TIMEOUT_MS, `pi-headless child did not start for session ${this.id}`)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function runPiHeadlessController(id: string, runtimeDir: string, piCmd: string, tail: string[]): Promise<void> {
|
|
183
|
+
// runtimeDir is retained in the command shape for parity with claude-headless and future per-session output.
|
|
184
|
+
mkdirSync(join(runtimeDir, 'sessions', id), { recursive: true })
|
|
185
|
+
const controller = new PiHeadlessController(id, runtimeDir, piCmd)
|
|
186
|
+
const resume = tail[0] === '--session'
|
|
187
|
+
const prompt = resume ? undefined : tail[0] === '--session-id' ? tail.slice(2).join(' ') : tail.join(' ')
|
|
188
|
+
await controller.start(prompt)
|
|
189
|
+
await new Promise<void>((resolve) => {
|
|
190
|
+
const stop = () => void controller.close().finally(resolve)
|
|
191
|
+
process.once('SIGINT', stop)
|
|
192
|
+
process.once('SIGTERM', stop)
|
|
193
|
+
process.once('SIGHUP', stop)
|
|
194
|
+
})
|
|
195
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { basename, dirname, join } from 'node:path'
|
|
4
|
+
import { spexcodeHome } from './layout.js'
|
|
5
|
+
import {
|
|
6
|
+
resolvedIdentityIcon, DEFAULT_GATEWAY_ICON, DEFAULT_PROJECT_ICON, requireIdentityChoice,
|
|
7
|
+
} from './identity-presets.js'
|
|
8
|
+
|
|
9
|
+
export type ResolvedIdentity = { title: string; icon: string }
|
|
10
|
+
export type GatewayIdentitySource = { identity: ResolvedIdentity; revision: string }
|
|
11
|
+
|
|
12
|
+
const revisionOf = (raw: string | null): string =>
|
|
13
|
+
createHash('sha256').update(raw === null ? 'missing' : `present\0${raw}`).digest('hex')
|
|
14
|
+
|
|
15
|
+
function readObject(file: string): { value: Record<string, any>; raw: string | null } {
|
|
16
|
+
if (!existsSync(file)) return { value: {}, raw: null }
|
|
17
|
+
const raw = readFileSync(file, 'utf8')
|
|
18
|
+
let value: unknown
|
|
19
|
+
try { value = JSON.parse(raw) }
|
|
20
|
+
catch (e) { throw new Error(`malformed ${file}: ${(e as Error).message}`) }
|
|
21
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${file} must contain one top-level JSON object`)
|
|
22
|
+
return { value: value as Record<string, any>, raw }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveProjectIdentity(configRoot: string, canonicalRoot = configRoot): ResolvedIdentity {
|
|
26
|
+
const { value } = readObject(join(configRoot, 'spexcode.json'))
|
|
27
|
+
const dashboard = value.dashboard && typeof value.dashboard === 'object' && !Array.isArray(value.dashboard)
|
|
28
|
+
? value.dashboard as Record<string, unknown>
|
|
29
|
+
: {}
|
|
30
|
+
const configuredTitle = typeof dashboard.title === 'string' ? dashboard.title.trim() : ''
|
|
31
|
+
return {
|
|
32
|
+
title: configuredTitle || basename(canonicalRoot),
|
|
33
|
+
icon: resolvedIdentityIcon(dashboard.icon, DEFAULT_PROJECT_ICON),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const hostConfigPath = (): string => join(spexcodeHome(), 'config.json')
|
|
38
|
+
|
|
39
|
+
export function readGatewayIdentity(): GatewayIdentitySource {
|
|
40
|
+
const { value, raw } = readObject(hostConfigPath())
|
|
41
|
+
const gateway = value.gateway && typeof value.gateway === 'object' && !Array.isArray(value.gateway)
|
|
42
|
+
? value.gateway as Record<string, unknown>
|
|
43
|
+
: {}
|
|
44
|
+
return {
|
|
45
|
+
identity: { title: 'Projects', icon: resolvedIdentityIcon(gateway.icon, DEFAULT_GATEWAY_ICON) },
|
|
46
|
+
revision: revisionOf(raw),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function writeGatewayIcon(icon: unknown, revision: string): GatewayIdentitySource {
|
|
51
|
+
const canonical = requireIdentityChoice(icon)
|
|
52
|
+
const file = hostConfigPath()
|
|
53
|
+
const current = readObject(file)
|
|
54
|
+
if (revision !== revisionOf(current.raw)) {
|
|
55
|
+
const error = new Error('host config changed on disk — reload before saving') as Error & { status?: number }
|
|
56
|
+
error.status = 409
|
|
57
|
+
throw error
|
|
58
|
+
}
|
|
59
|
+
const gateway = current.value.gateway && typeof current.value.gateway === 'object' && !Array.isArray(current.value.gateway)
|
|
60
|
+
? current.value.gateway as Record<string, unknown>
|
|
61
|
+
: {}
|
|
62
|
+
const next = { ...current.value, gateway: { ...gateway, icon: canonical } }
|
|
63
|
+
const raw = `${JSON.stringify(next, null, 2)}\n`
|
|
64
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
|
|
65
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
66
|
+
try {
|
|
67
|
+
writeFileSync(tmp, raw, { mode: 0o600 })
|
|
68
|
+
renameSync(tmp, file)
|
|
69
|
+
} finally {
|
|
70
|
+
try { rmSync(tmp) } catch { /* rename consumed it / write never created it */ }
|
|
71
|
+
}
|
|
72
|
+
return { identity: { title: 'Projects', icon: canonical }, revision: revisionOf(raw) }
|
|
73
|
+
}
|