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.
- package/LICENSE +191 -0
- package/README.md +115 -0
- package/assets/devcontainer/README.md +177 -0
- package/assets/devcontainer/devcontainer.json +83 -0
- package/assets/devcontainer/hooks/initialize.sh +55 -0
- package/assets/devcontainer/hooks/post-create.sh +67 -0
- package/assets/devcontainer/hooks/post-start.sh +34 -0
- package/assets/devcontainer/ssh-config-install.sh +172 -0
- package/assets/devcontainer/start.sh +485 -0
- package/assets/devcontainer/utils/codex-cli-update.sh +9 -0
- package/assets/devcontainer/utils/coding-agent-cli-update.sh +360 -0
- package/assets/devcontainer/utils/deps-install.sh +87 -0
- package/assets/devcontainer/utils/ssh-bootstrap.sh +200 -0
- package/dist/bin/cli.cjs +12 -0
- package/dist/bin/cli.d.cts +1 -0
- package/dist/bin/cli.d.mts +1 -0
- package/dist/bin/cli.mjs +14 -0
- package/dist/bin/cli.mjs.map +1 -0
- package/dist/main-BuEptwlL.cjs +1707 -0
- package/dist/main-ZFTrSVgt.mjs +1685 -0
- package/dist/main-ZFTrSVgt.mjs.map +1 -0
- package/dist/main.cjs +7 -0
- package/dist/main.d.cts +24 -0
- package/dist/main.d.cts.map +1 -0
- package/dist/main.d.mts +24 -0
- package/dist/main.d.mts.map +1 -0
- package/dist/main.mjs +3 -0
- package/docs/README.md +34 -0
- package/docs/architecture.md +75 -0
- package/docs/conventions.md +29 -0
- package/docs/development.md +59 -0
- package/docs/features/README.md +14 -0
- package/docs/features/generated-config-and-state.md +64 -0
- package/docs/features/github-auth-refresh.md +64 -0
- package/docs/features/lifecycle.md +64 -0
- package/docs/features/ssh-config-and-proxy.md +60 -0
- package/docs/features/start-and-shell.md +83 -0
- package/docs/testing.md +63 -0
- package/docs/todo.md +170 -0
- package/package.json +128 -0
- package/src/bin/cli.ts +9 -0
- package/src/coding-agents.ts +22 -0
- package/src/config.ts +81 -0
- package/src/constants.ts +9 -0
- package/src/devcontainer-cli.ts +57 -0
- package/src/devcontainer.ts +394 -0
- package/src/doctor.ts +139 -0
- package/src/github-git-auth.ts +143 -0
- package/src/jsonc.ts +123 -0
- package/src/list.ts +102 -0
- package/src/main.ts +362 -0
- package/src/metadata.ts +84 -0
- package/src/paths.ts +117 -0
- package/src/process.ts +94 -0
- package/src/shell.ts +60 -0
- package/src/ssh-config.ts +113 -0
- package/src/ssh-key.ts +52 -0
- 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
|
+
}
|