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
@@ -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
+ }