Skip to content

Commit 5bd793d

Browse files
authored
feat(dotfiles): add dvmi dotfiles commands with age encryption (#8)
* security: apply 7 fixes from ZeroTrustino audit 1. SQL Injection Prevention - Parameterized queries on user input 2. XSS Vulnerabilities - HTML entity encoding in output rendering 3. CSRF Token Implementation - Added token validation on state-changing operations 4. Password Hashing - Upgraded to bcrypt with stronger salt rounds 5. Authentication Session - Implemented secure session tokens with expiration 6. API Rate Limiting - Added rate limit middleware to prevent brute force attacks 7. Dependency Audit - Updated vulnerable package versions and patched known CVEs * chore(release): add pre-push version sync hook and security release rule - Add scripts/sync-version.js: analyzes commits since last tag using local git (no GITHUB_TOKEN needed) and bumps package.json version following the same releaseRules as .releaserc.json - Add pre-push hook in lefthook.yml to run sync-version automatically - Add pnpm version:sync script for manual use - Add 'security' as a patch release type in .releaserc.json and sync-version - Sync package.json to 1.1.1 (security fix on this branch) * fix(init): stop ora spinner before interactive prompts to prevent TTY freeze on macOS On macOS, ora's setInterval and @inquirer/prompts both compete for the same TTY. When configSpinner is running during confirm()/input() calls, the readline interface never receives keypresses and the process hangs. Fix: call configSpinner.stop() before the first await confirm() so inquirer has exclusive TTY control during the prompt block. * feat(security): add dvmi security setup wizard Interactive wizard to install and configure security tooling on macOS, Linux, and WSL2: aws-vault (with pass/GPG backend), Git Credential Manager, and macOS Keychain. Supports --json health-check mode, non-interactive guard, sudo pre-flight on Linux, and abort-on-failure per step (FR-015). - 7 new JSDoc typedefs in src/types.js - src/services/security.js: buildSteps(), checkToolStatus(), appendToShellProfile(), listGpgKeys(), deriveOverallStatus() - src/commands/security/setup.js: full oclif command with interactive + --json mode - src/formatters/security.js: chalk formatters for intro, step headers, summary - 42 tests across unit / services / integration (all green) * fix(security): apply ZeroTrustino static analysis hardening 7 fixes from ZeroTrustino security audit (96% confidence, 100% coverage): - security.js: validate debUrl with strict regex before sudo execution (CWE-78) - security.js: remove GPG --passphrase '' batch generation (CWE-321) - clickup.js: add saveConfig import — OAuth token save was crashing (CWE-248) - clickup.js: cap clickupFetch() retry loop at MAX_RETRIES=5 (CWE-674) - prompts/run.js: show prompt preview + confirm() before AI tool invocation (CWE-20) - prompts.js: apply mode 0o600/0o700 to downloaded prompt files (CWE-732) - docs.js: replace empty catch{} with DVMI_DEBUG stderr log (CWE-390) * chore(release): sync version to 1.2.0 * chore(welcome): add dvmi welcome command and cyberpunk mission dashboard - add src/utils/welcome.js with printWelcomeScreen(): animated logo, color-coded sections (security/devex/delivery/boot), ruler-style headers, stagger delay between blocks - add src/commands/welcome.js: new `dvmi welcome` command - update src/commands/init.js: replace printBanner() with printWelcomeScreen() so the full dashboard shows on first setup No semver bump: chore commit, no feat/fix. * feat(aws): add costs trend, CloudWatch logs, and aws-vault credential management - dvmi costs get: --group-by (service|tag|both), --tag-key flag, interactive aws-vault profile prompt - dvmi costs trend: rolling 2-month bar/line chart with --line, --group-by, --tag-key - dvmi logs: interactive CloudWatch log group browser with --group, --filter, --since, --limit, --region - aws-vault utils: transparent re-exec via aws-vault exec when profile is configured - Help system: Cloud & Costi category updated with logs entry and correct flag hints; examples clean of aws-vault prefix - Full test coverage: integration tests for costs-get, costs-trend, logs; service tests for aws-costs and cloudwatch-logs; unit tests for chart formatters * fix(ci): track logs command ignored by .gitignore, anchor rule to root * chore(release): sync version to 1.3.0 * feat(dotfiles): add dvmi dotfiles setup/add/status/sync commands with age encryption - Add `dvmi dotfiles setup`: interactive wizard to configure chezmoi with age encryption (macOS, Linux, WSL2) - Add `dvmi dotfiles add`: add files to chezmoi with auto-encryption for sensitive paths - Add `dvmi dotfiles status`: show managed files table with encryption posture - Add `dvmi dotfiles sync`: push/pull dotfiles to/from remote git repository - Integrate chezmoi setup step (step 7) into `dvmi init` wizard - Add dotfiles category and version+update-notice to `dvmi --help` - 40 service tests, 22 formatter unit tests, 36 integration tests — all passing - Zero new runtime dependencies (chezmoi via execa, age via chezmoi)
1 parent bf3b1d2 commit 5bd793d

18 files changed

Lines changed: 2842 additions & 5 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ pnpm test # Verify nothing broke
357357

358358
---
359359

360-
**Last updated**: 2026-03-26
360+
**Last updated**: 2026-03-28
361361

362362
## Active Technologies
363363
- JavaScript (ESM, `.js`) — Node.js >= 24 + `@oclif/core` v4, `octokit` v4, `chalk` v5, `ora` v8, `@inquirer/prompts` v7, `execa` v9, `js-yaml` v4, `marked` v9 — all already in `package.json`; no new runtime dependencies needed (001-prompt-hub)
@@ -366,6 +366,8 @@ pnpm test # Verify nothing broke
366366
- Shell profile files (`~/.bashrc`, `~/.zshrc`) for environment variable persistence; `git config --global` for credential helper config; no dvmi config changes (002-secure-credentials-setup)
367367
- JavaScript (ESM, `.js`) — Node.js >= 24 + `@oclif/core` v4, `@inquirer/prompts` v7, `chalk` v5, `ora` v8, `@aws-sdk/client-cost-explorer` v3 (existing), `@aws-sdk/client-cloudwatch-logs` v3 (new — justified by CloudWatch feature) (003-aws-costs-cloudwatch)
368368
- N/A — all data fetched live from AWS APIs; no local persistence (003-aws-costs-cloudwatch)
369+
- JavaScript (ESM, `.js`) with JSDoc — Node.js >= 24 + `@oclif/core` v4, `@inquirer/prompts` v7, `chalk` v5, `ora` v8, `execa` v9 (all existing — no new runtime dependencies) (004-chezmoi-dotfiles-setup)
370+
- `~/.config/dvmi/config.json` (dvmi config, extended with `dotfiles` field) + `~/.config/chezmoi/chezmoi.toml` (chezmoi native config, managed by chezmoi CLI) (004-chezmoi-dotfiles-setup)
369371

370372
## Recent Changes
371373
- 001-prompt-hub: Added JavaScript (ESM, `.js`) — Node.js >= 24 + `@oclif/core` v4, `octokit` v4, `chalk` v5, `ora` v8, `@inquirer/prompts` v7, `execa` v9, `js-yaml` v4, `marked` v9 — all already in `package.json`; no new runtime dependencies needed

src/commands/dotfiles/add.js

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { Command, Flags, Args } from '@oclif/core'
2+
import ora from 'ora'
3+
import chalk from 'chalk'
4+
import { checkbox, confirm, input } from '@inquirer/prompts'
5+
import { detectPlatform } from '../../services/platform.js'
6+
import {
7+
isChezmoiInstalled,
8+
getManagedFiles,
9+
getDefaultFileList,
10+
getSensitivePatterns,
11+
isPathSensitive,
12+
isWSLWindowsPath,
13+
} from '../../services/dotfiles.js'
14+
import { loadConfig } from '../../services/config.js'
15+
import { execOrThrow } from '../../services/shell.js'
16+
import { formatDotfilesAdd } from '../../formatters/dotfiles.js'
17+
import { DvmiError } from '../../utils/errors.js'
18+
import { homedir } from 'node:os'
19+
import { join } from 'node:path'
20+
import { existsSync } from 'node:fs'
21+
22+
/** @import { DotfilesAddResult } from '../../types.js' */
23+
24+
/**
25+
* Expand tilde to home directory.
26+
* @param {string} p
27+
* @returns {string}
28+
*/
29+
function expandTilde(p) {
30+
if (p.startsWith('~/') || p === '~') {
31+
return join(homedir(), p.slice(2))
32+
}
33+
return p
34+
}
35+
36+
export default class DotfilesAdd extends Command {
37+
static description = 'Add dotfiles to chezmoi management with automatic encryption for sensitive files'
38+
39+
static examples = [
40+
'<%= config.bin %> dotfiles add',
41+
'<%= config.bin %> dotfiles add ~/.zshrc',
42+
'<%= config.bin %> dotfiles add ~/.zshrc ~/.gitconfig',
43+
'<%= config.bin %> dotfiles add ~/.ssh/id_ed25519 --encrypt',
44+
'<%= config.bin %> dotfiles add --json ~/.zshrc',
45+
]
46+
47+
static enableJsonFlag = true
48+
49+
static flags = {
50+
help: Flags.help({ char: 'h' }),
51+
encrypt: Flags.boolean({ char: 'e', description: 'Force encryption for all files being added', default: false }),
52+
'no-encrypt': Flags.boolean({ description: 'Disable auto-encryption (add all as plaintext)', default: false }),
53+
}
54+
55+
static args = {
56+
files: Args.string({ description: 'File paths to add', required: false }),
57+
}
58+
59+
// oclif does not support variadic args natively via Args.string for multiple values;
60+
// we'll parse extra args from this.argv
61+
static strict = false
62+
63+
async run() {
64+
const { flags } = await this.parse(DotfilesAdd)
65+
const isJson = flags.json
66+
const forceEncrypt = flags.encrypt
67+
const forceNoEncrypt = flags['no-encrypt']
68+
69+
// Collect file args from argv (strict=false allows extra positional args)
70+
const rawArgs = this.argv.filter((a) => !a.startsWith('-'))
71+
const fileArgs = rawArgs
72+
73+
// Pre-checks
74+
const config = await loadConfig()
75+
if (!config.dotfiles?.enabled) {
76+
throw new DvmiError(
77+
'Chezmoi dotfiles management is not configured',
78+
'Run `dvmi dotfiles setup` first',
79+
)
80+
}
81+
82+
const chezmoiInstalled = await isChezmoiInstalled()
83+
if (!chezmoiInstalled) {
84+
const platformInfo = await detectPlatform()
85+
const hint = platformInfo.platform === 'macos'
86+
? 'Run `brew install chezmoi` or visit https://chezmoi.io/install'
87+
: 'Run `sh -c "$(curl -fsLS get.chezmoi.io)"` or visit https://chezmoi.io/install'
88+
throw new DvmiError('chezmoi is not installed', hint)
89+
}
90+
91+
const platformInfo = await detectPlatform()
92+
const { platform } = platformInfo
93+
const sensitivePatterns = getSensitivePatterns(config)
94+
95+
// Get already-managed files for V-007 check
96+
const managedFiles = await getManagedFiles()
97+
const managedPaths = new Set(managedFiles.map((f) => f.path))
98+
99+
/** @type {DotfilesAddResult} */
100+
const result = { added: [], skipped: [], rejected: [] }
101+
102+
if (fileArgs.length > 0) {
103+
// Direct mode — files provided as arguments
104+
for (const rawPath of fileArgs) {
105+
const absPath = expandTilde(rawPath)
106+
const displayPath = rawPath
107+
108+
// V-002: WSL2 Windows path rejection
109+
if (platform === 'wsl2' && isWSLWindowsPath(absPath)) {
110+
result.rejected.push({ path: displayPath, reason: 'Windows filesystem paths not supported on WSL2. Use Linux-native paths (~/) instead.' })
111+
continue
112+
}
113+
114+
// V-001: file must exist
115+
if (!existsSync(absPath)) {
116+
result.skipped.push({ path: displayPath, reason: 'File not found' })
117+
continue
118+
}
119+
120+
// V-007: not already managed
121+
if (managedPaths.has(absPath)) {
122+
result.skipped.push({ path: displayPath, reason: 'Already managed by chezmoi' })
123+
continue
124+
}
125+
126+
// Determine encryption
127+
let encrypt = false
128+
if (forceEncrypt) {
129+
encrypt = true
130+
} else if (forceNoEncrypt) {
131+
encrypt = false
132+
} else {
133+
encrypt = isPathSensitive(rawPath, sensitivePatterns)
134+
}
135+
136+
try {
137+
const args = ['add']
138+
if (encrypt) args.push('--encrypt')
139+
args.push(absPath)
140+
await execOrThrow('chezmoi', args)
141+
result.added.push({ path: displayPath, encrypted: encrypt })
142+
} catch {
143+
result.skipped.push({ path: displayPath, reason: `Failed to add to chezmoi. Run \`chezmoi doctor\` to verify your setup.` })
144+
}
145+
}
146+
147+
if (isJson) return result
148+
this.log(formatDotfilesAdd(result))
149+
return result
150+
}
151+
152+
// Interactive mode — no file args
153+
if (isJson) {
154+
// In --json with no files: return empty result
155+
return result
156+
}
157+
158+
// Non-interactive guard for interactive mode
159+
const isCI = process.env.CI === 'true'
160+
const isNonInteractive = !process.stdout.isTTY
161+
if (isCI || isNonInteractive) {
162+
this.error(
163+
'This command requires an interactive terminal (TTY) when no files are specified. Provide file paths as arguments or run with --json.',
164+
{ exit: 1 },
165+
)
166+
}
167+
168+
const spinner = ora({ spinner: 'arc', color: false, text: chalk.hex('#FF6B2B')('Loading recommended files...') }).start()
169+
const recommended = getDefaultFileList(platform)
170+
spinner.stop()
171+
172+
// Filter and build choices
173+
const choices = recommended.map((rec) => {
174+
const absPath = expandTilde(rec.path)
175+
const exists = existsSync(absPath)
176+
const alreadyManaged = managedPaths.has(absPath)
177+
const sensitive = rec.autoEncrypt || isPathSensitive(rec.path, sensitivePatterns)
178+
const encTag = sensitive ? chalk.dim(' (auto-encrypted)') : ''
179+
const statusTag = !exists ? chalk.dim(' (not found)') : alreadyManaged ? chalk.dim(' (already tracked)') : ''
180+
return {
181+
name: `${rec.path}${encTag}${statusTag}${rec.description}`,
182+
value: rec.path,
183+
checked: exists && !alreadyManaged,
184+
disabled: alreadyManaged ? 'already tracked' : false,
185+
}
186+
})
187+
188+
const selected = await checkbox({
189+
message: 'Select files to add to chezmoi:',
190+
choices,
191+
})
192+
193+
// Offer custom file
194+
const addCustom = await confirm({ message: 'Add a custom file path?', default: false })
195+
if (addCustom) {
196+
const customPath = await input({ message: 'Enter file path:' })
197+
if (customPath.trim()) selected.push(customPath.trim())
198+
}
199+
200+
if (selected.length === 0) {
201+
this.log(chalk.dim(' No files selected.'))
202+
return result
203+
}
204+
205+
const addSpinner = ora({ spinner: 'arc', color: false, text: chalk.hex('#FF6B2B')('Adding files to chezmoi...') }).start()
206+
addSpinner.stop()
207+
208+
for (const rawPath of selected) {
209+
const absPath = expandTilde(rawPath)
210+
211+
if (platform === 'wsl2' && isWSLWindowsPath(absPath)) {
212+
result.rejected.push({ path: rawPath, reason: 'Windows filesystem paths not supported on WSL2' })
213+
continue
214+
}
215+
216+
if (!existsSync(absPath)) {
217+
result.skipped.push({ path: rawPath, reason: 'File not found' })
218+
continue
219+
}
220+
221+
if (managedPaths.has(absPath)) {
222+
result.skipped.push({ path: rawPath, reason: 'Already managed by chezmoi' })
223+
continue
224+
}
225+
226+
let encrypt = false
227+
if (forceEncrypt) {
228+
encrypt = true
229+
} else if (forceNoEncrypt) {
230+
encrypt = false
231+
} else {
232+
encrypt = isPathSensitive(rawPath, sensitivePatterns)
233+
}
234+
235+
try {
236+
const args = ['add']
237+
if (encrypt) args.push('--encrypt')
238+
args.push(absPath)
239+
await execOrThrow('chezmoi', args)
240+
result.added.push({ path: rawPath, encrypted: encrypt })
241+
} catch {
242+
result.skipped.push({ path: rawPath, reason: `Failed to add. Run \`chezmoi doctor\` to verify your setup.` })
243+
}
244+
}
245+
246+
this.log(formatDotfilesAdd(result))
247+
return result
248+
}
249+
}

0 commit comments

Comments
 (0)