boxdown 1.0.0 → 1.2.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 +135 -12
- package/assets/devcontainer/README.md +49 -17
- package/assets/devcontainer/devcontainer.json +24 -7
- package/assets/devcontainer/hooks/initialize.sh +32 -0
- package/assets/devcontainer/hooks/post-create.sh +59 -12
- package/assets/devcontainer/hooks/post-start.sh +21 -4
- package/assets/devcontainer/ssh-config-install.sh +12 -3
- package/assets/devcontainer/start.sh +721 -44
- package/assets/devcontainer/utils/coding-agent-cli-update.sh +267 -3
- package/assets/devcontainer/utils/deps-install.sh +68 -0
- package/assets/devcontainer/utils/git-config-bootstrap.sh +128 -0
- package/assets/devcontainer/utils/git-signing-bootstrap.sh +56 -0
- package/assets/devcontainer/utils/python-bootstrap.sh +69 -0
- package/assets/devcontainer/utils/ssh-bootstrap.sh +9 -0
- package/dist/bin/cli.cjs +1 -1
- package/dist/bin/cli.mjs +1 -1
- package/dist/main-Df4E8ARj.cjs +5498 -0
- package/dist/main-XMBsKjIK.mjs +5458 -0
- package/dist/main-XMBsKjIK.mjs.map +1 -0
- package/dist/main.cjs +5 -2
- package/dist/main.d.cts +495 -4
- package/dist/main.d.cts.map +1 -1
- package/dist/main.d.mts +495 -4
- package/dist/main.d.mts.map +1 -1
- package/dist/main.mjs +2 -2
- package/docs/README.md +1 -0
- package/docs/architecture.md +32 -0
- package/docs/development.md +13 -6
- package/docs/features/README.md +2 -0
- package/docs/features/commit-signing.md +23 -0
- package/docs/features/generated-config-and-state.md +57 -4
- package/docs/features/github-auth-refresh.md +12 -0
- package/docs/features/lifecycle.md +97 -11
- package/docs/features/setup.md +66 -0
- package/docs/features/ssh-config-and-proxy.md +228 -7
- package/docs/features/start-and-shell.md +40 -5
- package/docs/superpowers/plans/2026-07-11-default-commit-signing.md +110 -0
- package/docs/superpowers/plans/2026-07-11-progress-ci-regression.md +125 -0
- package/docs/superpowers/specs/2026-07-11-default-commit-signing-design.md +396 -0
- package/docs/superpowers/specs/2026-07-11-progress-ci-regression-design.md +43 -0
- package/docs/testing.md +35 -2
- package/docs/todo.md +2 -2
- package/package.json +1 -1
- package/src/claude-app-config.ts +304 -0
- package/src/cli-style.ts +43 -0
- package/src/codex-app-config.ts +656 -0
- package/src/config.ts +68 -10
- package/src/constants.ts +5 -0
- package/src/devcontainer.ts +500 -64
- package/src/doctor.ts +195 -30
- package/src/git-signing.ts +87 -0
- package/src/interactive-prompts.ts +692 -0
- package/src/list.ts +16 -2
- package/src/logging.ts +154 -0
- package/src/main.ts +1213 -63
- package/src/metadata.ts +52 -3
- package/src/package-info.ts +18 -0
- package/src/paths.ts +50 -10
- package/src/process.ts +80 -2
- package/src/progress.ts +593 -0
- package/src/purge.ts +208 -0
- package/src/shell.ts +19 -0
- package/src/ssh-config.ts +134 -16
- package/src/ssh-install-targets.ts +111 -0
- package/src/ssh-key.ts +53 -13
- package/src/status.ts +5 -0
- package/dist/main-BuEptwlL.cjs +0 -1707
- package/dist/main-ZFTrSVgt.mjs +0 -1685
- package/dist/main-ZFTrSVgt.mjs.map +0 -1
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import type { WorkspaceContext } from './paths.ts'
|
|
6
|
+
|
|
7
|
+
export const CODEX_APP_CONFIG_VERSION = 1
|
|
8
|
+
export const CODEX_APP_CONFIG_DIRNAME = 'codex-app'
|
|
9
|
+
export const CODEX_APP_CONFIG_FILENAME = 'config.json'
|
|
10
|
+
export const CODEX_GLOBAL_STATE_FILENAME = '.codex-global-state.json'
|
|
11
|
+
|
|
12
|
+
export interface CodexAppProjectConfig {
|
|
13
|
+
remotePath: string
|
|
14
|
+
label?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CodexAppRemoteConnectionConfig {
|
|
18
|
+
sshAlias: string
|
|
19
|
+
projects: CodexAppProjectConfig[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CodexAppConfig {
|
|
23
|
+
version: 1
|
|
24
|
+
remoteConnectionMaxRetryAttempts?: number
|
|
25
|
+
sshConnectTimeoutSeconds?: number
|
|
26
|
+
remoteConnections: CodexAppRemoteConnectionConfig[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CodexAppProjectEntry {
|
|
30
|
+
sshAlias: string
|
|
31
|
+
remotePath: string
|
|
32
|
+
label: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface InstallCodexAppConfigResult {
|
|
36
|
+
configPath: string
|
|
37
|
+
backupPath?: string
|
|
38
|
+
changed: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface UninstallCodexAppConfigResult {
|
|
42
|
+
configPath: string
|
|
43
|
+
backupPath?: string
|
|
44
|
+
changed: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface UninstallCodexGlobalStateResult {
|
|
48
|
+
statePath: string
|
|
49
|
+
backupPath?: string
|
|
50
|
+
changed: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface InstallCodexGlobalStateResult {
|
|
54
|
+
statePath: string
|
|
55
|
+
backupPath?: string
|
|
56
|
+
changed: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isRecord (value: unknown): value is Record<string, unknown> {
|
|
60
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseOptionalNonnegativeInteger (value: unknown, key: string): number | undefined {
|
|
64
|
+
if (value === undefined) {
|
|
65
|
+
return undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
69
|
+
throw new Error(`Invalid Codex app config: ${key} must be a nonnegative integer`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return value
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseOptionalLabel (value: unknown): string | undefined {
|
|
76
|
+
if (value === undefined) {
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (typeof value !== 'string') {
|
|
81
|
+
throw new Error('Invalid Codex app config: project label must be a string')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return value
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseProjectConfig (value: unknown): CodexAppProjectConfig {
|
|
88
|
+
if (!isRecord(value)) {
|
|
89
|
+
throw new Error('Invalid Codex app config: project entries must be objects')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const remotePath = value.remotePath
|
|
93
|
+
if (typeof remotePath !== 'string' || remotePath.trim().length === 0) {
|
|
94
|
+
throw new Error('Invalid Codex app config: project remotePath must be a nonempty string')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const label = parseOptionalLabel(value.label)
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
remotePath: normalizeRemotePath(remotePath),
|
|
101
|
+
...(label === undefined ? {} : { label })
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseRemoteConnectionConfig (value: unknown): CodexAppRemoteConnectionConfig {
|
|
106
|
+
if (!isRecord(value)) {
|
|
107
|
+
throw new Error('Invalid Codex app config: remoteConnections entries must be objects')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const sshAlias = value.sshAlias
|
|
111
|
+
if (typeof sshAlias !== 'string' || sshAlias.trim().length === 0) {
|
|
112
|
+
throw new Error('Invalid Codex app config: sshAlias must be a nonempty string')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const projects = value.projects
|
|
116
|
+
if (projects !== undefined && !Array.isArray(projects)) {
|
|
117
|
+
throw new Error('Invalid Codex app config: projects must be an array')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
sshAlias: sshAlias.trim(),
|
|
122
|
+
projects: (projects ?? []).map((project) => parseProjectConfig(project))
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function parseCodexAppConfig (value: unknown): CodexAppConfig {
|
|
127
|
+
if (!isRecord(value)) {
|
|
128
|
+
throw new Error('Invalid Codex app config: top-level value must be an object')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const version = value.version
|
|
132
|
+
if (version !== undefined && (!Number.isInteger(version) || version !== CODEX_APP_CONFIG_VERSION)) {
|
|
133
|
+
throw new Error(`Unsupported Codex app config version: ${String(version)}`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const remoteConnections = value.remoteConnections
|
|
137
|
+
if (remoteConnections !== undefined && !Array.isArray(remoteConnections)) {
|
|
138
|
+
throw new Error('Invalid Codex app config: remoteConnections must be an array')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
version: CODEX_APP_CONFIG_VERSION,
|
|
143
|
+
...(value.remoteConnectionMaxRetryAttempts === undefined
|
|
144
|
+
? {}
|
|
145
|
+
: { remoteConnectionMaxRetryAttempts: parseOptionalNonnegativeInteger(value.remoteConnectionMaxRetryAttempts, 'remoteConnectionMaxRetryAttempts') }),
|
|
146
|
+
...(value.sshConnectTimeoutSeconds === undefined
|
|
147
|
+
? {}
|
|
148
|
+
: { sshConnectTimeoutSeconds: parseOptionalNonnegativeInteger(value.sshConnectTimeoutSeconds, 'sshConnectTimeoutSeconds') }),
|
|
149
|
+
remoteConnections: (remoteConnections ?? []).map((connection) => parseRemoteConnectionConfig(connection))
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function defaultCodexAppConfigPath (env: NodeJS.ProcessEnv = process.env): string {
|
|
154
|
+
return env.BOXDOWN_CODEX_APP_CONFIG ?? join(env.HOME ?? homedir(), '.codex', CODEX_APP_CONFIG_DIRNAME, CODEX_APP_CONFIG_FILENAME)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function defaultCodexGlobalStatePath (env: NodeJS.ProcessEnv = process.env): string {
|
|
158
|
+
return env.BOXDOWN_CODEX_GLOBAL_STATE ?? join(env.HOME ?? homedir(), '.codex', CODEX_GLOBAL_STATE_FILENAME)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function canonicalCodexRemotePathForWorkspace (context: WorkspaceContext): string {
|
|
162
|
+
return `/workspaces/${context.workspaceBasename}`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function legacyCodexRemotePathForWorkspace (context: WorkspaceContext): string {
|
|
166
|
+
return `/home/node/${context.workspaceBasename}`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function codexRemotePathForWorkspace (context: WorkspaceContext): string {
|
|
170
|
+
return canonicalCodexRemotePathForWorkspace(context)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function codexProjectEntryForWorkspace (context: WorkspaceContext, sshAlias: string): CodexAppProjectEntry {
|
|
174
|
+
return {
|
|
175
|
+
sshAlias,
|
|
176
|
+
remotePath: codexRemotePathForWorkspace(context),
|
|
177
|
+
label: context.workspaceBasename
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function normalizeRemotePath (remotePath: string): string {
|
|
182
|
+
const trimmed = remotePath.trim()
|
|
183
|
+
|
|
184
|
+
if (trimmed === '/') {
|
|
185
|
+
return trimmed
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return trimmed.replace(/\/+$/u, '')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function codexDiscoveredRemoteHostId (sshAlias: string): string {
|
|
192
|
+
return `remote-ssh-discovered:${sshAlias}`
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function normalizedRemotePathSet (remotePaths: readonly string[] = []): Set<string> {
|
|
196
|
+
return new Set(remotePaths.map((remotePath) => normalizeRemotePath(remotePath)))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function mergeCodexAppProject (
|
|
200
|
+
config: CodexAppConfig,
|
|
201
|
+
entry: CodexAppProjectEntry,
|
|
202
|
+
options: { legacyRemotePaths?: readonly string[] } = {}
|
|
203
|
+
): CodexAppConfig {
|
|
204
|
+
const remotePath = normalizeRemotePath(entry.remotePath)
|
|
205
|
+
const legacyRemotePaths = normalizedRemotePathSet(options.legacyRemotePaths)
|
|
206
|
+
let matchedConnection = false
|
|
207
|
+
|
|
208
|
+
const remoteConnections = config.remoteConnections.map((connection) => {
|
|
209
|
+
if (connection.sshAlias !== entry.sshAlias) {
|
|
210
|
+
return connection
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
matchedConnection = true
|
|
214
|
+
let matchedProject = false
|
|
215
|
+
const projects: CodexAppProjectConfig[] = []
|
|
216
|
+
|
|
217
|
+
for (const project of connection.projects) {
|
|
218
|
+
const projectRemotePath = normalizeRemotePath(project.remotePath)
|
|
219
|
+
|
|
220
|
+
if (projectRemotePath !== remotePath && !legacyRemotePaths.has(projectRemotePath)) {
|
|
221
|
+
projects.push(project)
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!matchedProject) {
|
|
226
|
+
projects.push({
|
|
227
|
+
remotePath,
|
|
228
|
+
label: entry.label
|
|
229
|
+
})
|
|
230
|
+
matchedProject = true
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!matchedProject) {
|
|
235
|
+
projects.push({
|
|
236
|
+
remotePath,
|
|
237
|
+
label: entry.label
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
sshAlias: connection.sshAlias,
|
|
243
|
+
projects
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
if (!matchedConnection) {
|
|
248
|
+
remoteConnections.push({
|
|
249
|
+
sshAlias: entry.sshAlias,
|
|
250
|
+
projects: [{
|
|
251
|
+
remotePath,
|
|
252
|
+
label: entry.label
|
|
253
|
+
}]
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
...config,
|
|
259
|
+
remoteConnections
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function removeCodexAppProject (
|
|
264
|
+
config: CodexAppConfig,
|
|
265
|
+
entry: CodexAppProjectEntry,
|
|
266
|
+
options: { additionalRemotePaths?: readonly string[] } = {}
|
|
267
|
+
): CodexAppConfig {
|
|
268
|
+
const remotePaths = normalizedRemotePathSet([
|
|
269
|
+
entry.remotePath,
|
|
270
|
+
...(options.additionalRemotePaths ?? [])
|
|
271
|
+
])
|
|
272
|
+
const remoteConnections = config.remoteConnections.flatMap((connection) => {
|
|
273
|
+
if (connection.sshAlias !== entry.sshAlias) {
|
|
274
|
+
return [connection]
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const projects = connection.projects.filter((project) => !remotePaths.has(normalizeRemotePath(project.remotePath)))
|
|
278
|
+
|
|
279
|
+
if (projects.length === 0) {
|
|
280
|
+
return []
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return [{
|
|
284
|
+
sshAlias: connection.sshAlias,
|
|
285
|
+
projects
|
|
286
|
+
}]
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
...config,
|
|
291
|
+
remoteConnections
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function cloneJsonObject (value: Record<string, unknown>): Record<string, unknown> {
|
|
296
|
+
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function removeObjectKey (value: unknown, key: string): void {
|
|
300
|
+
if (isRecord(value)) {
|
|
301
|
+
delete value[key]
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function removeFromStringArray (value: unknown, removedValues: Set<string>): unknown {
|
|
306
|
+
if (!Array.isArray(value)) {
|
|
307
|
+
return value
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return value.filter((item) => typeof item !== 'string' || !removedValues.has(item))
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function cleanupStringArrayField (container: Record<string, unknown>, key: string, removedValues: Set<string>): void {
|
|
314
|
+
if (Array.isArray(container[key])) {
|
|
315
|
+
container[key] = removeFromStringArray(container[key], removedValues)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function cleanupCodexStateContainer (container: Record<string, unknown>, hostId: string, remotePaths: Set<string>): Set<string> {
|
|
320
|
+
const removedProjectIds = new Set<string>()
|
|
321
|
+
const remoteProjects = container['remote-projects']
|
|
322
|
+
|
|
323
|
+
if (Array.isArray(remoteProjects)) {
|
|
324
|
+
container['remote-projects'] = remoteProjects.filter((project) => {
|
|
325
|
+
if (!isRecord(project)) {
|
|
326
|
+
return true
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const matches = project.hostId === hostId &&
|
|
330
|
+
typeof project.remotePath === 'string' &&
|
|
331
|
+
remotePaths.has(normalizeRemotePath(project.remotePath))
|
|
332
|
+
|
|
333
|
+
if (matches && typeof project.id === 'string') {
|
|
334
|
+
removedProjectIds.add(project.id)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return !matches
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const managedConnections = container['codex-managed-remote-connections']
|
|
342
|
+
if (Array.isArray(managedConnections)) {
|
|
343
|
+
container['codex-managed-remote-connections'] = managedConnections.filter((connection) => !isRecord(connection) || connection.hostId !== hostId)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
for (const key of [
|
|
347
|
+
'remote-connection-analytics-id-by-host-id',
|
|
348
|
+
'remote-connection-auto-connect-by-host-id',
|
|
349
|
+
'preferred-non-full-access-agent-mode-by-host-id',
|
|
350
|
+
'agent-mode-by-host-id',
|
|
351
|
+
'unread-thread-ids-by-host-v1'
|
|
352
|
+
]) {
|
|
353
|
+
removeObjectKey(container[key], hostId)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (container['selected-remote-host-id'] === hostId) {
|
|
357
|
+
delete container['selected-remote-host-id']
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return removedProjectIds
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function cleanupProjectReferences (state: Record<string, unknown>, atomState: unknown, removedProjectIds: Set<string>): void {
|
|
364
|
+
if (removedProjectIds.size === 0) {
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
cleanupStringArrayField(state, 'project-order', removedProjectIds)
|
|
369
|
+
|
|
370
|
+
for (const projectId of removedProjectIds) {
|
|
371
|
+
removeObjectKey(state['sidebar-collapsed-groups'], projectId)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (isRecord(atomState)) {
|
|
375
|
+
cleanupStringArrayField(atomState, 'project-order', removedProjectIds)
|
|
376
|
+
for (const projectId of removedProjectIds) {
|
|
377
|
+
removeObjectKey(atomState['sidebar-collapsed-groups'], projectId)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function removeCodexGlobalStateProject (
|
|
383
|
+
state: Record<string, unknown>,
|
|
384
|
+
entry: CodexAppProjectEntry,
|
|
385
|
+
options: { additionalRemotePaths?: readonly string[] } = {}
|
|
386
|
+
): Record<string, unknown> {
|
|
387
|
+
const hostId = codexDiscoveredRemoteHostId(entry.sshAlias)
|
|
388
|
+
const remotePaths = normalizedRemotePathSet([
|
|
389
|
+
entry.remotePath,
|
|
390
|
+
...(options.additionalRemotePaths ?? [])
|
|
391
|
+
])
|
|
392
|
+
const nextState = cloneJsonObject(state)
|
|
393
|
+
const removedProjectIds = cleanupCodexStateContainer(nextState, hostId, remotePaths)
|
|
394
|
+
const atomState = nextState['electron-persisted-atom-state']
|
|
395
|
+
|
|
396
|
+
if (isRecord(atomState)) {
|
|
397
|
+
for (const projectId of cleanupCodexStateContainer(atomState, hostId, remotePaths)) {
|
|
398
|
+
removedProjectIds.add(projectId)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
cleanupProjectReferences(nextState, atomState, removedProjectIds)
|
|
403
|
+
|
|
404
|
+
return nextState
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function normalizeCodexStateContainer (
|
|
408
|
+
container: Record<string, unknown>,
|
|
409
|
+
hostId: string,
|
|
410
|
+
canonicalRemotePath: string,
|
|
411
|
+
legacyRemotePaths: Set<string>
|
|
412
|
+
): Set<string> {
|
|
413
|
+
const removedProjectIds = new Set<string>()
|
|
414
|
+
const remoteProjects = container['remote-projects']
|
|
415
|
+
|
|
416
|
+
if (!Array.isArray(remoteProjects)) {
|
|
417
|
+
return removedProjectIds
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
let matchedProject = false
|
|
421
|
+
container['remote-projects'] = remoteProjects.flatMap((project) => {
|
|
422
|
+
if (!isRecord(project) || project.hostId !== hostId || typeof project.remotePath !== 'string') {
|
|
423
|
+
return [project]
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const remotePath = normalizeRemotePath(project.remotePath)
|
|
427
|
+
if (remotePath !== canonicalRemotePath && !legacyRemotePaths.has(remotePath)) {
|
|
428
|
+
return [project]
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (matchedProject) {
|
|
432
|
+
if (typeof project.id === 'string') {
|
|
433
|
+
removedProjectIds.add(project.id)
|
|
434
|
+
}
|
|
435
|
+
return []
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
matchedProject = true
|
|
439
|
+
return [{
|
|
440
|
+
...project,
|
|
441
|
+
remotePath: canonicalRemotePath
|
|
442
|
+
}]
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
return removedProjectIds
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function normalizeCodexGlobalStateProject (
|
|
449
|
+
state: Record<string, unknown>,
|
|
450
|
+
entry: CodexAppProjectEntry,
|
|
451
|
+
options: { legacyRemotePaths?: readonly string[] } = {}
|
|
452
|
+
): Record<string, unknown> {
|
|
453
|
+
const hostId = codexDiscoveredRemoteHostId(entry.sshAlias)
|
|
454
|
+
const canonicalRemotePath = normalizeRemotePath(entry.remotePath)
|
|
455
|
+
const legacyRemotePaths = normalizedRemotePathSet(options.legacyRemotePaths)
|
|
456
|
+
const nextState = cloneJsonObject(state)
|
|
457
|
+
const removedProjectIds = normalizeCodexStateContainer(nextState, hostId, canonicalRemotePath, legacyRemotePaths)
|
|
458
|
+
const atomState = nextState['electron-persisted-atom-state']
|
|
459
|
+
|
|
460
|
+
if (isRecord(atomState)) {
|
|
461
|
+
for (const projectId of normalizeCodexStateContainer(atomState, hostId, canonicalRemotePath, legacyRemotePaths)) {
|
|
462
|
+
removedProjectIds.add(projectId)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
cleanupProjectReferences(nextState, atomState, removedProjectIds)
|
|
467
|
+
|
|
468
|
+
return nextState
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function readCodexAppConfigFile (configPath: string): CodexAppConfig {
|
|
472
|
+
if (!existsSync(configPath)) {
|
|
473
|
+
return {
|
|
474
|
+
version: CODEX_APP_CONFIG_VERSION,
|
|
475
|
+
remoteConnections: []
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
let parsed: unknown
|
|
480
|
+
|
|
481
|
+
try {
|
|
482
|
+
parsed = JSON.parse(readFileSync(configPath, 'utf8')) as unknown
|
|
483
|
+
} catch (error) {
|
|
484
|
+
if (error instanceof SyntaxError) {
|
|
485
|
+
throw new Error(`Invalid Codex app config JSON: ${configPath}`, { cause: error })
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
throw error
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return parseCodexAppConfig(parsed)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function backupPathFor (configPath: string, now: Date): string {
|
|
495
|
+
return `${configPath}.${now.toISOString().replace(/[:.]/gu, '-')}.bak`
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function writeJsonAtomic (path: string, json: string): void {
|
|
499
|
+
const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}`
|
|
500
|
+
writeFileSync(tmpPath, json)
|
|
501
|
+
renameSync(tmpPath, path)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export function installCodexAppConfigProject (
|
|
505
|
+
entry: CodexAppProjectEntry,
|
|
506
|
+
options: { configPath?: string, now?: Date, legacyRemotePaths?: readonly string[] } = {}
|
|
507
|
+
): InstallCodexAppConfigResult {
|
|
508
|
+
const configPath = options.configPath ?? defaultCodexAppConfigPath()
|
|
509
|
+
const configDir = dirname(configPath)
|
|
510
|
+
const existingConfigExists = existsSync(configPath)
|
|
511
|
+
const existingConfig = readCodexAppConfigFile(configPath)
|
|
512
|
+
const nextConfig = mergeCodexAppProject(existingConfig, entry, { legacyRemotePaths: options.legacyRemotePaths })
|
|
513
|
+
const nextJson = `${JSON.stringify(nextConfig, null, 2)}\n`
|
|
514
|
+
const existingJson = existingConfigExists ? readFileSync(configPath, 'utf8') : undefined
|
|
515
|
+
|
|
516
|
+
if (existingJson === nextJson) {
|
|
517
|
+
return {
|
|
518
|
+
configPath,
|
|
519
|
+
changed: false
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
mkdirSync(configDir, { recursive: true })
|
|
524
|
+
|
|
525
|
+
let backupPath: string | undefined
|
|
526
|
+
if (existingConfigExists) {
|
|
527
|
+
backupPath = backupPathFor(configPath, options.now ?? new Date())
|
|
528
|
+
copyFileSync(configPath, backupPath)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
writeJsonAtomic(configPath, nextJson)
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
configPath,
|
|
535
|
+
...(backupPath === undefined ? {} : { backupPath }),
|
|
536
|
+
changed: true
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export function uninstallCodexAppConfigProject (
|
|
541
|
+
entry: CodexAppProjectEntry,
|
|
542
|
+
options: { configPath?: string, now?: Date, additionalRemotePaths?: readonly string[] } = {}
|
|
543
|
+
): UninstallCodexAppConfigResult {
|
|
544
|
+
const configPath = options.configPath ?? defaultCodexAppConfigPath()
|
|
545
|
+
const existingConfigExists = existsSync(configPath)
|
|
546
|
+
|
|
547
|
+
if (!existingConfigExists) {
|
|
548
|
+
return {
|
|
549
|
+
configPath,
|
|
550
|
+
changed: false
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const existingConfig = readCodexAppConfigFile(configPath)
|
|
555
|
+
const nextConfig = removeCodexAppProject(existingConfig, entry, { additionalRemotePaths: options.additionalRemotePaths })
|
|
556
|
+
|
|
557
|
+
if (JSON.stringify(existingConfig) === JSON.stringify(nextConfig)) {
|
|
558
|
+
return {
|
|
559
|
+
configPath,
|
|
560
|
+
changed: false
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const nextJson = `${JSON.stringify(nextConfig, null, 2)}\n`
|
|
565
|
+
const backupPath = backupPathFor(configPath, options.now ?? new Date())
|
|
566
|
+
copyFileSync(configPath, backupPath)
|
|
567
|
+
writeJsonAtomic(configPath, nextJson)
|
|
568
|
+
|
|
569
|
+
return {
|
|
570
|
+
configPath,
|
|
571
|
+
backupPath,
|
|
572
|
+
changed: true
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export function uninstallCodexGlobalStateProject (
|
|
577
|
+
entry: CodexAppProjectEntry,
|
|
578
|
+
options: { statePath?: string, now?: Date, additionalRemotePaths?: readonly string[] } = {}
|
|
579
|
+
): UninstallCodexGlobalStateResult {
|
|
580
|
+
const statePath = options.statePath ?? defaultCodexGlobalStatePath()
|
|
581
|
+
|
|
582
|
+
if (!existsSync(statePath)) {
|
|
583
|
+
return {
|
|
584
|
+
statePath,
|
|
585
|
+
changed: false
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const existingJson = readFileSync(statePath, 'utf8')
|
|
590
|
+
const existingState = JSON.parse(existingJson) as unknown
|
|
591
|
+
|
|
592
|
+
if (!isRecord(existingState)) {
|
|
593
|
+
throw new Error(`Invalid Codex global state JSON: ${statePath}`)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const nextState = removeCodexGlobalStateProject(existingState, entry, { additionalRemotePaths: options.additionalRemotePaths })
|
|
597
|
+
const nextJson = `${JSON.stringify(nextState)}\n`
|
|
598
|
+
|
|
599
|
+
if (JSON.stringify(existingState) === JSON.stringify(nextState)) {
|
|
600
|
+
return {
|
|
601
|
+
statePath,
|
|
602
|
+
changed: false
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const backupPath = backupPathFor(statePath, options.now ?? new Date())
|
|
607
|
+
copyFileSync(statePath, backupPath)
|
|
608
|
+
writeJsonAtomic(statePath, nextJson)
|
|
609
|
+
|
|
610
|
+
return {
|
|
611
|
+
statePath,
|
|
612
|
+
backupPath,
|
|
613
|
+
changed: true
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export function installCodexGlobalStateProject (
|
|
618
|
+
entry: CodexAppProjectEntry,
|
|
619
|
+
options: { statePath?: string, now?: Date, legacyRemotePaths?: readonly string[] } = {}
|
|
620
|
+
): InstallCodexGlobalStateResult {
|
|
621
|
+
const statePath = options.statePath ?? defaultCodexGlobalStatePath()
|
|
622
|
+
|
|
623
|
+
if (!existsSync(statePath)) {
|
|
624
|
+
return {
|
|
625
|
+
statePath,
|
|
626
|
+
changed: false
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const existingJson = readFileSync(statePath, 'utf8')
|
|
631
|
+
const existingState = JSON.parse(existingJson) as unknown
|
|
632
|
+
|
|
633
|
+
if (!isRecord(existingState)) {
|
|
634
|
+
throw new Error(`Invalid Codex global state JSON: ${statePath}`)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const nextState = normalizeCodexGlobalStateProject(existingState, entry, { legacyRemotePaths: options.legacyRemotePaths })
|
|
638
|
+
const nextJson = `${JSON.stringify(nextState)}\n`
|
|
639
|
+
|
|
640
|
+
if (JSON.stringify(existingState) === JSON.stringify(nextState)) {
|
|
641
|
+
return {
|
|
642
|
+
statePath,
|
|
643
|
+
changed: false
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const backupPath = backupPathFor(statePath, options.now ?? new Date())
|
|
648
|
+
copyFileSync(statePath, backupPath)
|
|
649
|
+
writeJsonAtomic(statePath, nextJson)
|
|
650
|
+
|
|
651
|
+
return {
|
|
652
|
+
statePath,
|
|
653
|
+
backupPath,
|
|
654
|
+
changed: true
|
|
655
|
+
}
|
|
656
|
+
}
|