@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.
- package/.dco-signatures +9 -0
- package/.github/workflows/dco.yaml +12 -0
- package/.github/workflows/gordian-open-integrity.yaml +13 -0
- package/.github/workflows/test.yaml +31 -0
- package/.o/GordianOpenIntegrity-CurrentLifehash.svg +1026 -0
- package/.o/GordianOpenIntegrity-InceptionLifehash.svg +1026 -0
- package/.o/GordianOpenIntegrity.yaml +21 -0
- package/.o/assets/Hero-Terminal44-v0.jpeg +0 -0
- package/.o/stream44.studio/assets/Icon-v1.svg +1170 -0
- package/.repo-identifier +1 -0
- package/DCO.md +34 -0
- package/LICENSE.txt +186 -0
- package/README.md +189 -0
- package/bin/activate +36 -0
- package/bin/activate.ts +30 -0
- package/bin/postinstall.sh +19 -0
- package/bin/shell +27 -0
- package/bin/t44 +27 -0
- package/caps/ConfigSchemaStruct.ts +55 -0
- package/caps/Home.ts +57 -0
- package/caps/HomeRegistry.ts +319 -0
- package/caps/HomeRegistryFile.ts +144 -0
- package/caps/JsonSchemas.ts +220 -0
- package/caps/OpenApiSchema.ts +67 -0
- package/caps/PackageDescriptor.ts +88 -0
- package/caps/ProjectCatalogs.ts +153 -0
- package/caps/ProjectDeployment.ts +426 -0
- package/caps/ProjectDevelopment.ts +257 -0
- package/caps/ProjectPublishing.ts +654 -0
- package/caps/ProjectPulling.ts +234 -0
- package/caps/ProjectRack.ts +155 -0
- package/caps/ProjectRepository.ts +332 -0
- package/caps/ProjectTest.ts +251 -0
- package/caps/ProjectTestLib.ts +257 -0
- package/caps/RootKey.ts +219 -0
- package/caps/SigningKey.ts +243 -0
- package/caps/TaskWorkflow.ts +192 -0
- package/caps/WorkspaceCli.ts +448 -0
- package/caps/WorkspaceConfig.ts +268 -0
- package/caps/WorkspaceConfig.yaml +87 -0
- package/caps/WorkspaceConfigFile.ts +902 -0
- package/caps/WorkspaceConnection.ts +329 -0
- package/caps/WorkspaceEntityConfig.ts +78 -0
- package/caps/WorkspaceEntityConfig.v0.ts +77 -0
- package/caps/WorkspaceEntityFact.ts +218 -0
- package/caps/WorkspaceInfo.ts +619 -0
- package/caps/WorkspaceInit.ts +30 -0
- package/caps/WorkspaceKey.ts +338 -0
- package/caps/WorkspaceModel.ts +373 -0
- package/caps/WorkspaceProjects.ts +636 -0
- package/caps/WorkspacePrompt.ts +430 -0
- package/caps/WorkspaceShell.sh +39 -0
- package/caps/WorkspaceShell.ts +104 -0
- package/caps/WorkspaceShell.yaml +64 -0
- package/caps/WorkspaceShellCli.ts +109 -0
- package/caps/patterns/README.md +2 -0
- package/caps/patterns/git-scm.com/ProjectPublishing.ts +507 -0
- package/caps/patterns/semver.org/ProjectPublishing.ts +458 -0
- package/docs/Overview.drawio +248 -0
- package/docs/Overview.svg +4 -0
- package/examples/01-Lifecycle/main.test.ts +223 -0
- package/lib/crypto.ts +53 -0
- package/lib/key.ts +381 -0
- package/lib/schema-console-renderer.ts +181 -0
- package/lib/schema-resolver.ts +349 -0
- package/lib/ucan.ts +137 -0
- package/package.json +91 -0
- package/standalone-rt.test.ts +150 -0
- package/standalone-rt.ts +140 -0
- package/structs/HomeRegistry.ts +55 -0
- package/structs/HomeRegistryConfig.ts +60 -0
- package/structs/ProjectCatalogsConfig.ts +53 -0
- package/structs/ProjectDeploymentConfig.ts +56 -0
- package/structs/ProjectDeploymentFact.ts +106 -0
- package/structs/ProjectPublishingConfig.ts +78 -0
- package/structs/ProjectPublishingFact.ts +68 -0
- package/structs/ProjectPullingConfig.ts +52 -0
- package/structs/ProjectRack.ts +51 -0
- package/structs/ProjectRackConfig.ts +56 -0
- package/structs/RepositoryOriginDescriptor.ts +51 -0
- package/structs/RootKeyConfig.ts +64 -0
- package/structs/SigningKeyConfig.ts +64 -0
- package/structs/Workspace.ts +56 -0
- package/structs/WorkspaceCatalogs.ts +56 -0
- package/structs/WorkspaceCliConfig.ts +53 -0
- package/structs/WorkspaceConfig.ts +64 -0
- package/structs/WorkspaceConfigFile.ts +50 -0
- package/structs/WorkspaceConfigFileMeta.ts +70 -0
- package/structs/WorkspaceKey.ts +55 -0
- package/structs/WorkspaceKeyConfig.ts +56 -0
- package/structs/WorkspaceMappingsConfig.ts +56 -0
- package/structs/WorkspaceProject.ts +104 -0
- package/structs/WorkspaceProjectsConfig.ts +67 -0
- package/structs/WorkspaceShellConfig.ts +83 -0
- package/structs/patterns/README.md +2 -0
- package/structs/patterns/git-scm.com/ProjectPublishingFact.ts +46 -0
- package/tsconfig.json +33 -0
- package/workspace-rt.ts +152 -0
- 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
|
+
}
|