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
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { runBuffered } from './process.ts'
|
|
2
|
+
|
|
3
|
+
export function canonicalGithubRemoteUrl (remoteUrl: string): string | undefined {
|
|
4
|
+
const trimmed = remoteUrl.trim()
|
|
5
|
+
const scpLike = /^git@github\.com:([^/]+)\/(.+)$/i.exec(trimmed)
|
|
6
|
+
|
|
7
|
+
if (scpLike !== null) {
|
|
8
|
+
return canonicalGithubUrlFromParts(scpLike[1], scpLike[2])
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const parsed = new URL(trimmed)
|
|
13
|
+
const protocol = parsed.protocol.toLowerCase()
|
|
14
|
+
const host = parsed.hostname.toLowerCase()
|
|
15
|
+
|
|
16
|
+
if (host !== 'github.com') {
|
|
17
|
+
return undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (protocol === 'ssh:' && parsed.username !== 'git') {
|
|
21
|
+
return undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (protocol !== 'https:' && protocol !== 'ssh:') {
|
|
25
|
+
return undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const parts = parsed.pathname.split('/').filter((part) => part.length > 0)
|
|
29
|
+
if (parts.length !== 2) {
|
|
30
|
+
return undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const [owner, repo] = parts
|
|
34
|
+
return canonicalGithubUrlFromParts(owner, repo)
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function canonicalGithubUrlFromParts (owner: string | undefined, repo: string | undefined): string | undefined {
|
|
41
|
+
if (owner === undefined || repo === undefined) {
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const normalizedOwner = owner.trim()
|
|
46
|
+
const normalizedRepo = repo.trim().replace(/\/+$/, '').replace(/\.git$/i, '')
|
|
47
|
+
|
|
48
|
+
if (
|
|
49
|
+
normalizedOwner.length === 0 ||
|
|
50
|
+
normalizedRepo.length === 0 ||
|
|
51
|
+
normalizedOwner.includes('/') ||
|
|
52
|
+
normalizedRepo.includes('/')
|
|
53
|
+
) {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return `https://github.com/${normalizedOwner}/${normalizedRepo}.git`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function configureWorkspaceGithubGitAuth (workspaceFolder: string): Promise<boolean> {
|
|
61
|
+
const insideWorkTree = await runWorkspaceGit(workspaceFolder, ['rev-parse', '--is-inside-work-tree'])
|
|
62
|
+
|
|
63
|
+
if (insideWorkTree.code !== 0 || insideWorkTree.stdout.trim() !== 'true') {
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const remoteConfig = await runWorkspaceGit(workspaceFolder, ['config', '--local', '--get-regexp', '^remote\\..*\\.url$'])
|
|
68
|
+
|
|
69
|
+
if (remoteConfig.code !== 0) {
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const githubRemotes = parseGithubRemoteConfig(remoteConfig.stdout)
|
|
74
|
+
|
|
75
|
+
if (githubRemotes.length === 0) {
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const remote of githubRemotes) {
|
|
80
|
+
const fetchUrl = await runWorkspaceGit(workspaceFolder, ['remote', 'set-url', remote.name, remote.url])
|
|
81
|
+
if (fetchUrl.code !== 0) {
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const pushUrl = await runWorkspaceGit(workspaceFolder, ['remote', 'set-url', '--push', remote.name, remote.url])
|
|
86
|
+
if (pushUrl.code !== 0) {
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
await runWorkspaceGit(workspaceFolder, ['config', '--local', '--unset-all', 'credential.https://github.com.helper'])
|
|
92
|
+
|
|
93
|
+
const resetHelper = await runWorkspaceGit(workspaceFolder, ['config', '--local', '--add', 'credential.https://github.com.helper', ''])
|
|
94
|
+
if (resetHelper.code !== 0) {
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const ghHelper = await runWorkspaceGit(workspaceFolder, ['config', '--local', '--add', 'credential.https://github.com.helper', '!gh auth git-credential'])
|
|
99
|
+
if (ghHelper.code !== 0) {
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const remoteUrl of new Set(githubRemotes.map((remote) => remote.url))) {
|
|
104
|
+
const rewrite = await runWorkspaceGit(workspaceFolder, ['config', '--local', '--replace-all', `url.${remoteUrl}.insteadOf`, remoteUrl])
|
|
105
|
+
if (rewrite.code !== 0) {
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return true
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseGithubRemoteConfig (configOutput: string): Array<{ name: string, url: string }> {
|
|
114
|
+
const remotes: Array<{ name: string, url: string }> = []
|
|
115
|
+
|
|
116
|
+
for (const line of configOutput.split(/\r?\n/)) {
|
|
117
|
+
const match = /^remote\.(.+)\.url\s+(.+)$/.exec(line)
|
|
118
|
+
if (match === null) {
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const name = match[1]
|
|
123
|
+
const rawUrl = match[2]
|
|
124
|
+
if (name === undefined || rawUrl === undefined) {
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const url = canonicalGithubRemoteUrl(rawUrl)
|
|
129
|
+
if (url !== undefined) {
|
|
130
|
+
remotes.push({ name, url })
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return remotes
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function runWorkspaceGit (workspaceFolder: string, args: string[]) {
|
|
138
|
+
return runBuffered('git', args, {
|
|
139
|
+
cwd: workspaceFolder,
|
|
140
|
+
mirrorStdout: false,
|
|
141
|
+
mirrorStderr: false
|
|
142
|
+
})
|
|
143
|
+
}
|
package/src/jsonc.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
export function stripJsonComments (input: string): string {
|
|
2
|
+
let output = ''
|
|
3
|
+
let inString = false
|
|
4
|
+
let escaped = false
|
|
5
|
+
|
|
6
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
7
|
+
const char = input[index]
|
|
8
|
+
const next = input[index + 1]
|
|
9
|
+
|
|
10
|
+
if (char === undefined) {
|
|
11
|
+
break
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (inString) {
|
|
15
|
+
output += char
|
|
16
|
+
|
|
17
|
+
if (escaped) {
|
|
18
|
+
escaped = false
|
|
19
|
+
} else if (char === '\\') {
|
|
20
|
+
escaped = true
|
|
21
|
+
} else if (char === '"') {
|
|
22
|
+
inString = false
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
continue
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (char === '"') {
|
|
29
|
+
inString = true
|
|
30
|
+
output += char
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (char === '/' && next === '/') {
|
|
35
|
+
while (index < input.length && input[index] !== '\n') {
|
|
36
|
+
index += 1
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (input[index] === '\n') {
|
|
40
|
+
output += '\n'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (char === '/' && next === '*') {
|
|
47
|
+
index += 2
|
|
48
|
+
|
|
49
|
+
while (index < input.length) {
|
|
50
|
+
if (input[index] === '\n') {
|
|
51
|
+
output += '\n'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (input[index] === '*' && input[index + 1] === '/') {
|
|
55
|
+
index += 1
|
|
56
|
+
break
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
index += 1
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
output += char
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return output
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function stripTrailingCommas (input: string): string {
|
|
72
|
+
let output = ''
|
|
73
|
+
let inString = false
|
|
74
|
+
let escaped = false
|
|
75
|
+
|
|
76
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
77
|
+
const char = input[index]
|
|
78
|
+
|
|
79
|
+
if (char === undefined) {
|
|
80
|
+
break
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (inString) {
|
|
84
|
+
output += char
|
|
85
|
+
|
|
86
|
+
if (escaped) {
|
|
87
|
+
escaped = false
|
|
88
|
+
} else if (char === '\\') {
|
|
89
|
+
escaped = true
|
|
90
|
+
} else if (char === '"') {
|
|
91
|
+
inString = false
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (char === '"') {
|
|
98
|
+
inString = true
|
|
99
|
+
output += char
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (char === ',') {
|
|
104
|
+
let nextIndex = index + 1
|
|
105
|
+
|
|
106
|
+
while (/\s/.test(input[nextIndex] ?? '')) {
|
|
107
|
+
nextIndex += 1
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (input[nextIndex] === '}' || input[nextIndex] === ']') {
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
output += char
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return output
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function parseJsonc <T> (input: string): T {
|
|
122
|
+
return JSON.parse(stripTrailingCommas(stripJsonComments(input))) as T
|
|
123
|
+
}
|
package/src/list.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { WorkspaceMetadata } from './metadata.ts'
|
|
2
|
+
import type { ContainerSummary } from './status.ts'
|
|
3
|
+
|
|
4
|
+
export interface WorkspaceListEntry extends WorkspaceMetadata {
|
|
5
|
+
repoExists: boolean
|
|
6
|
+
state: string
|
|
7
|
+
container: {
|
|
8
|
+
found: boolean
|
|
9
|
+
running: boolean
|
|
10
|
+
id?: string
|
|
11
|
+
name?: string
|
|
12
|
+
state?: string
|
|
13
|
+
status?: string
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function containerState (container: ContainerSummary | undefined, dockerAvailable: boolean): string {
|
|
18
|
+
if (!dockerAvailable) {
|
|
19
|
+
return 'unknown'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return container?.state?.toLowerCase() ?? 'absent'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function displayState (repoExists: boolean, container: ContainerSummary | undefined, dockerAvailable: boolean): string {
|
|
26
|
+
if (!repoExists) {
|
|
27
|
+
return 'missing'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return containerState(container, dockerAvailable)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createWorkspaceListEntries (
|
|
34
|
+
metadata: WorkspaceMetadata[],
|
|
35
|
+
containers: ContainerSummary[] | undefined,
|
|
36
|
+
exists: (path: string) => boolean
|
|
37
|
+
): WorkspaceListEntry[] {
|
|
38
|
+
const dockerAvailable = containers !== undefined
|
|
39
|
+
const containersByWorkspace = new Map(
|
|
40
|
+
(containers ?? [])
|
|
41
|
+
.filter((container) => container.localFolder !== undefined)
|
|
42
|
+
.map((container) => [container.localFolder as string, container])
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return metadata
|
|
46
|
+
.map((item) => {
|
|
47
|
+
const container = containersByWorkspace.get(item.workspaceFolder)
|
|
48
|
+
const repoExists = exists(item.workspaceFolder)
|
|
49
|
+
const state = displayState(repoExists, container, dockerAvailable)
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
...item,
|
|
53
|
+
repoExists,
|
|
54
|
+
state,
|
|
55
|
+
container: {
|
|
56
|
+
found: container !== undefined,
|
|
57
|
+
running: container?.state?.toLowerCase() === 'running',
|
|
58
|
+
id: container?.id,
|
|
59
|
+
name: container?.name,
|
|
60
|
+
state: container?.state ?? (dockerAvailable ? undefined : 'unknown'),
|
|
61
|
+
status: container?.status
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
.sort((a, b) => a.workspaceBasename.localeCompare(b.workspaceBasename) || a.workspaceFolder.localeCompare(b.workspaceFolder))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function pad (value: string, width: number): string {
|
|
69
|
+
return value.padEnd(width, ' ')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function containerLabel (entry: WorkspaceListEntry): string {
|
|
73
|
+
if (!entry.container.found) {
|
|
74
|
+
return '-'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return entry.container.name ?? entry.container.id ?? '-'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function formatWorkspaceListText (entries: WorkspaceListEntry[]): string {
|
|
81
|
+
if (entries.length === 0) {
|
|
82
|
+
return 'Boxdown list\n\nNo Boxdown workspaces found.\n'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const rows = entries.map((entry) => [
|
|
86
|
+
entry.state,
|
|
87
|
+
entry.workspaceBasename,
|
|
88
|
+
entry.workspaceFolder,
|
|
89
|
+
entry.sshAlias,
|
|
90
|
+
containerLabel(entry)
|
|
91
|
+
])
|
|
92
|
+
const headers = ['STATE', 'REPO', 'PATH', 'SSH ALIAS', 'CONTAINER']
|
|
93
|
+
const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0)))
|
|
94
|
+
const lines = [
|
|
95
|
+
'Boxdown list',
|
|
96
|
+
'',
|
|
97
|
+
headers.map((header, index) => pad(header, widths[index] ?? header.length)).join(' '),
|
|
98
|
+
rows.map((row) => row.map((value, index) => pad(value, widths[index] ?? value.length)).join(' ')).join('\n')
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
return `${lines.join('\n')}\n`
|
|
102
|
+
}
|