@stream44.studio/t44 0.4.0-rc.24

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 (99) 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/.github/workflows/test.yaml +31 -0
  5. package/.o/GordianOpenIntegrity-CurrentLifehash.svg +1026 -0
  6. package/.o/GordianOpenIntegrity-InceptionLifehash.svg +1026 -0
  7. package/.o/GordianOpenIntegrity.yaml +21 -0
  8. package/.o/assets/Hero-Terminal44-v0.jpeg +0 -0
  9. package/.o/stream44.studio/assets/Icon-v1.svg +1170 -0
  10. package/.repo-identifier +1 -0
  11. package/DCO.md +34 -0
  12. package/LICENSE.txt +186 -0
  13. package/README.md +189 -0
  14. package/bin/activate +36 -0
  15. package/bin/activate.ts +30 -0
  16. package/bin/postinstall.sh +19 -0
  17. package/bin/shell +27 -0
  18. package/bin/t44 +27 -0
  19. package/caps/ConfigSchemaStruct.ts +55 -0
  20. package/caps/Home.ts +57 -0
  21. package/caps/HomeRegistry.ts +319 -0
  22. package/caps/HomeRegistryFile.ts +144 -0
  23. package/caps/JsonSchemas.ts +220 -0
  24. package/caps/OpenApiSchema.ts +67 -0
  25. package/caps/PackageDescriptor.ts +88 -0
  26. package/caps/ProjectCatalogs.ts +153 -0
  27. package/caps/ProjectDeployment.ts +426 -0
  28. package/caps/ProjectDevelopment.ts +257 -0
  29. package/caps/ProjectPublishing.ts +654 -0
  30. package/caps/ProjectPulling.ts +234 -0
  31. package/caps/ProjectRack.ts +155 -0
  32. package/caps/ProjectRepository.ts +332 -0
  33. package/caps/ProjectTest.ts +251 -0
  34. package/caps/ProjectTestLib.ts +257 -0
  35. package/caps/RootKey.ts +219 -0
  36. package/caps/SigningKey.ts +243 -0
  37. package/caps/TaskWorkflow.ts +192 -0
  38. package/caps/WorkspaceCli.ts +448 -0
  39. package/caps/WorkspaceConfig.ts +268 -0
  40. package/caps/WorkspaceConfig.yaml +87 -0
  41. package/caps/WorkspaceConfigFile.ts +902 -0
  42. package/caps/WorkspaceConnection.ts +329 -0
  43. package/caps/WorkspaceEntityConfig.ts +78 -0
  44. package/caps/WorkspaceEntityConfig.v0.ts +77 -0
  45. package/caps/WorkspaceEntityFact.ts +218 -0
  46. package/caps/WorkspaceInfo.ts +619 -0
  47. package/caps/WorkspaceInit.ts +30 -0
  48. package/caps/WorkspaceKey.ts +338 -0
  49. package/caps/WorkspaceModel.ts +373 -0
  50. package/caps/WorkspaceProjects.ts +636 -0
  51. package/caps/WorkspacePrompt.ts +430 -0
  52. package/caps/WorkspaceShell.sh +39 -0
  53. package/caps/WorkspaceShell.ts +104 -0
  54. package/caps/WorkspaceShell.yaml +64 -0
  55. package/caps/WorkspaceShellCli.ts +109 -0
  56. package/caps/patterns/README.md +2 -0
  57. package/caps/patterns/git-scm.com/ProjectPublishing.ts +507 -0
  58. package/caps/patterns/semver.org/ProjectPublishing.ts +458 -0
  59. package/docs/Overview.drawio +248 -0
  60. package/docs/Overview.svg +4 -0
  61. package/examples/01-Lifecycle/main.test.ts +223 -0
  62. package/lib/crypto.ts +53 -0
  63. package/lib/key.ts +381 -0
  64. package/lib/schema-console-renderer.ts +181 -0
  65. package/lib/schema-resolver.ts +349 -0
  66. package/lib/ucan.ts +137 -0
  67. package/package.json +91 -0
  68. package/standalone-rt.test.ts +150 -0
  69. package/standalone-rt.ts +140 -0
  70. package/structs/HomeRegistry.ts +55 -0
  71. package/structs/HomeRegistryConfig.ts +60 -0
  72. package/structs/ProjectCatalogsConfig.ts +53 -0
  73. package/structs/ProjectDeploymentConfig.ts +56 -0
  74. package/structs/ProjectDeploymentFact.ts +106 -0
  75. package/structs/ProjectPublishingConfig.ts +78 -0
  76. package/structs/ProjectPublishingFact.ts +68 -0
  77. package/structs/ProjectPullingConfig.ts +52 -0
  78. package/structs/ProjectRack.ts +51 -0
  79. package/structs/ProjectRackConfig.ts +56 -0
  80. package/structs/RepositoryOriginDescriptor.ts +51 -0
  81. package/structs/RootKeyConfig.ts +64 -0
  82. package/structs/SigningKeyConfig.ts +64 -0
  83. package/structs/Workspace.ts +56 -0
  84. package/structs/WorkspaceCatalogs.ts +56 -0
  85. package/structs/WorkspaceCliConfig.ts +53 -0
  86. package/structs/WorkspaceConfig.ts +64 -0
  87. package/structs/WorkspaceConfigFile.ts +50 -0
  88. package/structs/WorkspaceConfigFileMeta.ts +70 -0
  89. package/structs/WorkspaceKey.ts +55 -0
  90. package/structs/WorkspaceKeyConfig.ts +56 -0
  91. package/structs/WorkspaceMappingsConfig.ts +56 -0
  92. package/structs/WorkspaceProject.ts +104 -0
  93. package/structs/WorkspaceProjectsConfig.ts +67 -0
  94. package/structs/WorkspaceShellConfig.ts +83 -0
  95. package/structs/patterns/README.md +2 -0
  96. package/structs/patterns/git-scm.com/ProjectPublishingFact.ts +46 -0
  97. package/tsconfig.json +33 -0
  98. package/workspace-rt.ts +152 -0
  99. package/workspace.yaml +3 -0
