boxdown 1.0.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 (58) hide show
  1. package/LICENSE +191 -0
  2. package/README.md +115 -0
  3. package/assets/devcontainer/README.md +177 -0
  4. package/assets/devcontainer/devcontainer.json +83 -0
  5. package/assets/devcontainer/hooks/initialize.sh +55 -0
  6. package/assets/devcontainer/hooks/post-create.sh +67 -0
  7. package/assets/devcontainer/hooks/post-start.sh +34 -0
  8. package/assets/devcontainer/ssh-config-install.sh +172 -0
  9. package/assets/devcontainer/start.sh +485 -0
  10. package/assets/devcontainer/utils/codex-cli-update.sh +9 -0
  11. package/assets/devcontainer/utils/coding-agent-cli-update.sh +360 -0
  12. package/assets/devcontainer/utils/deps-install.sh +87 -0
  13. package/assets/devcontainer/utils/ssh-bootstrap.sh +200 -0
  14. package/dist/bin/cli.cjs +12 -0
  15. package/dist/bin/cli.d.cts +1 -0
  16. package/dist/bin/cli.d.mts +1 -0
  17. package/dist/bin/cli.mjs +14 -0
  18. package/dist/bin/cli.mjs.map +1 -0
  19. package/dist/main-BuEptwlL.cjs +1707 -0
  20. package/dist/main-ZFTrSVgt.mjs +1685 -0
  21. package/dist/main-ZFTrSVgt.mjs.map +1 -0
  22. package/dist/main.cjs +7 -0
  23. package/dist/main.d.cts +24 -0
  24. package/dist/main.d.cts.map +1 -0
  25. package/dist/main.d.mts +24 -0
  26. package/dist/main.d.mts.map +1 -0
  27. package/dist/main.mjs +3 -0
  28. package/docs/README.md +34 -0
  29. package/docs/architecture.md +75 -0
  30. package/docs/conventions.md +29 -0
  31. package/docs/development.md +59 -0
  32. package/docs/features/README.md +14 -0
  33. package/docs/features/generated-config-and-state.md +64 -0
  34. package/docs/features/github-auth-refresh.md +64 -0
  35. package/docs/features/lifecycle.md +64 -0
  36. package/docs/features/ssh-config-and-proxy.md +60 -0
  37. package/docs/features/start-and-shell.md +83 -0
  38. package/docs/testing.md +63 -0
  39. package/docs/todo.md +170 -0
  40. package/package.json +128 -0
  41. package/src/bin/cli.ts +9 -0
  42. package/src/coding-agents.ts +22 -0
  43. package/src/config.ts +81 -0
  44. package/src/constants.ts +9 -0
  45. package/src/devcontainer-cli.ts +57 -0
  46. package/src/devcontainer.ts +394 -0
  47. package/src/doctor.ts +139 -0
  48. package/src/github-git-auth.ts +143 -0
  49. package/src/jsonc.ts +123 -0
  50. package/src/list.ts +102 -0
  51. package/src/main.ts +362 -0
  52. package/src/metadata.ts +84 -0
  53. package/src/paths.ts +117 -0
  54. package/src/process.ts +94 -0
  55. package/src/shell.ts +60 -0
  56. package/src/ssh-config.ts +113 -0
  57. package/src/ssh-key.ts +52 -0
  58. package/src/status.ts +327 -0
