devvami 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/oclif.manifest.json +180 -117
- package/package.json +1 -1
- package/src/commands/init.js +4 -2
- package/src/commands/prompts/run.js +19 -1
- package/src/commands/security/setup.js +249 -0
- package/src/commands/welcome.js +17 -0
- package/src/formatters/security.js +119 -0
- package/src/help.js +8 -0
- package/src/services/clickup.js +9 -3
- package/src/services/docs.js +5 -1
- package/src/services/prompts.js +2 -2
- package/src/services/security.js +634 -0
- package/src/types.js +66 -0
- package/src/utils/welcome.js +173 -0
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { readFile, appendFile } from 'node:fs/promises'
|
|
4
|
+
import { existsSync } from 'node:fs'
|
|
5
|
+
import { which, exec, execOrThrow } from './shell.js'
|
|
6
|
+
|
|
7
|
+
/** @import { Platform, PlatformInfo, SecurityTool, SecurityToolStatus, SetupStep, StepResult, GpgKey } from '../types.js' */
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Tool definitions
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/** @type {SecurityTool[]} */
|
|
14
|
+
const TOOL_DEFINITIONS = [
|
|
15
|
+
{
|
|
16
|
+
id: 'aws-vault',
|
|
17
|
+
displayName: 'aws-vault',
|
|
18
|
+
role: 'aws',
|
|
19
|
+
platforms: ['macos', 'linux', 'wsl2'],
|
|
20
|
+
status: 'not-installed',
|
|
21
|
+
version: null,
|
|
22
|
+
hint: null,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'gpg',
|
|
26
|
+
displayName: 'GPG',
|
|
27
|
+
role: 'dependency',
|
|
28
|
+
platforms: ['linux', 'wsl2'],
|
|
29
|
+
status: 'not-installed',
|
|
30
|
+
version: null,
|
|
31
|
+
hint: null,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'pass',
|
|
35
|
+
displayName: 'GNU pass',
|
|
36
|
+
role: 'dependency',
|
|
37
|
+
platforms: ['linux', 'wsl2'],
|
|
38
|
+
status: 'not-installed',
|
|
39
|
+
version: null,
|
|
40
|
+
hint: null,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'osxkeychain',
|
|
44
|
+
displayName: 'macOS Keychain',
|
|
45
|
+
role: 'git',
|
|
46
|
+
platforms: ['macos'],
|
|
47
|
+
status: 'not-installed',
|
|
48
|
+
version: null,
|
|
49
|
+
hint: null,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'gcm',
|
|
53
|
+
displayName: 'Git Credential Manager',
|
|
54
|
+
role: 'git',
|
|
55
|
+
platforms: ['linux', 'wsl2'],
|
|
56
|
+
status: 'not-installed',
|
|
57
|
+
version: null,
|
|
58
|
+
hint: null,
|
|
59
|
+
},
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// checkToolStatus
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check the current status of all security tools for the given platform.
|
|
68
|
+
* @param {Platform} platform
|
|
69
|
+
* @returns {Promise<SecurityToolStatus[]>}
|
|
70
|
+
*/
|
|
71
|
+
export async function checkToolStatus(platform) {
|
|
72
|
+
/** @type {SecurityToolStatus[]} */
|
|
73
|
+
const results = []
|
|
74
|
+
|
|
75
|
+
for (const tool of TOOL_DEFINITIONS) {
|
|
76
|
+
if (!tool.platforms.includes(platform)) {
|
|
77
|
+
results.push({ id: tool.id, displayName: tool.displayName, status: 'n/a', version: null, hint: null })
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (tool.id === 'aws-vault') {
|
|
82
|
+
const path = await which('aws-vault')
|
|
83
|
+
if (!path) {
|
|
84
|
+
results.push({ id: tool.id, displayName: tool.displayName, status: 'not-installed', version: null, hint: 'Install aws-vault' })
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
const versionResult = await exec('aws-vault', ['--version'])
|
|
88
|
+
const version = (versionResult.stdout || versionResult.stderr).replace(/^v/, '').trim()
|
|
89
|
+
// On Linux/WSL2 check that AWS_VAULT_BACKEND=pass is configured
|
|
90
|
+
if (platform !== 'macos') {
|
|
91
|
+
const backend = process.env.AWS_VAULT_BACKEND
|
|
92
|
+
if (backend !== 'pass') {
|
|
93
|
+
results.push({ id: tool.id, displayName: tool.displayName, status: 'misconfigured', version: version || null, hint: 'Add export AWS_VAULT_BACKEND=pass to your shell profile' })
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
results.push({ id: tool.id, displayName: tool.displayName, status: 'installed', version: version || null, hint: null })
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (tool.id === 'gpg') {
|
|
102
|
+
const path = await which('gpg')
|
|
103
|
+
if (!path) {
|
|
104
|
+
results.push({ id: tool.id, displayName: tool.displayName, status: 'not-installed', version: null, hint: 'Install gnupg via your package manager' })
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
const versionResult = await exec('gpg', ['--version'])
|
|
108
|
+
const match = versionResult.stdout.match(/gpg \(GnuPG\)\s+([\d.]+)/)
|
|
109
|
+
results.push({ id: tool.id, displayName: tool.displayName, status: 'installed', version: match ? match[1] : null, hint: null })
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (tool.id === 'pass') {
|
|
114
|
+
const path = await which('pass')
|
|
115
|
+
if (!path) {
|
|
116
|
+
results.push({ id: tool.id, displayName: tool.displayName, status: 'not-installed', version: null, hint: 'Install pass via your package manager' })
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
const versionResult = await exec('pass', ['--version'])
|
|
120
|
+
const match = versionResult.stdout.match(/([\d.]+)/)
|
|
121
|
+
// Check if pass is initialized
|
|
122
|
+
const lsResult = await exec('pass', ['ls'])
|
|
123
|
+
if (lsResult.exitCode !== 0) {
|
|
124
|
+
results.push({ id: tool.id, displayName: tool.displayName, status: 'misconfigured', version: match ? match[1] : null, hint: 'Initialize pass with: pass init <gpg-key-id>' })
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
results.push({ id: tool.id, displayName: tool.displayName, status: 'installed', version: match ? match[1] : null, hint: null })
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (tool.id === 'osxkeychain') {
|
|
132
|
+
const result = await exec('git', ['config', '--global', 'credential.helper'])
|
|
133
|
+
if (result.stdout === 'osxkeychain') {
|
|
134
|
+
results.push({ id: tool.id, displayName: tool.displayName, status: 'installed', version: null, hint: null })
|
|
135
|
+
} else {
|
|
136
|
+
results.push({ id: tool.id, displayName: tool.displayName, status: 'not-installed', version: null, hint: 'Run: git config --global credential.helper osxkeychain' })
|
|
137
|
+
}
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (tool.id === 'gcm') {
|
|
142
|
+
const path = await which('git-credential-manager')
|
|
143
|
+
if (!path) {
|
|
144
|
+
results.push({ id: tool.id, displayName: tool.displayName, status: 'not-installed', version: null, hint: 'Install Git Credential Manager' })
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
const versionResult = await exec('git-credential-manager', ['--version'])
|
|
148
|
+
const version = versionResult.stdout.trim() || null
|
|
149
|
+
const storeResult = await exec('git', ['config', '--global', 'credential.credentialStore'])
|
|
150
|
+
if (storeResult.stdout !== 'gpg') {
|
|
151
|
+
results.push({ id: tool.id, displayName: tool.displayName, status: 'misconfigured', version, hint: 'Run: git config --global credential.credentialStore gpg' })
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
results.push({ id: tool.id, displayName: tool.displayName, status: 'installed', version, hint: null })
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return results
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// appendToShellProfile
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Append a line to the developer's shell profile if not already present (idempotent).
|
|
168
|
+
* @param {string} line - The line to append (e.g., "export AWS_VAULT_BACKEND=pass")
|
|
169
|
+
* @returns {Promise<void>}
|
|
170
|
+
*/
|
|
171
|
+
export async function appendToShellProfile(line) {
|
|
172
|
+
const shell = process.env.SHELL ?? ''
|
|
173
|
+
let profilePath
|
|
174
|
+
if (shell.includes('zsh')) {
|
|
175
|
+
profilePath = join(homedir(), '.zshrc')
|
|
176
|
+
} else {
|
|
177
|
+
profilePath = join(homedir(), '.bashrc')
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (existsSync(profilePath)) {
|
|
181
|
+
const contents = await readFile(profilePath, 'utf8')
|
|
182
|
+
if (contents.includes(line)) return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
await appendFile(profilePath, `\n${line}\n`)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// listGpgKeys
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* List GPG secret keys available on the system.
|
|
194
|
+
* @returns {Promise<GpgKey[]>}
|
|
195
|
+
*/
|
|
196
|
+
export async function listGpgKeys() {
|
|
197
|
+
const result = await exec('gpg', ['--list-secret-keys', '--with-colons'])
|
|
198
|
+
if (result.exitCode !== 0 || !result.stdout.trim()) return []
|
|
199
|
+
|
|
200
|
+
const lines = result.stdout.split('\n')
|
|
201
|
+
/** @type {GpgKey[]} */
|
|
202
|
+
const keys = []
|
|
203
|
+
let current = /** @type {Partial<GpgKey>|null} */ (null)
|
|
204
|
+
|
|
205
|
+
for (const line of lines) {
|
|
206
|
+
const parts = line.split(':')
|
|
207
|
+
const type = parts[0]
|
|
208
|
+
|
|
209
|
+
if (type === 'sec') {
|
|
210
|
+
// Start a new key: fingerprint comes from subsequent 'fpr' line
|
|
211
|
+
current = {
|
|
212
|
+
id: parts[4] ? parts[4].slice(-16) : '',
|
|
213
|
+
fingerprint: '',
|
|
214
|
+
name: '',
|
|
215
|
+
email: '',
|
|
216
|
+
expiry: parts[6] ? new Date(Number(parts[6]) * 1000).toISOString() : null,
|
|
217
|
+
}
|
|
218
|
+
} else if (type === 'fpr' && current && !current.fingerprint) {
|
|
219
|
+
current.fingerprint = parts[9] ?? ''
|
|
220
|
+
} else if (type === 'uid' && current) {
|
|
221
|
+
const uid = parts[9] ?? ''
|
|
222
|
+
const nameMatch = uid.match(/^([^<]+?)\s*</)
|
|
223
|
+
const emailMatch = uid.match(/<([^>]+)>/)
|
|
224
|
+
if (!current.name) current.name = nameMatch ? nameMatch[1].trim() : uid
|
|
225
|
+
if (!current.email) current.email = emailMatch ? emailMatch[1] : ''
|
|
226
|
+
|
|
227
|
+
// Key is complete enough — push it
|
|
228
|
+
if (current.id) {
|
|
229
|
+
keys.push(/** @type {GpgKey} */ (current))
|
|
230
|
+
current = null
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return keys
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// deriveOverallStatus
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Derive an overall status string from a list of tool statuses.
|
|
244
|
+
* @param {SecurityToolStatus[]} tools
|
|
245
|
+
* @returns {'success'|'partial'|'not-configured'}
|
|
246
|
+
*/
|
|
247
|
+
export function deriveOverallStatus(tools) {
|
|
248
|
+
const applicable = tools.filter((t) => t.status !== 'n/a')
|
|
249
|
+
if (applicable.length === 0) return 'not-configured'
|
|
250
|
+
const allInstalled = applicable.every((t) => t.status === 'installed')
|
|
251
|
+
if (allInstalled) return 'success'
|
|
252
|
+
const someInstalled = applicable.some((t) => t.status === 'installed')
|
|
253
|
+
if (someInstalled) return 'partial'
|
|
254
|
+
return 'not-configured'
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// buildSteps
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Build an ordered list of setup steps for the given platform and selection.
|
|
263
|
+
* This is a pure function — all side-effecting logic is in the `run` closures.
|
|
264
|
+
* @param {PlatformInfo} platformInfo
|
|
265
|
+
* @param {'aws'|'git'|'both'} selection
|
|
266
|
+
* @param {{ gpgId?: string }} [context] - Optional context (e.g. chosen GPG key ID)
|
|
267
|
+
* @returns {SetupStep[]}
|
|
268
|
+
*/
|
|
269
|
+
export function buildSteps(platformInfo, selection, context = {}) {
|
|
270
|
+
const { platform } = platformInfo
|
|
271
|
+
const includeAws = selection === 'aws' || selection === 'both'
|
|
272
|
+
const includeGit = selection === 'git' || selection === 'both'
|
|
273
|
+
|
|
274
|
+
/** @type {SetupStep[]} */
|
|
275
|
+
const steps = []
|
|
276
|
+
|
|
277
|
+
if (platform === 'macos') {
|
|
278
|
+
if (includeAws) {
|
|
279
|
+
steps.push({
|
|
280
|
+
id: 'check-brew',
|
|
281
|
+
label: 'Check Homebrew installation',
|
|
282
|
+
toolId: 'aws-vault',
|
|
283
|
+
type: 'check',
|
|
284
|
+
requiresConfirmation: false,
|
|
285
|
+
run: async () => {
|
|
286
|
+
const path = await which('brew')
|
|
287
|
+
if (!path) {
|
|
288
|
+
return {
|
|
289
|
+
status: 'failed',
|
|
290
|
+
hint: 'Homebrew is required. Install it from https://brew.sh',
|
|
291
|
+
hintUrl: 'https://brew.sh',
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return { status: 'success', message: 'Homebrew is available' }
|
|
295
|
+
},
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
steps.push({
|
|
299
|
+
id: 'install-aws-vault',
|
|
300
|
+
label: 'Install aws-vault via Homebrew',
|
|
301
|
+
toolId: 'aws-vault',
|
|
302
|
+
type: 'install',
|
|
303
|
+
requiresConfirmation: true,
|
|
304
|
+
run: async () => {
|
|
305
|
+
const existing = await which('aws-vault')
|
|
306
|
+
if (existing) return { status: 'skipped', message: 'aws-vault already installed' }
|
|
307
|
+
try {
|
|
308
|
+
await execOrThrow('brew', ['install', 'aws-vault'])
|
|
309
|
+
return { status: 'success', message: 'aws-vault installed via Homebrew' }
|
|
310
|
+
} catch {
|
|
311
|
+
return {
|
|
312
|
+
status: 'failed',
|
|
313
|
+
hint: 'Run manually: brew install aws-vault',
|
|
314
|
+
hintUrl: 'https://github.com/99designs/aws-vault',
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
steps.push({
|
|
321
|
+
id: 'verify-aws-vault',
|
|
322
|
+
label: 'Verify aws-vault installation',
|
|
323
|
+
toolId: 'aws-vault',
|
|
324
|
+
type: 'verify',
|
|
325
|
+
requiresConfirmation: false,
|
|
326
|
+
run: async () => {
|
|
327
|
+
const result = await exec('aws-vault', ['--version'])
|
|
328
|
+
if (result.exitCode !== 0) {
|
|
329
|
+
return { status: 'failed', hint: 'aws-vault not found in PATH after install' }
|
|
330
|
+
}
|
|
331
|
+
const version = (result.stdout || result.stderr).trim()
|
|
332
|
+
return { status: 'success', message: `aws-vault ${version}` }
|
|
333
|
+
},
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (includeGit) {
|
|
338
|
+
steps.push({
|
|
339
|
+
id: 'configure-osxkeychain',
|
|
340
|
+
label: 'Configure macOS Keychain as Git credential helper',
|
|
341
|
+
toolId: 'osxkeychain',
|
|
342
|
+
type: 'configure',
|
|
343
|
+
requiresConfirmation: true,
|
|
344
|
+
run: async () => {
|
|
345
|
+
try {
|
|
346
|
+
await execOrThrow('git', ['config', '--global', 'credential.helper', 'osxkeychain'])
|
|
347
|
+
return { status: 'success', message: 'Git credential helper set to osxkeychain' }
|
|
348
|
+
} catch {
|
|
349
|
+
return { status: 'failed', hint: 'Run manually: git config --global credential.helper osxkeychain' }
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
steps.push({
|
|
355
|
+
id: 'verify-osxkeychain',
|
|
356
|
+
label: 'Verify macOS Keychain credential helper',
|
|
357
|
+
toolId: 'osxkeychain',
|
|
358
|
+
type: 'verify',
|
|
359
|
+
requiresConfirmation: false,
|
|
360
|
+
run: async () => {
|
|
361
|
+
const result = await exec('git', ['config', '--global', 'credential.helper'])
|
|
362
|
+
if (result.stdout !== 'osxkeychain') {
|
|
363
|
+
return { status: 'failed', hint: 'credential.helper is not set to osxkeychain' }
|
|
364
|
+
}
|
|
365
|
+
return { status: 'success', message: 'osxkeychain is configured' }
|
|
366
|
+
},
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
} else {
|
|
370
|
+
// Linux / WSL2
|
|
371
|
+
if (includeAws) {
|
|
372
|
+
steps.push({
|
|
373
|
+
id: 'check-gpg',
|
|
374
|
+
label: 'Check GPG installation',
|
|
375
|
+
toolId: 'gpg',
|
|
376
|
+
type: 'check',
|
|
377
|
+
requiresConfirmation: false,
|
|
378
|
+
run: async () => {
|
|
379
|
+
const path = await which('gpg')
|
|
380
|
+
if (!path) {
|
|
381
|
+
return { status: 'failed', hint: 'GPG not found — will be installed in the next step' }
|
|
382
|
+
}
|
|
383
|
+
const result = await exec('gpg', ['--version'])
|
|
384
|
+
const match = result.stdout.match(/gpg \(GnuPG\)\s+([\d.]+)/)
|
|
385
|
+
return { status: 'success', message: `GPG ${match ? match[1] : 'found'}` }
|
|
386
|
+
},
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
steps.push({
|
|
390
|
+
id: 'install-gpg',
|
|
391
|
+
label: 'Install GPG (gnupg)',
|
|
392
|
+
toolId: 'gpg',
|
|
393
|
+
type: 'install',
|
|
394
|
+
requiresConfirmation: true,
|
|
395
|
+
skippable: true,
|
|
396
|
+
run: async () => {
|
|
397
|
+
const path = await which('gpg')
|
|
398
|
+
if (path) return { status: 'skipped', message: 'GPG already installed' }
|
|
399
|
+
try {
|
|
400
|
+
await execOrThrow('sudo', ['apt-get', 'install', '-y', 'gnupg'])
|
|
401
|
+
return { status: 'success', message: 'GPG installed' }
|
|
402
|
+
} catch {
|
|
403
|
+
return { status: 'failed', hint: 'Run manually: sudo apt-get install -y gnupg' }
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
steps.push({
|
|
409
|
+
id: 'create-gpg-key',
|
|
410
|
+
label: 'Create or select a GPG key for pass and GCM',
|
|
411
|
+
toolId: 'gpg',
|
|
412
|
+
type: 'configure',
|
|
413
|
+
requiresConfirmation: true,
|
|
414
|
+
gpgInteractive: true,
|
|
415
|
+
run: async () => {
|
|
416
|
+
const gpgId = context.gpgId
|
|
417
|
+
if (gpgId) return { status: 'skipped', message: `Using existing GPG key ${gpgId}` }
|
|
418
|
+
// When gpgInteractive=true, the command layer stops the spinner and spawns
|
|
419
|
+
// gpg --full-generate-key with stdio:inherit so the user sets a strong passphrase.
|
|
420
|
+
// We never generate a key with an empty passphrase — that would leave the private
|
|
421
|
+
// key unprotected at rest and defeat the purpose of this setup wizard.
|
|
422
|
+
// If we reach this non-interactive fallback, ask the user to do it manually.
|
|
423
|
+
return {
|
|
424
|
+
status: 'failed',
|
|
425
|
+
hint: 'Create a GPG key manually with a strong passphrase: gpg --full-generate-key',
|
|
426
|
+
hintUrl: 'https://www.gnupg.org/gph/en/manual/c14.html',
|
|
427
|
+
}
|
|
428
|
+
},
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
steps.push({
|
|
432
|
+
id: 'install-pass',
|
|
433
|
+
label: 'Install GNU pass',
|
|
434
|
+
toolId: 'pass',
|
|
435
|
+
type: 'install',
|
|
436
|
+
requiresConfirmation: true,
|
|
437
|
+
run: async () => {
|
|
438
|
+
const path = await which('pass')
|
|
439
|
+
if (path) return { status: 'skipped', message: 'pass already installed' }
|
|
440
|
+
try {
|
|
441
|
+
await execOrThrow('sudo', ['apt-get', 'install', '-y', 'pass'])
|
|
442
|
+
return { status: 'success', message: 'pass installed' }
|
|
443
|
+
} catch {
|
|
444
|
+
return { status: 'failed', hint: 'Run manually: sudo apt-get install -y pass' }
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
steps.push({
|
|
450
|
+
id: 'init-pass',
|
|
451
|
+
label: 'Initialize pass with your GPG key',
|
|
452
|
+
toolId: 'pass',
|
|
453
|
+
type: 'configure',
|
|
454
|
+
requiresConfirmation: true,
|
|
455
|
+
run: async () => {
|
|
456
|
+
// Skip if pass is already initialized
|
|
457
|
+
const lsResult = await exec('pass', ['ls'])
|
|
458
|
+
if (lsResult.exitCode === 0) {
|
|
459
|
+
return { status: 'skipped', message: 'pass store already initialized' }
|
|
460
|
+
}
|
|
461
|
+
const gpgId = context.gpgId
|
|
462
|
+
if (!gpgId) {
|
|
463
|
+
return { status: 'failed', hint: 'No GPG key ID available — complete the create-gpg-key step first' }
|
|
464
|
+
}
|
|
465
|
+
try {
|
|
466
|
+
await execOrThrow('pass', ['init', gpgId])
|
|
467
|
+
return { status: 'success', message: `pass initialized with key ${gpgId}` }
|
|
468
|
+
} catch {
|
|
469
|
+
return { status: 'failed', hint: `Run manually: pass init ${gpgId}` }
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
steps.push({
|
|
475
|
+
id: 'install-aws-vault',
|
|
476
|
+
label: 'Install aws-vault binary',
|
|
477
|
+
toolId: 'aws-vault',
|
|
478
|
+
type: 'install',
|
|
479
|
+
requiresConfirmation: true,
|
|
480
|
+
run: async () => {
|
|
481
|
+
const existing = await which('aws-vault')
|
|
482
|
+
if (existing) return { status: 'skipped', message: 'aws-vault already installed' }
|
|
483
|
+
const arch = process.arch === 'arm64' ? 'arm64' : 'amd64'
|
|
484
|
+
const url = `https://github.com/99designs/aws-vault/releases/latest/download/aws-vault-linux-${arch}`
|
|
485
|
+
try {
|
|
486
|
+
await execOrThrow('sudo', ['sh', '-c', `curl -sSL '${url}' -o /usr/local/bin/aws-vault && chmod +x /usr/local/bin/aws-vault`])
|
|
487
|
+
return { status: 'success', message: 'aws-vault installed to /usr/local/bin/aws-vault' }
|
|
488
|
+
} catch {
|
|
489
|
+
return {
|
|
490
|
+
status: 'failed',
|
|
491
|
+
hint: `Download manually: curl -sSL '${url}' -o /usr/local/bin/aws-vault && chmod +x /usr/local/bin/aws-vault`,
|
|
492
|
+
hintUrl: 'https://github.com/99designs/aws-vault/releases',
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
},
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
steps.push({
|
|
499
|
+
id: 'configure-aws-vault-backend',
|
|
500
|
+
label: 'Configure AWS_VAULT_BACKEND=pass in shell profile',
|
|
501
|
+
toolId: 'aws-vault',
|
|
502
|
+
type: 'configure',
|
|
503
|
+
requiresConfirmation: true,
|
|
504
|
+
run: async () => {
|
|
505
|
+
try {
|
|
506
|
+
await appendToShellProfile('export AWS_VAULT_BACKEND=pass')
|
|
507
|
+
await appendToShellProfile('export GPG_TTY=$(tty)')
|
|
508
|
+
return { status: 'success', message: 'AWS_VAULT_BACKEND=pass and GPG_TTY added to shell profile' }
|
|
509
|
+
} catch {
|
|
510
|
+
return { status: 'failed', hint: 'Add manually to ~/.bashrc or ~/.zshrc: export AWS_VAULT_BACKEND=pass' }
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
steps.push({
|
|
516
|
+
id: 'verify-aws-vault',
|
|
517
|
+
label: 'Verify aws-vault installation',
|
|
518
|
+
toolId: 'aws-vault',
|
|
519
|
+
type: 'verify',
|
|
520
|
+
requiresConfirmation: false,
|
|
521
|
+
run: async () => {
|
|
522
|
+
const result = await exec('aws-vault', ['--version'])
|
|
523
|
+
if (result.exitCode !== 0) {
|
|
524
|
+
return { status: 'failed', hint: 'aws-vault not found in PATH after install' }
|
|
525
|
+
}
|
|
526
|
+
const version = (result.stdout || result.stderr).trim()
|
|
527
|
+
return { status: 'success', message: `aws-vault ${version}` }
|
|
528
|
+
},
|
|
529
|
+
})
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (includeGit) {
|
|
533
|
+
// On WSL2, first check for Windows-side GCM bridge
|
|
534
|
+
if (platform === 'wsl2') {
|
|
535
|
+
steps.push({
|
|
536
|
+
id: 'check-gcm-bridge',
|
|
537
|
+
label: 'Check for Windows Git Credential Manager bridge',
|
|
538
|
+
toolId: 'gcm',
|
|
539
|
+
type: 'check',
|
|
540
|
+
requiresConfirmation: false,
|
|
541
|
+
run: async () => {
|
|
542
|
+
const bridgePath = '/mnt/c/Program Files/Git/mingw64/bin/git-credential-manager.exe'
|
|
543
|
+
if (existsSync(bridgePath)) {
|
|
544
|
+
return { status: 'success', message: 'Windows GCM bridge found — using Windows Credential Manager' }
|
|
545
|
+
}
|
|
546
|
+
return { status: 'skipped', message: 'Windows GCM not found — will install native Linux GCM' }
|
|
547
|
+
},
|
|
548
|
+
})
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
steps.push({
|
|
552
|
+
id: 'install-gcm',
|
|
553
|
+
label: 'Install Git Credential Manager',
|
|
554
|
+
toolId: 'gcm',
|
|
555
|
+
type: 'install',
|
|
556
|
+
requiresConfirmation: true,
|
|
557
|
+
run: async () => {
|
|
558
|
+
const existing = await which('git-credential-manager')
|
|
559
|
+
if (existing) return { status: 'skipped', message: 'Git Credential Manager already installed' }
|
|
560
|
+
try {
|
|
561
|
+
const latestResult = await exec('sh', ['-c', "curl -sSL https://api.github.com/repos/git-ecosystem/git-credential-manager/releases/latest | grep 'browser_download_url.*gcm.*linux.*amd64.*deb' | head -1 | cut -d '\"' -f 4"])
|
|
562
|
+
const debUrl = latestResult.stdout.trim()
|
|
563
|
+
if (!debUrl) throw new Error('Could not find GCM deb package URL')
|
|
564
|
+
// Security: validate debUrl is a legitimate GitHub release asset URL before using it
|
|
565
|
+
// This prevents command injection if the GitHub API response were tampered with (MITM / supply-chain)
|
|
566
|
+
const SAFE_DEB_URL = /^https:\/\/github\.com\/git-ecosystem\/git-credential-manager\/releases\/download\/[a-zA-Z0-9._\-/]+\.deb$/
|
|
567
|
+
if (!SAFE_DEB_URL.test(debUrl)) {
|
|
568
|
+
throw new Error(`Unexpected GCM package URL format: "${debUrl}"`)
|
|
569
|
+
}
|
|
570
|
+
// Use array args — no shell interpolation of the URL
|
|
571
|
+
await execOrThrow('curl', ['-sSL', debUrl, '-o', '/tmp/gcm.deb'])
|
|
572
|
+
await execOrThrow('sudo', ['dpkg', '-i', '/tmp/gcm.deb'])
|
|
573
|
+
return { status: 'success', message: 'Git Credential Manager installed' }
|
|
574
|
+
} catch {
|
|
575
|
+
return {
|
|
576
|
+
status: 'failed',
|
|
577
|
+
hint: 'Install manually from https://github.com/git-ecosystem/git-credential-manager/releases',
|
|
578
|
+
hintUrl: 'https://github.com/git-ecosystem/git-credential-manager/releases',
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
},
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
steps.push({
|
|
585
|
+
id: 'configure-gcm',
|
|
586
|
+
label: 'Configure Git Credential Manager',
|
|
587
|
+
toolId: 'gcm',
|
|
588
|
+
type: 'configure',
|
|
589
|
+
requiresConfirmation: true,
|
|
590
|
+
run: async () => {
|
|
591
|
+
try {
|
|
592
|
+
await execOrThrow('git-credential-manager', ['configure'])
|
|
593
|
+
return { status: 'success', message: 'Git Credential Manager configured' }
|
|
594
|
+
} catch {
|
|
595
|
+
return { status: 'failed', hint: 'Run manually: git-credential-manager configure' }
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
steps.push({
|
|
601
|
+
id: 'configure-gcm-store',
|
|
602
|
+
label: 'Set GCM credential store to gpg',
|
|
603
|
+
toolId: 'gcm',
|
|
604
|
+
type: 'configure',
|
|
605
|
+
requiresConfirmation: true,
|
|
606
|
+
run: async () => {
|
|
607
|
+
try {
|
|
608
|
+
await execOrThrow('git', ['config', '--global', 'credential.credentialStore', 'gpg'])
|
|
609
|
+
return { status: 'success', message: 'GCM credential store set to gpg' }
|
|
610
|
+
} catch {
|
|
611
|
+
return { status: 'failed', hint: 'Run manually: git config --global credential.credentialStore gpg' }
|
|
612
|
+
}
|
|
613
|
+
},
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
steps.push({
|
|
617
|
+
id: 'verify-gcm',
|
|
618
|
+
label: 'Verify Git Credential Manager',
|
|
619
|
+
toolId: 'gcm',
|
|
620
|
+
type: 'verify',
|
|
621
|
+
requiresConfirmation: false,
|
|
622
|
+
run: async () => {
|
|
623
|
+
const result = await exec('git-credential-manager', ['--version'])
|
|
624
|
+
if (result.exitCode !== 0) {
|
|
625
|
+
return { status: 'failed', hint: 'git-credential-manager not found in PATH' }
|
|
626
|
+
}
|
|
627
|
+
return { status: 'success', message: `GCM ${result.stdout.trim()}` }
|
|
628
|
+
},
|
|
629
|
+
})
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return steps
|
|
634
|
+
}
|