@@ -0,0 +1,223 @@
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 '@stream44.studio/t44/workspace-rt'
13
+
14
+ const {
15
+ test: { describe, it, expect, beforeAll, workbenchDir },
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: '@stream44.studio/t44/caps/ProjectTest',
24
+ options: {
25
+ '#': {
26
+ bunTest,
27
+ }
28
+ }
29
+ },
30
+ }
31
+ }
32
+ }, {
33
+ importMeta: import.meta,
34
+ importStack: makeImportStack(),
35
+ capsuleName: '@stream44.studio/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 t44Bin = join(import.meta.dir, '../../bin/t44')
45
+ const bunExe = Bun.which('bun')
46
+
47
+ const homeDir = join(workbenchDir, 'lifecycle', 'home')
48
+ const repoDir = join(workbenchDir, '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([bunExe!, 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(workbenchDir, 'lifecycle'), { recursive: true, force: true })
99
+ await mkdir(homeDir, { recursive: true })
100
+ await mkdir(join(homeDir, '.ssh'), { recursive: true })
101
+ await mkdir(repoDir, { recursive: true })
102
+ // Note: .workspace/workspace.yaml is now created automatically by t44 init
103
+ })
104
+
105
+ it('init --yes initializes workspace', async () => {
106
+ const { exitCode, stdout, stderr } = await runT44('init')
107
+
108
+ console.log('STDOUT:', stdout)
109
+ console.log('STDERR:', stderr)
110
+ console.log('EXIT CODE:', exitCode)
111
+
112
+ expect(exitCode).toBe(0)
113
+
114
+ // Verify registry was created
115
+ const registryDir = join(homeDir, '.o/workspace.foundation')
116
+ const registryStat = await stat(registryDir)
117
+ expect(registryStat.isDirectory()).toBe(true)
118
+
119
+ // Verify SSH keys were created
120
+ const sshDir = join(homeDir, '.ssh')
121
+ const rootKeyStat = await stat(join(sshDir, 'id_t44_ed25519'))
122
+ expect(rootKeyStat.isFile()).toBe(true)
123
+
124
+ const signingKeyStat = await stat(join(sshDir, 'id_t44_signing_ed25519'))
125
+ expect(signingKeyStat.isFile()).toBe(true)
126
+ }, 15_000)
127
+
128
+ it('activate — bin/activate.ts outputs shell exports', async () => {
129
+ const activateBin = join(import.meta.dir, '../../bin/activate.ts')
130
+ const proc = Bun.spawn([bunExe!, activateBin, '--yes'], {
131
+ env,
132
+ cwd: repoDir,
133
+ stdout: 'pipe',
134
+ stderr: 'pipe',
135
+ stdin: 'pipe',
136
+ })
137
+ proc.stdin.end()
138
+
139
+ const timeout = setTimeout(() => proc.kill(), 15_000)
140
+
141
+ const stdoutChunks: string[] = []
142
+ const stderrChunks: string[] = []
143
+
144
+ const verbose = !!process.env.VERBOSE
145
+
146
+ const stdoutReader = new WritableStream({
147
+ write(chunk) {
148
+ const text = new TextDecoder().decode(chunk)
149
+ stdoutChunks.push(text)
150
+ if (verbose) process.stdout.write(text)
151
+ }
152
+ })
153
+
154
+ const stderrReader = new WritableStream({
155
+ write(chunk) {
156
+ const text = new TextDecoder().decode(chunk)
157
+ stderrChunks.push(text)
158
+ if (verbose) process.stderr.write(text)
159
+ }
160
+ })
161
+
162
+ const [exitCode] = await Promise.all([
163
+ proc.exited,
164
+ proc.stdout.pipeTo(stdoutReader),
165
+ proc.stderr.pipeTo(stderrReader),
166
+ ])
167
+
168
+ clearTimeout(timeout)
169
+
170
+ const stdout = stdoutChunks.join('')
171
+ const stderr = stderrChunks.join('')
172
+
173
+ if (exitCode !== 0) {
174
+ console.log('STDOUT:', stdout)
175
+ console.log('STDERR:', stderr)
176
+ }
177
+
178
+ expect(exitCode).toBe(0)
179
+ expect(stdout.length).toBeGreaterThan(0)
180
+ expect(stdout).toContain('export ')
181
+ }, 15_000)
182
+
183
+ it('info — displays workspace information', async () => {
184
+ const { exitCode, stdout, stderr } = await runT44('info', '--full')
185
+
186
+ if (exitCode !== 0) {
187
+ console.log('STDOUT:', stdout)
188
+ console.log('STDERR:', stderr)
189
+ }
190
+
191
+ expect(exitCode).toBe(0)
192
+ expect(stdout).toContain('WORKSPACE INFORMATION')
193
+ expect(stdout).toContain('repo')
194
+ expect(stdout).toContain('did:key:')
195
+ expect(stdout).toContain('CONFIGURATION FILES')
196
+ }, 15_000)
197
+
198
+ it('activate — outputs shell export statements', async () => {
199
+ const { exitCode, stdout, stderr } = await runT44('activate')
200
+
201
+ if (exitCode !== 0) {
202
+ console.log('STDOUT:', stdout)
203
+ console.log('STDERR:', stderr)
204
+ }
205
+
206
+ expect(exitCode).toBe(0)
207
+ expect(stdout).toContain('export ')
208
+ expect(stdout).toContain('F_WORKSPACE_DIR')
209
+ }, 15_000)
210
+
211
+ it('query — displays workspace model', async () => {
212
+ const { exitCode, stdout, stderr } = await runT44('query', '--full')
213
+
214
+ if (exitCode !== 0) {
215
+ console.log('STDOUT:', stdout)
216
+ console.log('STDERR:', stderr)
217
+ }
218
+
219
+ expect(exitCode).toBe(0)
220
+ expect(stdout).toContain('WorkspaceConfig')
221
+ }, 15_000)
222
+
223
+ })
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,381 @@
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
+ * Skips adding to agent in non-TTY mode (e.g., CI environments).
162
+ */
163
+ export async function ensureKeyInAgent(privateKeyPath: string, keyName: string, keyLabel: string): Promise<void> {
164
+ const chalk = (await import('chalk')).default
165
+
166
+ // Skip ssh-agent operations in non-TTY mode (CI, scripts, etc.)
167
+ if (!process.stdin.isTTY || process.env.CI) {
168
+ return
169
+ }
170
+
171
+ if (isKeyInAgent(privateKeyPath)) {
172
+ return
173
+ }
174
+
175
+ console.log(chalk.gray(`\n Adding ${keyLabel} '${keyName}' to ssh-agent (macOS Keychain) ...`))
176
+ if (addKeyToAgent(privateKeyPath)) {
177
+ console.log(chalk.green(` ✓ ${keyLabel} added to ssh-agent`))
178
+ console.log(chalk.gray(` Passphrase stored in macOS Keychain.\n`))
179
+ } else {
180
+ console.log(chalk.yellow(`\n ⚠ Could not add key to ssh-agent`))
181
+ console.log(chalk.yellow(` You may need to enter the passphrase manually when the key is used.\n`))
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Prompt for a passphrase using inquirer (password input with confirmation).
187
+ * Returns null in non-TTY mode (CI environments).
188
+ */
189
+ export async function promptPassphrase(): Promise<string | null> {
190
+ // Skip prompting in non-TTY mode (CI, scripts, etc.)
191
+ if (!process.stdin.isTTY || process.env.CI) {
192
+ return null
193
+ }
194
+
195
+ const inquirer = await import('inquirer')
196
+ const chalk = (await import('chalk')).default
197
+
198
+ try {
199
+ const { passphrase } = await inquirer.default.prompt([
200
+ {
201
+ type: 'password',
202
+ name: 'passphrase',
203
+ message: 'Enter a passphrase for the key:',
204
+ mask: '*',
205
+ validate: (input: string) => {
206
+ if (!input || input.length === 0) {
207
+ return 'Passphrase cannot be empty'
208
+ }
209
+ if (input.length < 5) {
210
+ return 'Passphrase must be at least 5 characters'
211
+ }
212
+ return true
213
+ }
214
+ }
215
+ ])
216
+
217
+ const { confirm } = await inquirer.default.prompt([
218
+ {
219
+ type: 'password',
220
+ name: 'confirm',
221
+ message: 'Confirm passphrase:',
222
+ mask: '*',
223
+ validate: (input: string) => {
224
+ if (input !== passphrase) {
225
+ return 'Passphrases do not match'
226
+ }
227
+ return true
228
+ }
229
+ }
230
+ ])
231
+
232
+ return passphrase
233
+ } catch (error: any) {
234
+ if (error.message?.includes('SIGINT') || error.message?.includes('force closed')) {
235
+ console.log(chalk.red('\n\nABORTED\n'))
236
+ process.exit(0)
237
+ }
238
+ throw error
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Ensure a key has a passphrase. If not, prompt the user to set one.
244
+ */
245
+ export async function ensurePassphrase(privateKeyPath: string, keyName: string, keyLabel: string): Promise<boolean> {
246
+ const chalk = (await import('chalk')).default
247
+
248
+ if (hasPassphrase(privateKeyPath)) {
249
+ return true
250
+ }
251
+
252
+ console.log(chalk.yellow(`\n ⚠ ${keyLabel} '${keyName}' has no passphrase`))
253
+ console.log(chalk.gray(` A passphrase is required to protect the key. The passphrase will be stored`))
254
+ console.log(chalk.gray(` in the macOS Keychain so you won't need to enter it again.\n`))
255
+
256
+ const passphrase = await promptPassphrase()
257
+ if (!passphrase) {
258
+ console.log(chalk.red(`\n ✗ A passphrase is required for the ${keyLabel.toLowerCase()}.\n`))
259
+ return false
260
+ }
261
+
262
+ console.log(chalk.gray(`\n Setting passphrase on ${privateKeyPath} ...`))
263
+ try {
264
+ execSync(
265
+ `ssh-keygen -p -f ${JSON.stringify(privateKeyPath)} -P "" -N ${JSON.stringify(passphrase)}`,
266
+ { stdio: 'pipe' }
267
+ )
268
+ } catch (error: any) {
269
+ console.log(chalk.red(`\n ✗ Failed to set passphrase: ${error.message}\n`))
270
+ return false
271
+ }
272
+
273
+ console.log(chalk.green(` ✓ Passphrase set on ${keyLabel.toLowerCase()} '${keyName}'\n`))
274
+ return true
275
+ }
276
+
277
+ /**
278
+ * Generate a new Ed25519 SSH key with a passphrase.
279
+ * Returns the key info or null on failure.
280
+ */
281
+ export async function generateEd25519Key(
282
+ privateKeyPath: string,
283
+ passphrase: string,
284
+ comment: string
285
+ ): Promise<{ publicKey: string; keyFingerprint: string } | null> {
286
+ const chalk = (await import('chalk')).default
287
+ const { mkdir } = await import('fs/promises')
288
+ const { dirname } = await import('path')
289
+
290
+ await mkdir(dirname(privateKeyPath), { recursive: true })
291
+
292
+ try {
293
+ execSync(
294
+ `ssh-keygen -t ed25519 -f ${JSON.stringify(privateKeyPath)} -N ${JSON.stringify(passphrase)} -C ${JSON.stringify(comment)}`,
295
+ { stdio: 'pipe' }
296
+ )
297
+ } catch (error: any) {
298
+ console.log(chalk.red(`\n ✗ Failed to generate key: ${error.message}\n`))
299
+ return null
300
+ }
301
+
302
+ const pubKeyContent = await readFile(privateKeyPath + '.pub', 'utf-8')
303
+ const publicKey = pubKeyContent.trim()
304
+ const keyFingerprint = computeFingerprint(privateKeyPath)
305
+
306
+ return { publicKey, keyFingerprint }
307
+ }
308
+
309
+ /**
310
+ * Validate that a configured key exists and its fingerprint matches.
311
+ * Returns true if valid, false otherwise (with error messages logged).
312
+ */
313
+ export async function validateConfiguredKey(
314
+ keyConfig: KeyConfig,
315
+ keyLabel: string
316
+ ): Promise<boolean> {
317
+ const chalk = (await import('chalk')).default
318
+
319
+ const exists = await keyFileExists(keyConfig.privateKeyPath)
320
+ if (!exists) {
321
+ console.log(chalk.red(`\n┌─────────────────────────────────────────────────────────────────┐`))
322
+ console.log(chalk.red(`│ ✗ ${keyLabel} Error${' '.repeat(Math.max(0, 56 - keyLabel.length))}│`))
323
+ console.log(chalk.red(`├─────────────────────────────────────────────────────────────────┤`))
324
+ console.log(chalk.red(`│ The configured private key file is missing: │`))
325
+ console.log(chalk.red(`│ ${keyConfig.privateKeyPath}`))
326
+ console.log(chalk.red(`│ │`))
327
+ console.log(chalk.red(`│ The private key '${keyConfig.name}' has been removed or moved.`))
328
+ console.log(chalk.red(`│ Please restore the key file to the path above to proceed. │`))
329
+ console.log(chalk.red(`└─────────────────────────────────────────────────────────────────┘\n`))
330
+ return false
331
+ }
332
+
333
+ let currentFingerprint: string
334
+ try {
335
+ currentFingerprint = computeFingerprint(keyConfig.privateKeyPath)
336
+ } catch (error: any) {
337
+ console.log(chalk.red(`\n┌─────────────────────────────────────────────────────────────────┐`))
338
+ console.log(chalk.red(`│ ✗ ${keyLabel} Error${' '.repeat(Math.max(0, 56 - keyLabel.length))}│`))
339
+ console.log(chalk.red(`├─────────────────────────────────────────────────────────────────┤`))
340
+ console.log(chalk.red(`│ Failed to compute fingerprint for the private key at: │`))
341
+ console.log(chalk.red(`│ ${keyConfig.privateKeyPath}`))
342
+ console.log(chalk.red(`│ │`))
343
+ console.log(chalk.red(`│ The key file may be corrupted. Please restore the original │`))
344
+ console.log(chalk.red(`│ key file to proceed. │`))
345
+ console.log(chalk.red(`└─────────────────────────────────────────────────────────────────┘\n`))
346
+ return false
347
+ }
348
+
349
+ if (currentFingerprint !== keyConfig.keyFingerprint) {
350
+ console.log(chalk.red(`\n┌─────────────────────────────────────────────────────────────────┐`))
351
+ console.log(chalk.red(`│ ✗ ${keyLabel} Mismatch${' '.repeat(Math.max(0, 53 - keyLabel.length))}│`))
352
+ console.log(chalk.red(`├─────────────────────────────────────────────────────────────────┤`))
353
+ console.log(chalk.red(`│ The private key at the configured path does not match the │`))
354
+ console.log(chalk.red(`│ fingerprint stored in the workspace config. │`))
355
+ console.log(chalk.red(`│ │`))
356
+ console.log(chalk.red(`│ Key name: ${keyConfig.name}`))
357
+ console.log(chalk.red(`│ Path: ${keyConfig.privateKeyPath}`))
358
+ console.log(chalk.red(`│ │`))
359
+ console.log(chalk.red(`│ The private key has changed. Please restore the original │`))
360
+ console.log(chalk.red(`│ private key that matches the configured fingerprint to proceed.│`))
361
+ console.log(chalk.red(`│ │`))
362
+ console.log(chalk.red(`│ Expected fingerprint: │`))
363
+ console.log(chalk.red(`│ ${keyConfig.keyFingerprint}`))
364
+ console.log(chalk.red(`│ │`))
365
+ console.log(chalk.red(`│ Current fingerprint: │`))
366
+ console.log(chalk.red(`│ ${currentFingerprint}`))
367
+ console.log(chalk.red(`└─────────────────────────────────────────────────────────────────┘\n`))
368
+ return false
369
+ }
370
+
371
+ if (!process.env.T44_KEYS_PASSPHRASE) {
372
+ const passphraseOk = await ensurePassphrase(keyConfig.privateKeyPath, keyConfig.name, keyLabel)
373
+ if (!passphraseOk) {
374
+ return false
375
+ }
376
+
377
+ await ensureKeyInAgent(keyConfig.privateKeyPath, keyConfig.name, keyLabel)
378
+ }
379
+
380
+ return true
381
+ }