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.
Files changed (69) hide show
  1. package/README.md +135 -12
  2. package/assets/devcontainer/README.md +49 -17
  3. package/assets/devcontainer/devcontainer.json +24 -7
  4. package/assets/devcontainer/hooks/initialize.sh +32 -0
  5. package/assets/devcontainer/hooks/post-create.sh +59 -12
  6. package/assets/devcontainer/hooks/post-start.sh +21 -4
  7. package/assets/devcontainer/ssh-config-install.sh +12 -3
  8. package/assets/devcontainer/start.sh +721 -44
  9. package/assets/devcontainer/utils/coding-agent-cli-update.sh +267 -3
  10. package/assets/devcontainer/utils/deps-install.sh +68 -0
  11. package/assets/devcontainer/utils/git-config-bootstrap.sh +128 -0
  12. package/assets/devcontainer/utils/git-signing-bootstrap.sh +56 -0
  13. package/assets/devcontainer/utils/python-bootstrap.sh +69 -0
  14. package/assets/devcontainer/utils/ssh-bootstrap.sh +9 -0
  15. package/dist/bin/cli.cjs +1 -1
  16. package/dist/bin/cli.mjs +1 -1
  17. package/dist/main-Df4E8ARj.cjs +5498 -0
  18. package/dist/main-XMBsKjIK.mjs +5458 -0
  19. package/dist/main-XMBsKjIK.mjs.map +1 -0
  20. package/dist/main.cjs +5 -2
  21. package/dist/main.d.cts +495 -4
  22. package/dist/main.d.cts.map +1 -1
  23. package/dist/main.d.mts +495 -4
  24. package/dist/main.d.mts.map +1 -1
  25. package/dist/main.mjs +2 -2
  26. package/docs/README.md +1 -0
  27. package/docs/architecture.md +32 -0
  28. package/docs/development.md +13 -6
  29. package/docs/features/README.md +2 -0
  30. package/docs/features/commit-signing.md +23 -0
  31. package/docs/features/generated-config-and-state.md +57 -4
  32. package/docs/features/github-auth-refresh.md +12 -0
  33. package/docs/features/lifecycle.md +97 -11
  34. package/docs/features/setup.md +66 -0
  35. package/docs/features/ssh-config-and-proxy.md +228 -7
  36. package/docs/features/start-and-shell.md +40 -5
  37. package/docs/superpowers/plans/2026-07-11-default-commit-signing.md +110 -0
  38. package/docs/superpowers/plans/2026-07-11-progress-ci-regression.md +125 -0
  39. package/docs/superpowers/specs/2026-07-11-default-commit-signing-design.md +396 -0
  40. package/docs/superpowers/specs/2026-07-11-progress-ci-regression-design.md +43 -0
  41. package/docs/testing.md +35 -2
  42. package/docs/todo.md +2 -2
  43. package/package.json +1 -1
  44. package/src/claude-app-config.ts +304 -0
  45. package/src/cli-style.ts +43 -0
  46. package/src/codex-app-config.ts +656 -0
  47. package/src/config.ts +68 -10
  48. package/src/constants.ts +5 -0
  49. package/src/devcontainer.ts +500 -64
  50. package/src/doctor.ts +195 -30
  51. package/src/git-signing.ts +87 -0
  52. package/src/interactive-prompts.ts +692 -0
  53. package/src/list.ts +16 -2
  54. package/src/logging.ts +154 -0
  55. package/src/main.ts +1213 -63
  56. package/src/metadata.ts +52 -3
  57. package/src/package-info.ts +18 -0
  58. package/src/paths.ts +50 -10
  59. package/src/process.ts +80 -2
  60. package/src/progress.ts +593 -0
  61. package/src/purge.ts +208 -0
  62. package/src/shell.ts +19 -0
  63. package/src/ssh-config.ts +134 -16
  64. package/src/ssh-install-targets.ts +111 -0
  65. package/src/ssh-key.ts +53 -13
  66. package/src/status.ts +5 -0
  67. package/dist/main-BuEptwlL.cjs +0 -1707
  68. package/dist/main-ZFTrSVgt.mjs +0 -1685
  69. 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
+ }