package/src/shell.ts ADDED
@@ -0,0 +1,60 @@
1
+ export function shellQuote (value: string): string {
2
+ if (value.length === 0) {
3
+ return "''"
4
+ }
5
+
6
+ return `'${value.replaceAll("'", "'\\''")}'`
7
+ }
8
+
9
+ export function sshConfigQuote (value: string): string {
10
+ return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`
11
+ }
12
+
13
+ export const DEFAULT_TTY_MAX_COLUMNS = 120
14
+
15
+ export function interactiveShellEnvArgs (env: NodeJS.ProcessEnv = process.env): string[] {
16
+ return [
17
+ `TERM=${env.TERM ?? 'xterm-256color'}`,
18
+ 'COLORTERM=truecolor',
19
+ `BOXDOWN_TTY_NORMALIZE=${env.BOXDOWN_TTY_NORMALIZE ?? '1'}`,
20
+ `BOXDOWN_TTY_MAX_COLUMNS=${env.BOXDOWN_TTY_MAX_COLUMNS ?? String(DEFAULT_TTY_MAX_COLUMNS)}`
21
+ ]
22
+ }
23
+
24
+ function interactiveTtySetupScript (): string {
25
+ return [
26
+ 'if [ -t 0 ]; then',
27
+ ' case "${BOXDOWN_TTY_NORMALIZE:-1}" in',
28
+ ' 0|false|FALSE|no|NO|off|OFF) ;;',
29
+ ' *)',
30
+ ' max_columns="${BOXDOWN_TTY_MAX_COLUMNS:-120}"',
31
+ ' if [ "$max_columns" -gt 0 ] 2>/dev/null; then',
32
+ ' set -- $(stty size 2>/dev/null || true)',
33
+ ' rows="${1:-}"',
34
+ ' columns="${2:-}"',
35
+ ' if [ -n "$columns" ] && [ "$columns" -gt "$max_columns" ] 2>/dev/null; then',
36
+ ' stty cols "$max_columns" 2>/dev/null || true',
37
+ ' export COLUMNS="$max_columns"',
38
+ ' if [ -n "$rows" ]; then export LINES="$rows"; fi',
39
+ ' printf "Boxdown: terminal width clamped to %s columns (was %s). Set BOXDOWN_TTY_NORMALIZE=0 to disable.\\n" "$max_columns" "$columns" >&2',
40
+ ' fi',
41
+ ' fi',
42
+ ' ;;',
43
+ ' esac',
44
+ 'fi',
45
+ ].join('\n')
46
+ }
47
+
48
+ export function interactiveShellScript (): string {
49
+ return [
50
+ interactiveTtySetupScript(),
51
+ 'exec bash -i'
52
+ ].join('\n')
53
+ }
54
+
55
+ export function interactiveCommandScript (): string {
56
+ return [
57
+ interactiveTtySetupScript(),
58
+ 'exec "$@"'
59
+ ].join('\n')
60
+ }
@@ -0,0 +1,113 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ import type { WorkspaceContext } from './paths.ts'
5
+ import { shellQuote, sshConfigQuote } from './shell.ts'
6
+ import { ensureHostSshKey } from './ssh-key.ts'
7
+
8
+ export function defaultSshAlias (workspaceBasename: string): string {
9
+ return `${workspaceBasename}-devcontainer`
10
+ }
11
+
12
+ export function validateSshAlias (alias: string): void {
13
+ if (!/^[A-Za-z0-9_.-]+$/.test(alias)) {
14
+ throw new Error(`SSH alias contains unsupported characters: ${alias}`)
15
+ }
16
+ }
17
+
18
+ export function defaultSshConfigPath (env: NodeJS.ProcessEnv = process.env): string {
19
+ return env.BOXDOWN_SSH_CONFIG ?? env.DEVCONTAINER_SSH_CONFIG ?? join(env.HOME ?? '', '.ssh', 'config')
20
+ }
21
+
22
+ export function buildProxyCommand (context: WorkspaceContext, alias: string): string {
23
+ const cliPath = join(context.packageRoot, 'dist', 'bin', 'cli.cjs')
24
+
25
+ return `${shellQuote(process.execPath)} ${shellQuote(cliPath)} ssh-proxy --workspace ${shellQuote(context.workspaceFolder)} --alias ${shellQuote(alias)}`
26
+ }
27
+
28
+ export function buildSshConfigBlock (context: WorkspaceContext, alias: string): string {
29
+ validateSshAlias(alias)
30
+
31
+ return [
32
+ `# BEGIN ${alias} boxdown devcontainer ssh`,
33
+ `Host ${alias}`,
34
+ ` HostName ${alias}`,
35
+ ' User node',
36
+ ' IdentityFile none',
37
+ ` IdentityFile ${sshConfigQuote(context.sshKeyPath)}`,
38
+ ' IdentitiesOnly yes',
39
+ ` ProxyCommand ${buildProxyCommand(context, alias)}`,
40
+ ' StrictHostKeyChecking no',
41
+ ' UserKnownHostsFile /dev/null',
42
+ ' LogLevel ERROR',
43
+ `# END ${alias} boxdown devcontainer ssh`,
44
+ ''
45
+ ].join('\n')
46
+ }
47
+
48
+ export function replaceSshConfigBlock (existingConfig: string, alias: string, block: string): string {
49
+ const begin = `# BEGIN ${alias} boxdown devcontainer ssh`
50
+ const end = `# END ${alias} boxdown devcontainer ssh`
51
+ const lines = existingConfig.split(/\r?\n/)
52
+ const nextLines: string[] = []
53
+ let skipping = false
54
+
55
+ for (const line of lines) {
56
+ if (line === begin) {
57
+ skipping = true
58
+ continue
59
+ }
60
+
61
+ if (line === end) {
62
+ skipping = false
63
+ continue
64
+ }
65
+
66
+ if (!skipping) {
67
+ nextLines.push(line)
68
+ }
69
+ }
70
+
71
+ while (nextLines.length > 0 && nextLines[nextLines.length - 1] === '') {
72
+ nextLines.pop()
73
+ }
74
+
75
+ return `${nextLines.join('\n')}${nextLines.length > 0 ? '\n\n' : ''}${block}`
76
+ }
77
+
78
+ export async function installSshConfig (context: WorkspaceContext, alias: string, options: { quiet?: boolean, configPath?: string } = {}): Promise<void> {
79
+ validateSshAlias(alias)
80
+ await ensureHostSshKey(context, options.quiet ?? false)
81
+
82
+ const sshConfigPath = options.configPath ?? defaultSshConfigPath()
83
+ const sshConfigDir = dirname(sshConfigPath)
84
+ mkdirSync(sshConfigDir, { recursive: true, mode: 0o700 })
85
+
86
+ if (!existsSync(sshConfigPath)) {
87
+ writeFileSync(sshConfigPath, '')
88
+ }
89
+
90
+ chmodSync(sshConfigDir, 0o700)
91
+ chmodSync(sshConfigPath, 0o600)
92
+
93
+ const existingConfig = readFileSync(sshConfigPath, 'utf8')
94
+ const block = buildSshConfigBlock(context, alias)
95
+ const nextConfig = replaceSshConfigBlock(existingConfig, alias, block)
96
+
97
+ if (nextConfig !== existingConfig) {
98
+ writeFileSync(sshConfigPath, nextConfig)
99
+ if (!options.quiet) {
100
+ process.stdout.write(`Installed SSH alias: ${alias}\n`)
101
+ }
102
+ } else if (!options.quiet) {
103
+ process.stdout.write(`SSH alias already up to date: ${alias}\n`)
104
+ }
105
+
106
+ chmodSync(sshConfigPath, 0o600)
107
+
108
+ if (!options.quiet) {
109
+ process.stdout.write(`SSH config: ${sshConfigPath}\n`)
110
+ process.stdout.write(`Identity file: ${context.sshKeyPath}\n\n`)
111
+ process.stdout.write(`Validate with:\n ssh ${alias} 'whoami && pwd'\n`)
112
+ }
113
+ }
package/src/ssh-key.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+
3
+ import type { WorkspaceContext } from './paths.ts'
4
+ import { runBuffered } from './process.ts'
5
+
6
+ export async function ensureHostSshKey (context: WorkspaceContext, quiet = false): Promise<void> {
7
+ mkdirSync(context.sshKeyDir, { recursive: true, mode: 0o700 })
8
+
9
+ if (!existsSync(context.sshKeyPath)) {
10
+ if (!quiet) {
11
+ process.stderr.write(`Generating Boxdown SSH identity: ${context.sshKeyPath}\n`)
12
+ }
13
+
14
+ const result = await runBuffered('ssh-keygen', [
15
+ '-t',
16
+ 'ed25519',
17
+ '-f',
18
+ context.sshKeyPath,
19
+ '-N',
20
+ '',
21
+ '-C',
22
+ `${context.workspaceBasename}-devcontainer`
23
+ ], {
24
+ mirrorStdout: quiet ? false : 'stderr',
25
+ mirrorStderr: 'stderr'
26
+ })
27
+
28
+ if (result.code !== 0) {
29
+ throw new Error(`ssh-keygen failed while creating ${context.sshKeyPath}`)
30
+ }
31
+ }
32
+
33
+ if (!existsSync(context.sshPublicKeyPath)) {
34
+ const result = await runBuffered('ssh-keygen', ['-y', '-f', context.sshKeyPath], {
35
+ mirrorStdout: false,
36
+ mirrorStderr: 'stderr'
37
+ })
38
+
39
+ if (result.code !== 0) {
40
+ throw new Error(`ssh-keygen failed while deriving ${context.sshPublicKeyPath}`)
41
+ }
42
+
43
+ writeFileSync(context.sshPublicKeyPath, result.stdout)
44
+ }
45
+
46
+ mkdirSync(context.sshPublicKeyRuntimeDir, { recursive: true, mode: 0o755 })
47
+ writeFileSync(context.sshPublicKeyRuntimePath, readFileSync(context.sshPublicKeyPath, 'utf8'))
48
+
49
+ chmodSync(context.sshKeyPath, 0o600)
50
+ chmodSync(context.sshPublicKeyPath, 0o644)
51
+ chmodSync(context.sshPublicKeyRuntimePath, 0o644)
52
+ }
package/src/status.ts ADDED
@@ -0,0 +1,327 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ import type { WorkspaceContext } from './paths.ts'
4
+ import { buildSshConfigBlock, defaultSshConfigPath } from './ssh-config.ts'
5
+
6
+ export type SshAliasSource = 'default' | 'provided'
7
+ export type SshManagedBlockState = 'missing' | 'installed' | 'outdated'
8
+
9
+ export interface ContainerSummary {
10
+ id: string
11
+ name?: string
12
+ state?: string
13
+ status?: string
14
+ localFolder?: string
15
+ }
16
+
17
+ export interface StatusInfo {
18
+ workspace: {
19
+ folder: string
20
+ basename: string
21
+ id: string
22
+ }
23
+ ssh: {
24
+ alias: string
25
+ aliasSource: SshAliasSource
26
+ configPath: string
27
+ configExists: boolean
28
+ managedBlockState: SshManagedBlockState
29
+ keyPath: string
30
+ keyExists: boolean
31
+ publicKeyPath: string
32
+ publicKeyExists: boolean
33
+ publicKeyRuntimePath: string
34
+ publicKeyRuntimeExists: boolean
35
+ }
36
+ paths: {
37
+ cacheRoot: string
38
+ dataRoot: string
39
+ workspaceCacheDir: string
40
+ workspaceDataDir: string
41
+ generatedConfigPath: string
42
+ generatedConfigExists: boolean
43
+ assetsDevcontainerDir: string
44
+ assetsDevcontainerExists: boolean
45
+ }
46
+ container: {
47
+ found: boolean
48
+ running: boolean
49
+ id?: string
50
+ name?: string
51
+ state?: string
52
+ status?: string
53
+ }
54
+ }
55
+
56
+ export interface SshConfigStatus {
57
+ configPath: string
58
+ configExists: boolean
59
+ managedBlockState: SshManagedBlockState
60
+ }
61
+
62
+ interface DockerPsJson {
63
+ ID?: unknown
64
+ Names?: unknown
65
+ State?: unknown
66
+ Status?: unknown
67
+ Labels?: unknown
68
+ }
69
+
70
+ function dockerLabelsFromString (labels: string): Record<string, string> {
71
+ return Object.fromEntries(labels
72
+ .split(',')
73
+ .map((label) => label.trim())
74
+ .filter((label) => label.length > 0)
75
+ .map((label) => {
76
+ const separator = label.indexOf('=')
77
+ return separator === -1 ? [label, ''] : [label.slice(0, separator), label.slice(separator + 1)]
78
+ }))
79
+ }
80
+
81
+ export function parseDockerPsJsonLines (output: string): ContainerSummary[] {
82
+ const lines = output.split(/\r?\n/).filter((line) => line.trim().length > 0)
83
+
84
+ return lines.map((line) => {
85
+ let parsed: DockerPsJson
86
+
87
+ try {
88
+ parsed = JSON.parse(line) as DockerPsJson
89
+ } catch {
90
+ throw new Error(`Could not parse docker ps output: ${line}`)
91
+ }
92
+
93
+ if (typeof parsed.ID !== 'string' || parsed.ID.length === 0) {
94
+ throw new Error(`Docker ps output is missing container ID: ${line}`)
95
+ }
96
+
97
+ return {
98
+ id: parsed.ID,
99
+ name: typeof parsed.Names === 'string' && parsed.Names.length > 0 ? parsed.Names : undefined,
100
+ state: typeof parsed.State === 'string' && parsed.State.length > 0 ? parsed.State : undefined,
101
+ status: typeof parsed.Status === 'string' && parsed.Status.length > 0 ? parsed.Status : undefined,
102
+ localFolder: typeof parsed.Labels === 'string' ? dockerLabelsFromString(parsed.Labels)['devcontainer.local_folder'] : undefined
103
+ }
104
+ })
105
+ }
106
+
107
+ function readFileUtf8 (path: string): string {
108
+ return readFileSync(path, 'utf8')
109
+ }
110
+
111
+ function managedSshBlockMarkers (alias: string): { begin: string, end: string } {
112
+ return {
113
+ begin: `# BEGIN ${alias} boxdown devcontainer ssh`,
114
+ end: `# END ${alias} boxdown devcontainer ssh`
115
+ }
116
+ }
117
+
118
+ function findManagedSshConfigBlock (config: string, alias: string): string | undefined {
119
+ const { begin, end } = managedSshBlockMarkers(alias)
120
+ const beginIndex = config.indexOf(begin)
121
+
122
+ if (beginIndex === -1) {
123
+ return undefined
124
+ }
125
+
126
+ const endIndex = config.indexOf(end, beginIndex)
127
+
128
+ if (endIndex === -1) {
129
+ return ''
130
+ }
131
+
132
+ const afterEndMarkerIndex = endIndex + end.length
133
+ const afterEndLineIndex = config[afterEndMarkerIndex] === '\n' ? afterEndMarkerIndex + 1 : afterEndMarkerIndex
134
+
135
+ return config.slice(beginIndex, afterEndLineIndex)
136
+ }
137
+
138
+ export function inspectSshConfigStatus (
139
+ context: WorkspaceContext,
140
+ alias: string,
141
+ configPath: string,
142
+ exists: (path: string) => boolean,
143
+ readFile: (path: string) => string = readFileUtf8
144
+ ): SshConfigStatus {
145
+ const configExists = exists(configPath)
146
+
147
+ if (!configExists) {
148
+ return {
149
+ configPath,
150
+ configExists,
151
+ managedBlockState: 'missing'
152
+ }
153
+ }
154
+
155
+ const config = readFile(configPath)
156
+ const managedBlock = findManagedSshConfigBlock(config, alias)
157
+
158
+ if (managedBlock === undefined) {
159
+ return {
160
+ configPath,
161
+ configExists,
162
+ managedBlockState: 'missing'
163
+ }
164
+ }
165
+
166
+ return {
167
+ configPath,
168
+ configExists,
169
+ managedBlockState: managedBlock === buildSshConfigBlock(context, alias) ? 'installed' : 'outdated'
170
+ }
171
+ }
172
+
173
+ export function createStatusInfo (
174
+ context: WorkspaceContext,
175
+ alias: string,
176
+ container: ContainerSummary | undefined,
177
+ exists: (path: string) => boolean,
178
+ options: {
179
+ aliasSource?: SshAliasSource
180
+ sshConfigPath?: string
181
+ readFile?: (path: string) => string
182
+ } = {}
183
+ ): StatusInfo {
184
+ const state = container?.state?.toLowerCase()
185
+ const sshConfig = inspectSshConfigStatus(
186
+ context,
187
+ alias,
188
+ options.sshConfigPath ?? defaultSshConfigPath(),
189
+ exists,
190
+ options.readFile
191
+ )
192
+
193
+ return {
194
+ workspace: {
195
+ folder: context.workspaceFolder,
196
+ basename: context.workspaceBasename,
197
+ id: context.workspaceId
198
+ },
199
+ ssh: {
200
+ alias,
201
+ aliasSource: options.aliasSource ?? 'provided',
202
+ configPath: sshConfig.configPath,
203
+ configExists: sshConfig.configExists,
204
+ managedBlockState: sshConfig.managedBlockState,
205
+ keyPath: context.sshKeyPath,
206
+ keyExists: exists(context.sshKeyPath),
207
+ publicKeyPath: context.sshPublicKeyPath,
208
+ publicKeyExists: exists(context.sshPublicKeyPath),
209
+ publicKeyRuntimePath: context.sshPublicKeyRuntimePath,
210
+ publicKeyRuntimeExists: exists(context.sshPublicKeyRuntimePath)
211
+ },
212
+ paths: {
213
+ cacheRoot: context.cacheRoot,
214
+ dataRoot: context.dataRoot,
215
+ workspaceCacheDir: context.workspaceCacheDir,
216
+ workspaceDataDir: context.workspaceDataDir,
217
+ generatedConfigPath: context.generatedConfigPath,
218
+ generatedConfigExists: exists(context.generatedConfigPath),
219
+ assetsDevcontainerDir: context.assetsDevcontainerDir,
220
+ assetsDevcontainerExists: exists(context.assetsDevcontainerDir)
221
+ },
222
+ container: {
223
+ found: container !== undefined,
224
+ running: state === 'running',
225
+ id: container?.id,
226
+ name: container?.name,
227
+ state: container?.state,
228
+ status: container?.status
229
+ }
230
+ }
231
+ }
232
+
233
+ export function statusIsHealthy (status: StatusInfo): boolean {
234
+ return status.paths.generatedConfigExists &&
235
+ status.paths.assetsDevcontainerExists &&
236
+ status.ssh.keyExists &&
237
+ status.ssh.publicKeyExists &&
238
+ status.ssh.publicKeyRuntimeExists &&
239
+ status.container.found &&
240
+ status.container.running
241
+ }
242
+
243
+ const color = {
244
+ green: '\u001B[32m',
245
+ red: '\u001B[31m',
246
+ reset: '\u001B[0m'
247
+ }
248
+
249
+ function colorize (value: string, colorName: 'green' | 'red', enabled: boolean): string {
250
+ if (!enabled) {
251
+ return value
252
+ }
253
+
254
+ return `${color[colorName]}${value}${color.reset}`
255
+ }
256
+
257
+ function existenceText (value: boolean, colorEnabled: boolean): string {
258
+ return colorize(value ? 'exists' : 'missing', value ? 'green' : 'red', colorEnabled)
259
+ }
260
+
261
+ function runningText (value: boolean, colorEnabled: boolean): string {
262
+ return colorize(value ? 'yes' : 'no', value ? 'green' : 'red', colorEnabled)
263
+ }
264
+
265
+ function managedBlockText (state: SshManagedBlockState, colorEnabled: boolean): string {
266
+ return colorize(state, state === 'installed' ? 'green' : 'red', colorEnabled)
267
+ }
268
+
269
+ function aliasSourceText (source: SshAliasSource): string {
270
+ return source === 'default' ? 'computed default' : 'provided'
271
+ }
272
+
273
+ function installedText (state: SshManagedBlockState): string {
274
+ return state === 'installed' ? 'installed' : 'not installed'
275
+ }
276
+
277
+ function stateText (state: string, healthy: boolean, colorEnabled: boolean): string {
278
+ return colorize(state, healthy ? 'green' : 'red', colorEnabled)
279
+ }
280
+
281
+ export function formatStatusText (status: StatusInfo, options: { color?: boolean } = {}): string {
282
+ const colorEnabled = options.color ?? false
283
+ const containerState = status.container.found ? status.container.state ?? 'unknown' : 'absent'
284
+ const healthy = statusIsHealthy(status)
285
+ const lines = [
286
+ 'Boxdown status',
287
+ '',
288
+ 'Workspace:',
289
+ ` Path: ${status.workspace.folder}`,
290
+ ` Name: ${status.workspace.basename}`,
291
+ ` ID: ${status.workspace.id}`,
292
+ ` SSH alias: ${status.ssh.alias} (${aliasSourceText(status.ssh.aliasSource)}; ${installedText(status.ssh.managedBlockState)})`,
293
+ '',
294
+ 'Paths:',
295
+ ` Cache root: ${status.paths.cacheRoot}`,
296
+ ` Data root: ${status.paths.dataRoot}`,
297
+ ` Workspace cache: ${status.paths.workspaceCacheDir}`,
298
+ ` Workspace data: ${status.paths.workspaceDataDir}`,
299
+ ` Generated config: ${status.paths.generatedConfigPath} (${existenceText(status.paths.generatedConfigExists, colorEnabled)})`,
300
+ ` Devcontainer assets: ${status.paths.assetsDevcontainerDir} (${existenceText(status.paths.assetsDevcontainerExists, colorEnabled)})`,
301
+ '',
302
+ 'SSH:',
303
+ ` SSH config: ${status.ssh.configPath} (${existenceText(status.ssh.configExists, colorEnabled)})`,
304
+ ` Boxdown SSH block: ${managedBlockText(status.ssh.managedBlockState, colorEnabled)}`,
305
+ ` Private key: ${status.ssh.keyPath} (${existenceText(status.ssh.keyExists, colorEnabled)})`,
306
+ ` Public key: ${status.ssh.publicKeyPath} (${existenceText(status.ssh.publicKeyExists, colorEnabled)})`,
307
+ ` Runtime public key: ${status.ssh.publicKeyRuntimePath} (${existenceText(status.ssh.publicKeyRuntimeExists, colorEnabled)})`,
308
+ '',
309
+ 'Container:',
310
+ ` State: ${stateText(containerState, healthy, colorEnabled)}`,
311
+ ` Running: ${runningText(status.container.running, colorEnabled)}`
312
+ ]
313
+
314
+ if (status.container.id !== undefined) {
315
+ lines.push(` ID: ${status.container.id}`)
316
+ }
317
+
318
+ if (status.container.name !== undefined) {
319
+ lines.push(` Name: ${status.container.name}`)
320
+ }
321
+
322
+ if (status.container.status !== undefined) {
323
+ lines.push(` Docker status: ${status.container.status}`)
324
+ }
325
+
326
+ return `${lines.join('\n')}\n`
327
+ }