t44 0.4.0-rc.10

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 (127) hide show
  1. package/.dco-signatures +9 -0
  2. package/.github/workflows/dco.yaml +12 -0
  3. package/.github/workflows/gordian-open-integrity.yaml +13 -0
  4. package/.o/GordianOpenIntegrity-CurrentLifehash.svg +1026 -0
  5. package/.o/GordianOpenIntegrity-InceptionLifehash.svg +1026 -0
  6. package/.o/GordianOpenIntegrity.yaml +25 -0
  7. package/.o/assets/Hero-Terminal44-v0.jpeg +0 -0
  8. package/DCO.md +34 -0
  9. package/LICENSE.md +203 -0
  10. package/README.md +185 -0
  11. package/bin/activate +36 -0
  12. package/bin/activate.ts +30 -0
  13. package/bin/postinstall.sh +19 -0
  14. package/bin/shell +27 -0
  15. package/bin/t44 +27 -0
  16. package/caps/ConfigSchemaStruct.ts +55 -0
  17. package/caps/Home.ts +57 -0
  18. package/caps/HomeRegistry.ts +319 -0
  19. package/caps/HomeRegistryFile.ts +144 -0
  20. package/caps/JsonSchemas.ts +220 -0
  21. package/caps/OpenApiSchema.ts +67 -0
  22. package/caps/PackageDescriptor.ts +88 -0
  23. package/caps/ProjectCatalogs.ts +153 -0
  24. package/caps/ProjectDeployment.ts +363 -0
  25. package/caps/ProjectDevelopment.ts +257 -0
  26. package/caps/ProjectPublishing.ts +522 -0
  27. package/caps/ProjectRack.ts +155 -0
  28. package/caps/ProjectRepository.ts +322 -0
  29. package/caps/RootKey.ts +219 -0
  30. package/caps/SigningKey.ts +243 -0
  31. package/caps/WorkspaceCli.ts +442 -0
  32. package/caps/WorkspaceConfig.ts +268 -0
  33. package/caps/WorkspaceConfig.yaml +71 -0
  34. package/caps/WorkspaceConfigFile.ts +807 -0
  35. package/caps/WorkspaceConnection.ts +256 -0
  36. package/caps/WorkspaceEntityConfig.ts +78 -0
  37. package/caps/WorkspaceEntityConfig.v0.ts +77 -0
  38. package/caps/WorkspaceEntityFact.ts +218 -0
  39. package/caps/WorkspaceInfo.ts +595 -0
  40. package/caps/WorkspaceInit.ts +30 -0
  41. package/caps/WorkspaceKey.ts +338 -0
  42. package/caps/WorkspaceModel.ts +373 -0
  43. package/caps/WorkspaceProjects.ts +636 -0
  44. package/caps/WorkspacePrompt.ts +406 -0
  45. package/caps/WorkspaceShell.sh +39 -0
  46. package/caps/WorkspaceShell.ts +104 -0
  47. package/caps/WorkspaceShell.yaml +64 -0
  48. package/caps/WorkspaceShellCli.ts +109 -0
  49. package/caps/WorkspaceTest.ts +167 -0
  50. package/caps/providers/README.md +2 -0
  51. package/caps/providers/bunny.net/ProjectDeployment.ts +327 -0
  52. package/caps/providers/bunny.net/api-pull.test.ts +319 -0
  53. package/caps/providers/bunny.net/api-pull.ts +164 -0
  54. package/caps/providers/bunny.net/api-storage.test.ts +168 -0
  55. package/caps/providers/bunny.net/api-storage.ts +248 -0
  56. package/caps/providers/bunny.net/api.ts +95 -0
  57. package/caps/providers/dynadot.com/ProjectDeployment.ts +202 -0
  58. package/caps/providers/dynadot.com/api-domains.test.ts +224 -0
  59. package/caps/providers/dynadot.com/api-domains.ts +169 -0
  60. package/caps/providers/dynadot.com/api-restful-v1.test.ts +190 -0
  61. package/caps/providers/dynadot.com/api-restful-v1.ts +94 -0
  62. package/caps/providers/dynadot.com/api-restful-v2.test.ts +200 -0
  63. package/caps/providers/dynadot.com/api-restful-v2.ts +94 -0
  64. package/caps/providers/git-scm.com/ProjectPublishing.ts +654 -0
  65. package/caps/providers/github.com/ProjectPublishing.ts +133 -0
  66. package/caps/providers/github.com/api.ts +130 -0
  67. package/caps/providers/npmjs.com/ProjectPublishing.ts +536 -0
  68. package/caps/providers/semver.org/ProjectPublishing.ts +286 -0
  69. package/caps/providers/vercel.com/ProjectDeployment.ts +326 -0
  70. package/caps/providers/vercel.com/api.test.ts +67 -0
  71. package/caps/providers/vercel.com/api.ts +132 -0
  72. package/caps/providers/vercel.com/bun.lock +194 -0
  73. package/caps/providers/vercel.com/package.json +10 -0
  74. package/caps/providers/vercel.com/project.test.ts +108 -0
  75. package/caps/providers/vercel.com/project.ts +150 -0
  76. package/caps/providers/vercel.com/tsconfig.json +28 -0
  77. package/docs/Overview.drawio +248 -0
  78. package/docs/Overview.svg +4 -0
  79. package/examples/01-Lifecycle/main.test.ts +228 -0
  80. package/lib/crypto.ts +53 -0
  81. package/lib/key.ts +369 -0
  82. package/lib/schema-console-renderer.ts +181 -0
  83. package/lib/schema-resolver.ts +349 -0
  84. package/lib/ucan.ts +137 -0
  85. package/package.json +102 -0
  86. package/standalone-rt.ts +121 -0
  87. package/structs/HomeRegistry.ts +55 -0
  88. package/structs/HomeRegistryConfig.ts +60 -0
  89. package/structs/ProjectCatalogsConfig.ts +53 -0
  90. package/structs/ProjectDeploymentConfig.ts +56 -0
  91. package/structs/ProjectDeploymentFact.ts +106 -0
  92. package/structs/ProjectPublishingFact.ts +68 -0
  93. package/structs/ProjectRack.ts +51 -0
  94. package/structs/ProjectRackConfig.ts +56 -0
  95. package/structs/RepositoryOriginDescriptor.ts +51 -0
  96. package/structs/RootKeyConfig.ts +64 -0
  97. package/structs/SigningKeyConfig.ts +64 -0
  98. package/structs/Workspace.ts +56 -0
  99. package/structs/WorkspaceCatalogs.ts +56 -0
  100. package/structs/WorkspaceCliConfig.ts +53 -0
  101. package/structs/WorkspaceConfig.ts +64 -0
  102. package/structs/WorkspaceConfigFile.ts +50 -0
  103. package/structs/WorkspaceConfigFileMeta.ts +70 -0
  104. package/structs/WorkspaceKey.ts +55 -0
  105. package/structs/WorkspaceKeyConfig.ts +56 -0
  106. package/structs/WorkspaceMappingsConfig.ts +56 -0
  107. package/structs/WorkspaceProject.ts +104 -0
  108. package/structs/WorkspaceProjectsConfig.ts +67 -0
  109. package/structs/WorkspacePublishingConfig.ts +65 -0
  110. package/structs/WorkspaceShellConfig.ts +83 -0
  111. package/structs/providers/README.md +2 -0
  112. package/structs/providers/bunny.net/PullZoneFact.ts +55 -0
  113. package/structs/providers/bunny.net/PullZoneListFact.ts +55 -0
  114. package/structs/providers/bunny.net/StorageZoneFact.ts +55 -0
  115. package/structs/providers/bunny.net/StorageZoneListFact.ts +55 -0
  116. package/structs/providers/bunny.net/WorkspaceConnectionConfig.ts +43 -0
  117. package/structs/providers/dynadot.com/DomainFact.ts +46 -0
  118. package/structs/providers/dynadot.com/WorkspaceConnectionConfig.ts +54 -0
  119. package/structs/providers/git-scm.com/ProjectPublishingFact.ts +46 -0
  120. package/structs/providers/github.com/ProjectPublishingFact.ts +46 -0
  121. package/structs/providers/github.com/WorkspaceConnectionConfig.ts +43 -0
  122. package/structs/providers/npmjs.com/ProjectPublishingFact.ts +46 -0
  123. package/structs/providers/vercel.com/ProjectDeploymentFact.ts +55 -0
  124. package/structs/providers/vercel.com/WorkspaceConnectionConfig.ts +49 -0
  125. package/tsconfig.json +28 -0
  126. package/workspace-rt.ts +134 -0
  127. package/workspace.yaml +3 -0
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env bun test
2
+ // Set VERBOSE=1 to see stdout/stderr from spawned t44 commands
3
+
4
+ export const testConfig = {
5
+ group: 'lifecycle',
6
+ runOnAll: false,
7
+ }
8
+
9
+ import { join } from 'path'
10
+ import { mkdir, writeFile, rm, stat } from 'fs/promises'
11
+ import * as bunTest from 'bun:test'
12
+ import { run } from 't44/workspace-rt'
13
+
14
+ const {
15
+ test: { describe, it, expect, beforeAll },
16
+ } = await run(async ({ encapsulate, CapsulePropertyTypes, makeImportStack }: any) => {
17
+ const spine = await encapsulate({
18
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
19
+ '#@stream44.studio/encapsulate/structs/Capsule': {},
20
+ '#': {
21
+ test: {
22
+ type: CapsulePropertyTypes.Mapping,
23
+ value: 't44/caps/WorkspaceTest',
24
+ options: {
25
+ '#': {
26
+ bunTest,
27
+ }
28
+ }
29
+ },
30
+ }
31
+ }
32
+ }, {
33
+ importMeta: import.meta,
34
+ importStack: makeImportStack(),
35
+ capsuleName: 't44/examples/01-Lifecycle/main.test'
36
+ })
37
+ return { spine }
38
+ }, async ({ spine, apis }: any) => {
39
+ return apis[spine.capsuleSourceLineRef]
40
+ }, {
41
+ importMeta: import.meta
42
+ })
43
+
44
+ const testDir = join(import.meta.dir, '.~main.test')
45
+ const t44Bin = join(import.meta.dir, '../../bin/t44')
46
+
47
+ const homeDir = join(testDir, 'lifecycle', 'home')
48
+ const repoDir = join(testDir, 'lifecycle', 'repo')
49
+ const env = { ...process.env, T44_HOME_DIR: homeDir, T44_KEYS_PASSPHRASE: 't44-test' }
50
+
51
+ async function runT44(...args: string[]) {
52
+ const proc = Bun.spawn([t44Bin, ...args, '--yes'], {
53
+ env,
54
+ cwd: repoDir,
55
+ stdout: 'pipe',
56
+ stderr: 'pipe',
57
+ stdin: 'pipe',
58
+ })
59
+ proc.stdin.end()
60
+
61
+ const timeout = setTimeout(() => proc.kill(), 15_000)
62
+
63
+ const stdoutChunks: string[] = []
64
+ const stderrChunks: string[] = []
65
+
66
+ const verbose = !!process.env.VERBOSE
67
+
68
+ const stdoutReader = new WritableStream({
69
+ write(chunk) {
70
+ const text = new TextDecoder().decode(chunk)
71
+ stdoutChunks.push(text)
72
+ if (verbose) process.stdout.write(text)
73
+ }
74
+ })
75
+
76
+ const stderrReader = new WritableStream({
77
+ write(chunk) {
78
+ const text = new TextDecoder().decode(chunk)
79
+ stderrChunks.push(text)
80
+ if (verbose) process.stderr.write(text)
81
+ }
82
+ })
83
+
84
+ const [exitCode] = await Promise.all([
85
+ proc.exited,
86
+ proc.stdout.pipeTo(stdoutReader),
87
+ proc.stderr.pipeTo(stderrReader),
88
+ ])
89
+
90
+ clearTimeout(timeout)
91
+
92
+ return { exitCode, stdout: stdoutChunks.join(''), stderr: stderrChunks.join('') }
93
+ }
94
+
95
+ describe('t44 lifecycle', function () {
96
+
97
+ beforeAll(async () => {
98
+ await rm(join(testDir, 'lifecycle'), { recursive: true, force: true })
99
+ await mkdir(homeDir, { recursive: true })
100
+ await mkdir(join(homeDir, '.ssh'), { recursive: true })
101
+ await mkdir(join(repoDir, '.workspace'), { recursive: true })
102
+
103
+ const workspaceYaml = `extends:
104
+ - 't44/workspace.yaml'
105
+ `
106
+ await writeFile(join(repoDir, '.workspace', 'workspace.yaml'), workspaceYaml)
107
+ })
108
+
109
+ it('init --yes initializes workspace', async () => {
110
+ const { exitCode, stdout, stderr } = await runT44('info')
111
+
112
+ if (exitCode !== 0) {
113
+ console.log('STDOUT:', stdout)
114
+ console.log('STDERR:', stderr)
115
+ }
116
+
117
+ expect(exitCode).toBe(0)
118
+
119
+ // Verify registry was created
120
+ const registryDir = join(homeDir, '.o/workspace.foundation')
121
+ const registryStat = await stat(registryDir)
122
+ expect(registryStat.isDirectory()).toBe(true)
123
+
124
+ // Verify SSH keys were created
125
+ const sshDir = join(homeDir, '.ssh')
126
+ const rootKeyStat = await stat(join(sshDir, 'id_t44_ed25519'))
127
+ expect(rootKeyStat.isFile()).toBe(true)
128
+
129
+ const signingKeyStat = await stat(join(sshDir, 'id_t44_signing_ed25519'))
130
+ expect(signingKeyStat.isFile()).toBe(true)
131
+ }, 15_000)
132
+
133
+ it('activate — bin/activate.ts outputs shell exports', async () => {
134
+ const activateBin = join(import.meta.dir, '../../bin/activate.ts')
135
+ const proc = Bun.spawn([activateBin, '--yes'], {
136
+ env,
137
+ cwd: repoDir,
138
+ stdout: 'pipe',
139
+ stderr: 'pipe',
140
+ stdin: 'pipe',
141
+ })
142
+ proc.stdin.end()
143
+
144
+ const timeout = setTimeout(() => proc.kill(), 15_000)
145
+
146
+ const stdoutChunks: string[] = []
147
+ const stderrChunks: string[] = []
148
+
149
+ const verbose = !!process.env.VERBOSE
150
+
151
+ const stdoutReader = new WritableStream({
152
+ write(chunk) {
153
+ const text = new TextDecoder().decode(chunk)
154
+ stdoutChunks.push(text)
155
+ if (verbose) process.stdout.write(text)
156
+ }
157
+ })
158
+
159
+ const stderrReader = new WritableStream({
160
+ write(chunk) {
161
+ const text = new TextDecoder().decode(chunk)
162
+ stderrChunks.push(text)
163
+ if (verbose) process.stderr.write(text)
164
+ }
165
+ })
166
+
167
+ const [exitCode] = await Promise.all([
168
+ proc.exited,
169
+ proc.stdout.pipeTo(stdoutReader),
170
+ proc.stderr.pipeTo(stderrReader),
171
+ ])
172
+
173
+ clearTimeout(timeout)
174
+
175
+ const stdout = stdoutChunks.join('')
176
+ const stderr = stderrChunks.join('')
177
+
178
+ if (exitCode !== 0) {
179
+ console.log('STDOUT:', stdout)
180
+ console.log('STDERR:', stderr)
181
+ }
182
+
183
+ expect(exitCode).toBe(0)
184
+ expect(stdout.length).toBeGreaterThan(0)
185
+ expect(stdout).toContain('export ')
186
+ }, 15_000)
187
+
188
+ it('info — displays workspace information', async () => {
189
+ const { exitCode, stdout, stderr } = await runT44('info', '--full')
190
+
191
+ if (exitCode !== 0) {
192
+ console.log('STDOUT:', stdout)
193
+ console.log('STDERR:', stderr)
194
+ }
195
+
196
+ expect(exitCode).toBe(0)
197
+ expect(stdout).toContain('WORKSPACE INFORMATION')
198
+ expect(stdout).toContain('repo')
199
+ expect(stdout).toContain('did:key:')
200
+ expect(stdout).toContain('CONFIGURATION FILES')
201
+ }, 15_000)
202
+
203
+ it('activate — outputs shell export statements', async () => {
204
+ const { exitCode, stdout, stderr } = await runT44('activate')
205
+
206
+ if (exitCode !== 0) {
207
+ console.log('STDOUT:', stdout)
208
+ console.log('STDERR:', stderr)
209
+ }
210
+
211
+ expect(exitCode).toBe(0)
212
+ expect(stdout).toContain('export ')
213
+ expect(stdout).toContain('F_WORKSPACE_DIR')
214
+ }, 15_000)
215
+
216
+ it('query — displays workspace model', async () => {
217
+ const { exitCode, stdout, stderr } = await runT44('query', '--full')
218
+
219
+ if (exitCode !== 0) {
220
+ console.log('STDOUT:', stdout)
221
+ console.log('STDERR:', stderr)
222
+ }
223
+
224
+ expect(exitCode).toBe(0)
225
+ expect(stdout).toContain('WorkspaceConfig')
226
+ }, 15_000)
227
+
228
+ })
package/lib/crypto.ts ADDED
@@ -0,0 +1,53 @@
1
+
2
+ import * as crypto from 'crypto'
3
+
4
+ /**
5
+ * Encrypt a string using AES-256-GCM with a key derived from the private key
6
+ */
7
+ export function encryptString(plaintext: string, privateKeyBase64: string): string {
8
+ // Derive a 32-byte symmetric key from the private key using SHA-256
9
+ const keyBytes = Buffer.from(privateKeyBase64, 'base64')
10
+ const symmetricKey = crypto.createHash('sha256').update(keyBytes).digest()
11
+
12
+ // Generate a random IV
13
+ const iv = crypto.randomBytes(16)
14
+
15
+ // Encrypt using AES-256-GCM
16
+ const cipher = crypto.createCipheriv('aes-256-gcm', symmetricKey, iv)
17
+ const encrypted = Buffer.concat([
18
+ cipher.update(plaintext, 'utf8'),
19
+ cipher.final()
20
+ ])
21
+ const authTag = cipher.getAuthTag()
22
+
23
+ // Combine IV + authTag + encrypted data and encode as base64
24
+ const combined = Buffer.concat([iv, authTag, encrypted])
25
+ return combined.toString('base64')
26
+ }
27
+
28
+ /**
29
+ * Decrypt a string using AES-256-GCM with a key derived from the private key
30
+ */
31
+ export function decryptString(ciphertext: string, privateKeyBase64: string): string {
32
+ // Derive the same symmetric key from the private key
33
+ const keyBytes = Buffer.from(privateKeyBase64, 'base64')
34
+ const symmetricKey = crypto.createHash('sha256').update(keyBytes).digest()
35
+
36
+ // Decode the combined data
37
+ const combined = Buffer.from(ciphertext, 'base64')
38
+
39
+ // Extract IV (16 bytes), authTag (16 bytes), and encrypted data
40
+ const iv = combined.subarray(0, 16)
41
+ const authTag = combined.subarray(16, 32)
42
+ const encrypted = combined.subarray(32)
43
+
44
+ // Decrypt using AES-256-GCM
45
+ const decipher = crypto.createDecipheriv('aes-256-gcm', symmetricKey, iv)
46
+ decipher.setAuthTag(authTag)
47
+ const decrypted = Buffer.concat([
48
+ decipher.update(encrypted),
49
+ decipher.final()
50
+ ])
51
+
52
+ return decrypted.toString('utf8')
53
+ }
package/lib/key.ts ADDED
@@ -0,0 +1,369 @@
1
+
2
+ import { execSync } from 'child_process'
3
+ import { readdir, readFile, stat } from 'fs/promises'
4
+ import { join } from 'path'
5
+
6
+ export interface Ed25519KeyInfo {
7
+ name: string
8
+ privateKeyPath: string
9
+ publicKey: string
10
+ }
11
+
12
+ export interface KeyConfig {
13
+ name: string
14
+ privateKeyPath: string
15
+ publicKey: string
16
+ keyFingerprint: string
17
+ }
18
+
19
+ /**
20
+ * Extract the SHA256 fingerprint from ssh-keygen -lf output.
21
+ * Output format: "256 SHA256:xxx comment (ED25519)"
22
+ */
23
+ export function extractFingerprint(sshKeygenOutput: string): string {
24
+ const match = sshKeygenOutput.match(/(SHA256:\S+)/)
25
+ return match ? match[1] : sshKeygenOutput
26
+ }
27
+
28
+ /**
29
+ * Compute the SHA256 fingerprint of a key file.
30
+ */
31
+ export function computeFingerprint(privateKeyPath: string): string {
32
+ return extractFingerprint(execSync(
33
+ `ssh-keygen -lf ${JSON.stringify(privateKeyPath)}`,
34
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
35
+ ).trim())
36
+ }
37
+
38
+ /**
39
+ * Check whether an Ed25519 private key has a passphrase set.
40
+ */
41
+ export function hasPassphrase(privateKeyPath: string): boolean {
42
+ try {
43
+ execSync(
44
+ `ssh-keygen -y -P "" -f ${JSON.stringify(privateKeyPath)}`,
45
+ { stdio: 'pipe' }
46
+ )
47
+ return false // empty passphrase worked → no passphrase
48
+ } catch {
49
+ return true // empty passphrase failed → has passphrase
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Discover existing Ed25519 keys in the given SSH directory.
55
+ */
56
+ export async function discoverEd25519Keys(sshDir: string): Promise<Ed25519KeyInfo[]> {
57
+ const dir = sshDir
58
+ const keys: Ed25519KeyInfo[] = []
59
+
60
+ let entries: string[]
61
+ try {
62
+ entries = await readdir(dir)
63
+ } catch {
64
+ return keys
65
+ }
66
+
67
+ const pubFiles = entries.filter(f => f.endsWith('.pub'))
68
+
69
+ for (const pubFile of pubFiles) {
70
+ try {
71
+ const pubPath = join(dir, pubFile)
72
+ const content = await readFile(pubPath, 'utf-8')
73
+ const trimmed = content.trim()
74
+
75
+ if (!trimmed.startsWith('ssh-ed25519 ')) {
76
+ continue
77
+ }
78
+
79
+ const privateName = pubFile.replace(/\.pub$/, '')
80
+ const privatePath = join(dir, privateName)
81
+
82
+ try {
83
+ await stat(privatePath)
84
+ } catch {
85
+ continue
86
+ }
87
+
88
+ keys.push({
89
+ name: privateName,
90
+ privateKeyPath: privatePath,
91
+ publicKey: trimmed
92
+ })
93
+ } catch {
94
+ // Skip unreadable files
95
+ }
96
+ }
97
+
98
+ return keys
99
+ }
100
+
101
+ /**
102
+ * Check if a key file exists at the given path.
103
+ */
104
+ export async function keyFileExists(privateKeyPath: string): Promise<boolean> {
105
+ try {
106
+ const s = await stat(privateKeyPath)
107
+ return s.isFile()
108
+ } catch {
109
+ return false
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Check if the key is loaded in the ssh-agent by fingerprint.
115
+ */
116
+ export function isKeyInAgent(privateKeyPath: string): boolean {
117
+ let keyFingerprint: string
118
+ try {
119
+ const fpOutput = execSync(
120
+ `ssh-keygen -lf ${JSON.stringify(privateKeyPath)}`,
121
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
122
+ ).trim()
123
+ const match = fpOutput.match(/(SHA256:\S+)/)
124
+ keyFingerprint = match ? match[1] : ''
125
+ } catch {
126
+ return false
127
+ }
128
+
129
+ if (!keyFingerprint) return false
130
+
131
+ try {
132
+ const agentKeys = execSync('ssh-add -l', {
133
+ encoding: 'utf-8',
134
+ stdio: ['pipe', 'pipe', 'pipe']
135
+ })
136
+ return agentKeys.includes(keyFingerprint)
137
+ } catch {
138
+ return false
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Add a key to the macOS ssh-agent with Keychain storage.
144
+ * Returns true if added successfully, false otherwise.
145
+ */
146
+ export function addKeyToAgent(privateKeyPath: string): boolean {
147
+ try {
148
+ execSync(
149
+ `ssh-add --apple-use-keychain ${JSON.stringify(privateKeyPath)}`,
150
+ { stdio: 'inherit' }
151
+ )
152
+ return true
153
+ } catch {
154
+ return false
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Ensure a key is loaded in the ssh-agent. Adds it if not already present.
160
+ * Logs status messages using chalk.
161
+ */
162
+ export async function ensureKeyInAgent(privateKeyPath: string, keyName: string, keyLabel: string): Promise<void> {
163
+ const chalk = (await import('chalk')).default
164
+
165
+ if (isKeyInAgent(privateKeyPath)) {
166
+ return
167
+ }
168
+
169
+ console.log(chalk.gray(`\n Adding ${keyLabel} '${keyName}' to ssh-agent (macOS Keychain) ...`))
170
+ if (addKeyToAgent(privateKeyPath)) {
171
+ console.log(chalk.green(` ✓ ${keyLabel} added to ssh-agent`))
172
+ console.log(chalk.gray(` Passphrase stored in macOS Keychain.\n`))
173
+ } else {
174
+ console.log(chalk.yellow(`\n ⚠ Could not add key to ssh-agent`))
175
+ console.log(chalk.yellow(` You may need to enter the passphrase manually when the key is used.\n`))
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Prompt for a passphrase using inquirer (password input with confirmation).
181
+ */
182
+ export async function promptPassphrase(): Promise<string | null> {
183
+ const inquirer = await import('inquirer')
184
+ const chalk = (await import('chalk')).default
185
+
186
+ try {
187
+ const { passphrase } = await inquirer.default.prompt([
188
+ {
189
+ type: 'password',
190
+ name: 'passphrase',
191
+ message: 'Enter a passphrase for the key:',
192
+ mask: '*',
193
+ validate: (input: string) => {
194
+ if (!input || input.length === 0) {
195
+ return 'Passphrase cannot be empty'
196
+ }
197
+ if (input.length < 5) {
198
+ return 'Passphrase must be at least 5 characters'
199
+ }
200
+ return true
201
+ }
202
+ }
203
+ ])
204
+
205
+ const { confirm } = await inquirer.default.prompt([
206
+ {
207
+ type: 'password',
208
+ name: 'confirm',
209
+ message: 'Confirm passphrase:',
210
+ mask: '*',
211
+ validate: (input: string) => {
212
+ if (input !== passphrase) {
213
+ return 'Passphrases do not match'
214
+ }
215
+ return true
216
+ }
217
+ }
218
+ ])
219
+
220
+ return passphrase
221
+ } catch (error: any) {
222
+ if (error.message?.includes('SIGINT') || error.message?.includes('force closed')) {
223
+ console.log(chalk.red('\n\nABORTED\n'))
224
+ process.exit(0)
225
+ }
226
+ throw error
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Ensure a key has a passphrase. If not, prompt the user to set one.
232
+ */
233
+ export async function ensurePassphrase(privateKeyPath: string, keyName: string, keyLabel: string): Promise<boolean> {
234
+ const chalk = (await import('chalk')).default
235
+
236
+ if (hasPassphrase(privateKeyPath)) {
237
+ return true
238
+ }
239
+
240
+ console.log(chalk.yellow(`\n ⚠ ${keyLabel} '${keyName}' has no passphrase`))
241
+ console.log(chalk.gray(` A passphrase is required to protect the key. The passphrase will be stored`))
242
+ console.log(chalk.gray(` in the macOS Keychain so you won't need to enter it again.\n`))
243
+
244
+ const passphrase = await promptPassphrase()
245
+ if (!passphrase) {
246
+ console.log(chalk.red(`\n ✗ A passphrase is required for the ${keyLabel.toLowerCase()}.\n`))
247
+ return false
248
+ }
249
+
250
+ console.log(chalk.gray(`\n Setting passphrase on ${privateKeyPath} ...`))
251
+ try {
252
+ execSync(
253
+ `ssh-keygen -p -f ${JSON.stringify(privateKeyPath)} -P "" -N ${JSON.stringify(passphrase)}`,
254
+ { stdio: 'pipe' }
255
+ )
256
+ } catch (error: any) {
257
+ console.log(chalk.red(`\n ✗ Failed to set passphrase: ${error.message}\n`))
258
+ return false
259
+ }
260
+
261
+ console.log(chalk.green(` ✓ Passphrase set on ${keyLabel.toLowerCase()} '${keyName}'\n`))
262
+ return true
263
+ }
264
+
265
+ /**
266
+ * Generate a new Ed25519 SSH key with a passphrase.
267
+ * Returns the key info or null on failure.
268
+ */
269
+ export async function generateEd25519Key(
270
+ privateKeyPath: string,
271
+ passphrase: string,
272
+ comment: string
273
+ ): Promise<{ publicKey: string; keyFingerprint: string } | null> {
274
+ const chalk = (await import('chalk')).default
275
+ const { mkdir } = await import('fs/promises')
276
+ const { dirname } = await import('path')
277
+
278
+ await mkdir(dirname(privateKeyPath), { recursive: true })
279
+
280
+ try {
281
+ execSync(
282
+ `ssh-keygen -t ed25519 -f ${JSON.stringify(privateKeyPath)} -N ${JSON.stringify(passphrase)} -C ${JSON.stringify(comment)}`,
283
+ { stdio: 'pipe' }
284
+ )
285
+ } catch (error: any) {
286
+ console.log(chalk.red(`\n ✗ Failed to generate key: ${error.message}\n`))
287
+ return null
288
+ }
289
+
290
+ const pubKeyContent = await readFile(privateKeyPath + '.pub', 'utf-8')
291
+ const publicKey = pubKeyContent.trim()
292
+ const keyFingerprint = computeFingerprint(privateKeyPath)
293
+
294
+ return { publicKey, keyFingerprint }
295
+ }
296
+
297
+ /**
298
+ * Validate that a configured key exists and its fingerprint matches.
299
+ * Returns true if valid, false otherwise (with error messages logged).
300
+ */
301
+ export async function validateConfiguredKey(
302
+ keyConfig: KeyConfig,
303
+ keyLabel: string
304
+ ): Promise<boolean> {
305
+ const chalk = (await import('chalk')).default
306
+
307
+ const exists = await keyFileExists(keyConfig.privateKeyPath)
308
+ if (!exists) {
309
+ console.log(chalk.red(`\n┌─────────────────────────────────────────────────────────────────┐`))
310
+ console.log(chalk.red(`│ ✗ ${keyLabel} Error${' '.repeat(Math.max(0, 56 - keyLabel.length))}│`))
311
+ console.log(chalk.red(`├─────────────────────────────────────────────────────────────────┤`))
312
+ console.log(chalk.red(`│ The configured private key file is missing: │`))
313
+ console.log(chalk.red(`│ ${keyConfig.privateKeyPath}`))
314
+ console.log(chalk.red(`│ │`))
315
+ console.log(chalk.red(`│ The private key '${keyConfig.name}' has been removed or moved.`))
316
+ console.log(chalk.red(`│ Please restore the key file to the path above to proceed. │`))
317
+ console.log(chalk.red(`└─────────────────────────────────────────────────────────────────┘\n`))
318
+ return false
319
+ }
320
+
321
+ let currentFingerprint: string
322
+ try {
323
+ currentFingerprint = computeFingerprint(keyConfig.privateKeyPath)
324
+ } catch (error: any) {
325
+ console.log(chalk.red(`\n┌─────────────────────────────────────────────────────────────────┐`))
326
+ console.log(chalk.red(`│ ✗ ${keyLabel} Error${' '.repeat(Math.max(0, 56 - keyLabel.length))}│`))
327
+ console.log(chalk.red(`├─────────────────────────────────────────────────────────────────┤`))
328
+ console.log(chalk.red(`│ Failed to compute fingerprint for the private key at: │`))
329
+ console.log(chalk.red(`│ ${keyConfig.privateKeyPath}`))
330
+ console.log(chalk.red(`│ │`))
331
+ console.log(chalk.red(`│ The key file may be corrupted. Please restore the original │`))
332
+ console.log(chalk.red(`│ key file to proceed. │`))
333
+ console.log(chalk.red(`└─────────────────────────────────────────────────────────────────┘\n`))
334
+ return false
335
+ }
336
+
337
+ if (currentFingerprint !== keyConfig.keyFingerprint) {
338
+ console.log(chalk.red(`\n┌─────────────────────────────────────────────────────────────────┐`))
339
+ console.log(chalk.red(`│ ✗ ${keyLabel} Mismatch${' '.repeat(Math.max(0, 53 - keyLabel.length))}│`))
340
+ console.log(chalk.red(`├─────────────────────────────────────────────────────────────────┤`))
341
+ console.log(chalk.red(`│ The private key at the configured path does not match the │`))
342
+ console.log(chalk.red(`│ fingerprint stored in the workspace config. │`))
343
+ console.log(chalk.red(`│ │`))
344
+ console.log(chalk.red(`│ Key name: ${keyConfig.name}`))
345
+ console.log(chalk.red(`│ Path: ${keyConfig.privateKeyPath}`))
346
+ console.log(chalk.red(`│ │`))
347
+ console.log(chalk.red(`│ The private key has changed. Please restore the original │`))
348
+ console.log(chalk.red(`│ private key that matches the configured fingerprint to proceed.│`))
349
+ console.log(chalk.red(`│ │`))
350
+ console.log(chalk.red(`│ Expected fingerprint: │`))
351
+ console.log(chalk.red(`│ ${keyConfig.keyFingerprint}`))
352
+ console.log(chalk.red(`│ │`))
353
+ console.log(chalk.red(`│ Current fingerprint: │`))
354
+ console.log(chalk.red(`│ ${currentFingerprint}`))
355
+ console.log(chalk.red(`└─────────────────────────────────────────────────────────────────┘\n`))
356
+ return false
357
+ }
358
+
359
+ if (!process.env.T44_KEYS_PASSPHRASE) {
360
+ const passphraseOk = await ensurePassphrase(keyConfig.privateKeyPath, keyConfig.name, keyLabel)
361
+ if (!passphraseOk) {
362
+ return false
363
+ }
364
+
365
+ await ensureKeyInAgent(keyConfig.privateKeyPath, keyConfig.name, keyLabel)
366
+ }
367
+
368
+ return true
369
+ }