diff --git a/.gitignore b/.gitignore index 37876a0d..eadb76bf 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ node_modules oclif.manifest.json .env +.codify-files diff --git a/CLAUDE.md b/CLAUDE.md index 47bb7c3c..a939f614 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -184,4 +184,27 @@ Parent Process Plugin Process 3. **Plugin IPC**: Plugins cannot directly read stdin (security isolation) 4. **Sudo Caching**: Password cached in memory during session unless `--secure` flag used 5. **File Watcher**: Use `persistent: false` option to prevent hanging processes -6. **Linting**: ESLint enforces single quotes, specific import ordering, and strict type safety \ No newline at end of file +6. **Linting**: ESLint enforces single quotes, specific import ordering, and strict type safety +7. **Reporter display methods are async**: All `Reporter` interface display methods (`displayPlan`, `displayImportResult`, `displayFileModifications`, `displayMessage`, `displayPluginError`) return `Promise`. Always `await` them at call sites — `DefaultReporter.updateRenderState()` has a 50ms sleep, so unawaited calls cause `process.exit(1)` to fire before the UI renders. +8. **Mock reporter async assertions**: Assertions inside `MockReporter` config callbacks (e.g. `displayFileModifications`) will silently pass if the call isn't awaited. Making display methods async surfaced latent bugs where expected file paths were wrong. + +## Plugin Error Handling Architecture + +Plugin errors flow as structured `PluginErrorData` over IPC and are caught as `PluginError` instances on the CLI side: + +**IPC envelope** (`@codifycli/schemas`): +```typescript +interface PluginErrorData { + errorType: string; // 'apply_validation' | 'sudo_error' | 'unknown' + message: string; + data?: unknown; +} +``` + +**CLI carrier** (`src/common/errors.ts`): `PluginError extends CodifyError` holds `pluginName`, `resourceType`, and `errorData: PluginErrorData`. + +**Reporter as view model**: Reporters (not components) decide how to render each `errorType`. `DefaultReporter.displayPluginError()` branches on `errorType` to set the appropriate `RenderStatus` (`APPLY_VALIDATION_ERROR` with a `ResourcePlan` for plan diffs, `PLUGIN_ERROR` with a message string for generic errors). The `DefaultComponent` is purely display. + +**Shared formatter**: `src/ui/plugin-error-formatter.ts` exports `formatApplyValidationError(error: PluginError): string` used by both `PlainReporter` and `DefaultComponent`. + +**Backward compat**: `plugin.ts#toErrorData()` validates IPC data against `ErrorResponseDataSchema` (AJV); falls back to `{ errorType: 'unknown', message: data }` for old plugins sending bare strings. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 209a76a8..ab8a6b82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,17 @@ { "name": "codify", - "version": "1.0.0-beta9", + "version": "1.1.0-beta6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codify", - "version": "1.0.0-beta9", + "version": "1.1.0-beta6", "license": "Apache-2.0", "dependencies": { "@codifycli/ink-form": "0.0.12", - "@codifycli/schemas": "1.0.0", + "@codifycli/schemas": "1.1.0-beta8", "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5", - "@inkjs/ui": "^2", "@mischnic/json-sourcemap": "^0.1.1", "@oclif/core": "^4.0.8", "@oclif/plugin-autocomplete": "^3.2.24", @@ -51,7 +50,7 @@ "codify": "bin/run.js" }, "devDependencies": { - "@codifycli/plugin-core": "^1.0.0", + "@codifycli/plugin-core": "^1.1.0-beta19", "@oclif/prettier-config": "^0.2.1", "@types/chalk": "^2.2.0", "@types/cors": "^2.8.19", @@ -1090,13 +1089,13 @@ } }, "node_modules/@codifycli/plugin-core": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@codifycli/plugin-core/-/plugin-core-1.0.0.tgz", - "integrity": "sha512-Gq7/ZjNOVGZA+awftbHvuxszt/TmUOVqf1jozkkNieiTGLbQdgc7GWS43p3FPoKgT/2boIf2foAMFH70x8a8eA==", + "version": "1.1.0-beta19", + "resolved": "https://registry.npmjs.org/@codifycli/plugin-core/-/plugin-core-1.1.0-beta19.tgz", + "integrity": "sha512-ci8QU2xn3Zl50EdCA1ymi2KiwDQO43t27fG7cRqBnbCpQZgVtlSyV18xLd3td6rzigVVDNtCSY3a6ZayM7zhpg==", "dev": true, "license": "ISC", "dependencies": { - "@codifycli/schemas": "1.0.0", + "@codifycli/schemas": "^1.1.0-beta8", "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", "ajv": "^8.18.0", "ajv-formats": "^2.1.1", @@ -1104,7 +1103,7 @@ "lodash.isequal": "^4.5.0", "nanoid": "^5.0.9", "strip-ansi": "^7.1.0", - "uuid": "^10.0.0", + "uuid": "^14.0.0", "zod": "4.1.13" }, "bin": { @@ -1147,10 +1146,24 @@ } } }, + "node_modules/@codifycli/plugin-core/node_modules/uuid": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/@codifycli/schemas": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@codifycli/schemas/-/schemas-1.0.0.tgz", - "integrity": "sha512-E7F56uA7DENvQJP4Wnwe1y+gwl5SWcGsbOH4gNNs6FL5BE2WagVDz0jR6/dm1Bfjmg6N0AvROIQJmUaRW+To2g==", + "version": "1.1.0-beta8", + "resolved": "https://registry.npmjs.org/@codifycli/schemas/-/schemas-1.1.0-beta8.tgz", + "integrity": "sha512-2PLCPmU2mtDilqx71uQIjpZLnvqSkdSR+BgImN6eRbRWKJcfltBEONPAlRhRU74kAyURpqCfDSLKTYa1MqLxZw==", "license": "ISC", "dependencies": { "ajv": "^8.18.0" @@ -2121,24 +2134,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@inkjs/ui": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@inkjs/ui/-/ui-2.0.0.tgz", - "integrity": "sha512-5+8fJmwtF9UvikzLfph9sA+LS+l37Ij/szQltkuXLOAXwNkBX9innfzh4pLGXIB59vKEQUtc6D4qGvhD7h3pAg==", - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "cli-spinners": "^3.0.0", - "deepmerge": "^4.3.1", - "figures": "^6.1.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "ink": ">=5" - } - }, "node_modules/@inquirer/ansi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", @@ -7783,15 +7778,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/default-browser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", diff --git a/package.json b/package.json index 359bf79f..eb66bd0b 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,8 @@ }, "dependencies": { "@codifycli/ink-form": "0.0.12", - "@codifycli/schemas": "1.0.0", + "@codifycli/schemas": "1.1.0-beta8", "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5", - "@inkjs/ui": "^2", "@mischnic/json-sourcemap": "^0.1.1", "@oclif/core": "^4.0.8", "@oclif/plugin-autocomplete": "^3.2.24", @@ -44,7 +43,7 @@ }, "description": "Codify is a configuration-as-code tool that declaratively installs and manages developer tools and applications. Check out https://dashboard.codifycli.com for an editor.", "devDependencies": { - "@codifycli/plugin-core": "^1.0.0", + "@codifycli/plugin-core": "^1.1.0-beta19", "@oclif/prettier-config": "^0.2.1", "@types/chalk": "^2.2.0", "@types/cors": "^2.8.19", @@ -128,6 +127,7 @@ }, "repository": "codifycli/codify", "scripts": { + "postinstall": "[ -f node_modules/oclif/lib/tarballs/bin.js ] && tsx scripts/patch-oclif.ts || true", "build": "shx rm -rf dist && tsc -b", "build:release": "npm run pkg && ./scripts/notarize.sh", "lint": "tsc", @@ -145,7 +145,7 @@ "deploy": "npm run pkg && npm run notarize && npm run upload", "prepublishOnly": "npm run build" }, - "version": "1.0.2", + "version": "1.1.0-beta.3", "bugs": "https://github.com/codifycli/codify/issues", "keywords": [ "oclif", diff --git a/scripts/install-beta.sh b/scripts/install-beta.sh index 2106318a..b0fa70ce 100644 --- a/scripts/install-beta.sh +++ b/scripts/install-beta.sh @@ -44,6 +44,8 @@ fi mkdir -p /usr/local/lib + mkdir -p /usr/local/bin + cd /usr/local/lib rm -rf codify rm -rf ~/.local/share/codify/client diff --git a/scripts/install.sh b/scripts/install.sh index df3d3d79..46260f50 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -44,6 +44,8 @@ fi mkdir -p /usr/local/lib + mkdir -p /usr/local/bin + cd /usr/local/lib rm -rf codify rm -rf ~/.local/share/codify/client diff --git a/scripts/patch-oclif.ts b/scripts/patch-oclif.ts new file mode 100644 index 00000000..822d233f --- /dev/null +++ b/scripts/patch-oclif.ts @@ -0,0 +1,132 @@ +// Patches node_modules/oclif/lib/tarballs/bin.js to inject bash logic into the shell script +// that oclif generates during `oclif pack tarballs`. This runs via the `postinstall` npm script +// so it re-applies automatically after any `npm install` that updates oclif. +// +// Why: Node.js takes 500ms–1s to start. By handling simple cases in the shell script we can +// give instant feedback before Node launches. +// +// What the injected bash does (inside the else block, before the "$NODE ... $DIR/run" line): +// - codify --help / -h → cats dist/static/help.txt and exits (no Node startup) +// - codify --help / -h → cats dist/static/-help.txt and exits +// - codify --version / -v → cats dist/static/version.txt and exits +// - codify apply/destroy/plan → prints "Running Codify ..." immediately +// (suppressed when --output json or -o json is passed) +// - everything else → falls through to normal Node.js launch +// +// Static files (dist/static/*.txt) are generated in scripts/pkg.ts after the esbuild step. +// Missing static files are guarded by [ -f ] so all cases fall back to Node gracefully. +// +// Note: console.log('Running Codify apply/destroy...') was removed from src/commands/apply.ts +// and src/commands/destroy.ts to prevent double-printing (shell prints first, Node would repeat it). +// +// Also patches node_modules/oclif/lib/commands/pack/macos.js to add +// `sudo rm -rf ~/.local/share/codify` to the macOS installer's preinstall script. +// This fixes an oclif bug where the auto-updater cache (~/.local/share/codify) isn't cleared +// on fresh installs, causing the old cached version to be used. The patch must happen before +// `oclif pack macos` runs — modifying the .pkg after the fact breaks notarization. +// +// If oclif upgrades and changes either file's structure, this script exits with code 1 so the +// breakage is immediately visible. +import { existsSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BIN_JS = path.join(__dirname, '../node_modules/oclif/lib/tarballs/bin.js'); +const MACOS_JS = path.join(__dirname, '../node_modules/oclif/lib/commands/pack/macos.js'); + +if (!existsSync(BIN_JS)) { + console.log('oclif bin.js not found (likely production install). Skipping.'); + process.exit(0); +} + +let content = await fs.readFile(BIN_JS, 'utf8'); + +if (content.includes('CODIFY_PATCH_START')) { + console.log('Removing existing patch to reapply...'); + content = content.replace(/ # CODIFY_PATCH_START[\s\S]*?# CODIFY_PATCH_END[^\n]*\n/, ''); +} + +const SEARCH = ' if [ "\\$DEBUG" == "*" ]; then\n echoerr'; +const idx = content.lastIndexOf(SEARCH); +if (idx === -1) { + console.error('ERROR: Could not find insertion point in oclif bin.js. The oclif version may have changed.'); + process.exit(1); +} + +// Patch uses \\$ so that it survives the JS string — in the generated shell script each \\$ becomes \$ +// which bash then interprets as a literal $ (not a template substitution in the JS template literal). +// Bash default-value syntax ${1:-} is avoided since ${...} would be evaluated as a JS template expression. +const PATCH = ` # CODIFY_PATCH_START — do not remove this marker + _first_arg="" + if [ "\\$#" -gt 0 ]; then _first_arg="\\$1"; fi + _second_arg="" + if [ "\\$#" -gt 1 ]; then _second_arg="\\$2"; fi + if [ "\\$_first_arg" = "--help" ] || [ "\\$_first_arg" = "-h" ]; then + _help_file="\\$DIR/../dist/static/help.txt" + if [ -f "\\$_help_file" ]; then cat "\\$_help_file"; exit 0; fi + fi + if [ "\\$_second_arg" = "--help" ] || [ "\\$_second_arg" = "-h" ]; then + _cmd_help_file="\\$DIR/../dist/static/\\$_first_arg-help.txt" + if [ -f "\\$_cmd_help_file" ]; then cat "\\$_cmd_help_file"; exit 0; fi + fi + if [ "\\$_first_arg" = "--version" ] || [ "\\$_first_arg" = "-v" ] || [ "\\$_first_arg" = "version" ]; then + _version_file="\\$DIR/../dist/static/version.txt" + if [ -f "\\$_version_file" ]; then cat "\\$_version_file"; exit 0; fi + fi + _cmd="\\$_first_arg" + if [ "\\$_cmd" = "apply" ] || [ "\\$_cmd" = "destroy" ] || [ "\\$_cmd" = "plan" ]; then + _json_output=0 + _prev="" + for _a in "\\$@"; do + if [ "\\$_a" = "--output=json" ] || [ "\\$_a" = "-o=json" ]; then _json_output=1; break; fi + if [ "\\$_prev" = "--output" ] || [ "\\$_prev" = "-o" ]; then + if [ "\\$_a" = "json" ]; then _json_output=1; break; fi + fi + _prev="\\$_a" + done + if [ "\\$_json_output" -eq 0 ]; then echo "Running Codify \\$_cmd..."; fi + fi + # CODIFY_PATCH_END — do not remove this marker +`; + +const patched = content.slice(0, idx) + PATCH + content.slice(idx); + +// Use exec to replace the shell process with Node rather than spawning a child. +// This avoids an extra process in memory and ensures signals go directly to Node. +const NODE_LAUNCH = ' "\\$NODE" '; +const NODE_LAUNCH_EXEC = ' exec "\\$NODE" '; +let withExec = patched; +if (patched.includes(NODE_LAUNCH) && !patched.includes(NODE_LAUNCH_EXEC)) { + withExec = patched.replace(NODE_LAUNCH, NODE_LAUNCH_EXEC); +} else if (!patched.includes(NODE_LAUNCH_EXEC)) { + console.error('ERROR: Could not find Node launch line to add exec. The oclif version may have changed.'); + process.exit(1); +} + +await fs.writeFile(BIN_JS, withExec, 'utf8'); +console.log('Successfully patched oclif bin.js'); + +// Patch macos.js preinstall script to also clear the auto-updater cache directory. +// Oclif's auto-updater stores binaries in ~/.local/share/codify and the macOS installer +// doesn't clean this up, so fresh installs still run the old cached version. +// We must patch the template before `oclif pack macos` runs — modifying the .pkg after +// the fact breaks notarization since the binary has been tampered with. +const SEARCH_PREINSTALL = 'sudo rm -rf /usr/local/bin/${config.bin}\n${additionalCLI'; +const PATCH_PREINSTALL = 'sudo rm -rf /usr/local/bin/${config.bin}\nsudo rm -rf ~/.local/share/${config.dirname}\n${additionalCLI'; + +if (!existsSync(MACOS_JS)) { + console.log('oclif macos.js not found. Skipping preinstall patch.'); +} else { + const macosContent = await fs.readFile(MACOS_JS, 'utf8'); + if (macosContent.includes(PATCH_PREINSTALL)) { + console.log('oclif macos.js preinstall already patched. Skipping.'); + } else if (!macosContent.includes(SEARCH_PREINSTALL)) { + console.error('ERROR: Could not find preinstall insertion point in oclif macos.js. The oclif version may have changed.'); + process.exit(1); + } else { + await fs.writeFile(MACOS_JS, macosContent.replace(SEARCH_PREINSTALL, PATCH_PREINSTALL), 'utf8'); + console.log('Successfully patched oclif macos.js preinstall script'); + } +} diff --git a/scripts/pkg.ts b/scripts/pkg.ts index 2a308681..543d909a 100644 --- a/scripts/pkg.ts +++ b/scripts/pkg.ts @@ -30,12 +30,34 @@ await Promise.all([ console.log(chalk.magenta('Esbuild src')) execSync('tsx esbuild.ts', { shell: 'zsh' }) +console.log(chalk.magenta('Generating static help/version files')) +await fs.mkdir('./.build/dist/static', { recursive: true }); +const helpOutput = execSync('./bin/dev.js --help', { + shell: 'zsh', + env: { ...process.env, FORCE_COLOR: '1' }, +}).toString(); +const versionOutput = execSync('./bin/dev.js --version', { shell: 'zsh' }).toString().trim(); +await fs.writeFile('./.build/dist/static/help.txt', helpOutput, 'utf8'); +await fs.writeFile('./.build/dist/static/version.txt', versionOutput + '\n', 'utf8'); + +const commandFiles = await fs.readdir('./src/commands'); +const commands = commandFiles + .filter(f => f.endsWith('.ts') && !f.startsWith('index')) + .map(f => f.replace(/\.ts$/, '')); +for (const cmd of commands) { + const cmdHelp = execSync(`./bin/dev.js ${cmd} --help`, { + shell: 'zsh', + env: { ...process.env, FORCE_COLOR: '1' }, + }).toString(); + await fs.writeFile(`./.build/dist/static/${cmd}-help.txt`, cmdHelp, 'utf8'); +} +console.log(chalk.magenta(`Generated help files for: ${commands.join(', ')}`)) + console.log(chalk.magenta('Install production dependencies')) execSync('npm install --production', { cwd: './.build', shell: 'zsh' }) console.log(chalk.magenta('Running oclif pkg macos')) execSync('oclif pack macos -r .', { cwd: './.build', shell: 'zsh' }); -await patchMacOsInstallers() console.log(chalk.magenta('Running oclif pkg tarballs')) execSync('oclif pack tarballs -r . -t darwin-arm64,darwin-x64,linux-x64,linux-arm64', { cwd: './.build', shell: 'zsh' }) @@ -51,25 +73,3 @@ async function ignoreError(fn: () => Promise | any): Promise { } catch (e) { } } - -// Oclif has a bug where the installer doesn't clear out the auto-updater location. This causes older versions -// to be re-used even with a clean install -// Comment this out because it does not work with MacOS notary tool. It fails verification -async function patchMacOsInstallers() { - // console.log(chalk.magenta('Patching MacOS installers with bug fix')) - // - // const pkgFolder = './.build/dist/macos'; - // const files = await fs.readdir(pkgFolder) - // const pkgFiles = files.filter((name) => name.endsWith('.pkg')) - // - // for (const pkgFile of pkgFiles) { - // const pkgPath = path.join(pkgFolder, pkgFile); - // const tmpPath = path.join(pkgFolder, 'tmp'); - // - // execSync(`pkgutil --expand ${pkgPath} ${tmpPath}`) - // await fs.appendFile(path.join(tmpPath, 'Scripts', 'preinstall'), '\nsudo rm -rf ~/.local/share/codify', 'utf8'); - // execSync(`pkgutil --flatten ${tmpPath} ${pkgPath} `) - // execSync(`rm -rf ${tmpPath}`); - // console.log(chalk.magenta(`Done patching installer ${pkgFile}`)) - // } -} diff --git a/src/api/backend/index.ts b/src/api/backend/index.ts index 3272bcd1..74e4384c 100644 --- a/src/api/backend/index.ts +++ b/src/api/backend/index.ts @@ -9,10 +9,10 @@ import { PluginSearchQuery, PluginSearchResult } from './types.js'; const API_BASE_URL = 'https://api.codifycli.com' export const ApiClient = { - async searchPlugins(query: PluginSearchQuery[]): Promise { - const body = JSON.stringify({ query }); + async searchPlugins(query: PluginSearchQuery[], cliVersion: string): Promise { + const body = JSON.stringify({ query, cliVersion }); const res = await fetch( - `${API_BASE_URL}/v1/plugins/versions/search`, + `${API_BASE_URL}/v2/plugins/versions/search`, { method: 'POST', body, headers: { 'Content-Type': 'application/json' } } ); diff --git a/src/commands/apply.ts b/src/commands/apply.ts index d90bc887..7765b3ff 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -25,6 +25,19 @@ For more information, visit: https://codifycli.com/docs/commands/apply description: 'Automatically use this password for any handlers that require elevated permissions.', char: 'S' }), + 'yes': Flags.boolean({ + description: 'Automatically approve the apply without prompting for confirmation.', + char: 'y', + default: false, + }), + 'verbose': Flags.boolean({ + char: 'v', + description: 'Print plugin output (stdout/stderr) to the terminal.', + }), + 'allow-sleep': Flags.boolean({ + description: 'Allow the system to sleep during apply operations.', + default: false, + }), } static args = { @@ -38,11 +51,6 @@ For more information, visit: https://codifycli.com/docs/commands/apply '<%= config.bin %> <%= command.id %> -S ', ] - async init(): Promise { - console.log('Running Codify apply...') - return super.init(); - } - public async run(): Promise { const { flags, args } = await this.parse(Apply) @@ -52,7 +60,9 @@ For more information, visit: https://codifycli.com/docs/commands/apply await ApplyOrchestrator.run({ path: flags.path ?? args.pathArgs, - verbosityLevel: flags.debug ? 3 : 0, + verbosityLevel: flags.debug || flags.verbose ? 3 : 0, + autoApprove: flags.yes, + allowSleep: flags['allow-sleep'], // secure: flags.secure, }, this.reporter); diff --git a/src/commands/destroy.ts b/src/commands/destroy.ts index 5338e6c8..9ceb014d 100644 --- a/src/commands/destroy.ts +++ b/src/commands/destroy.ts @@ -33,8 +33,21 @@ For more information, visit: https://codifycli.com/docs/commands/destory` char: 'S', helpValue: '' }), + 'yes': Flags.boolean({ + description: 'Automatically approve the destroy without prompting for confirmation.', + char: 'y', + default: false, + }), + 'verbose': Flags.boolean({ + char: 'v', + description: 'Print plugin output (stdout/stderr) to the terminal.', + }), + 'allow-sleep': Flags.boolean({ + description: 'Allow the system to sleep during destroy operations.', + default: false, + }), } - + public async run(): Promise { const { flags, raw } = await this.parse(Destroy) @@ -42,14 +55,12 @@ For more information, visit: https://codifycli.com/docs/commands/destory` .filter((r) => r.type === 'arg') .map((r) => r.input); - if (flags.path) { - this.log(`Applying Codify from: ${flags.path}`); - } - await DestroyOrchestrator.run({ - verbosityLevel: flags.debug ? 3 : 0, + verbosityLevel: flags.debug || flags.verbose ? 3 : 0, typeIds: args, path: flags.path, + autoApprove: flags.yes, + allowSleep: flags['allow-sleep'], }, this.reporter) process.exit(0); diff --git a/src/commands/edit.ts b/src/commands/edit.ts index d8e4c6af..fc0c7e32 100644 --- a/src/commands/edit.ts +++ b/src/commands/edit.ts @@ -21,5 +21,6 @@ For more information, visit: https://codifycli.com/docs/commands/edit await EditOrchestrator.run(rootCommand, this.reporter); + process.exit(0); } } diff --git a/src/commands/init.ts b/src/commands/init.ts index ad494e36..e8ed32b5 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -21,6 +21,9 @@ For more information, visit: https://codifycli.com/docs/commands/init` includeSensitive: Flags.boolean({ description: 'Include sensitive resources in the generated configs.', }), + skipBanner: Flags.boolean({ + description: 'Skip the initial confirmation prompt.', + }), } static override examples = [ @@ -34,6 +37,7 @@ For more information, visit: https://codifycli.com/docs/commands/init` verbosityLevel: flags.debug ? 3 : 0, path: flags.path, includeSensitive: flags.includeSensitive, + skipBanner: flags.skipBanner, },this.reporter); process.exit(0) diff --git a/src/common/base-command.ts b/src/common/base-command.ts index 22e3083e..a0a4a597 100644 --- a/src/common/base-command.ts +++ b/src/common/base-command.ts @@ -5,11 +5,13 @@ import { CommandRequestData, PressKeyToContinueRequestData } from '@codifycli/sc import createDebug from 'debug'; import { LoginHelper } from '../connect/login-helper.js'; -import { Event, ctx } from '../events/context.js'; +import { ctx, Event } from '../events/context.js'; import { LoginOrchestrator } from '../orchestrators/login.js'; +import { DefaultReporter } from '../ui/reporters/default-reporter.js'; import { Reporter, ReporterFactory, ReporterType } from '../ui/reporters/reporter.js'; import { spawnSafe } from '../utils/spawn.js'; -import { prettyPrintError } from './errors.js'; +import { SudoUtils } from '../utils/sudo.js'; +import { PluginError, prettyPrintError } from './errors.js'; export abstract class BaseCommand extends Command { static baseFlags = { @@ -18,9 +20,8 @@ export abstract class BaseCommand extends Command { }), 'output': Flags.option({ char: 'o', - default: 'default', options: ['plain', 'default', 'json'], - description: 'Control the output format.', + description: 'Control the output format. Default to default and plain for non-tty environments. Use json for scripts', })(), path: Flags.string({ char: 'p', description: 'Path to run Codify from.' }), } @@ -41,30 +42,43 @@ export abstract class BaseCommand extends Command { createDebug.enable('*'); } - const reporterType = this.getReporterType(flags); + const reporterType = this.getReporterType(flags) this.reporter = ReporterFactory.create(reporterType) + let cachedSudoPassword: string | null = flags.sudoPassword ?? null; + + if (this.reporter instanceof DefaultReporter) { + if (cachedSudoPassword !== null) { + this.reporter.setSudoPasswordCached(); + } + + this.reporter.onSudoPasswordSubmitted(async (password: string) => { + const isValid = await SudoUtils.validate(password); + if (isValid) { + cachedSudoPassword = password; + } + return isValid; + }); + } + if (flags.secure) { console.log(chalk.blue('Running Codify in secure mode. Sudo will be prompted every time')); } ctx.on(Event.COMMAND_REQUEST, async (pluginName: string, data: CommandRequestData) => { try { - const password = data.options.requiresRoot - ? (flags.sudoPassword) ?? (await this.reporter.promptSudo(pluginName, data, flags.secure)) - : undefined; - - // We print that we used sudo everytime even if the user provides it in the beginning - if (flags.sudoPassword && data.options.requiresRoot) { - console.log(chalk.blue(`Plugin: "${pluginName}" requires root access to run command: "sudo ${data.command}"`)); + let password = undefined; + if (data.options.requiresRoot || data.options.requiresSudoAskpass) { + if (flags.secure || !cachedSudoPassword) { + password = (await this.reporter.promptSudo(pluginName, data)) + } else { + password = cachedSudoPassword + } } if (data.options.stdin) { - await this.reporter.hide(); console.log(chalk.blue(`Plugin "${pluginName}" is requesting stdin`)); - - // Raw mode is needed by stdin applications to function properly - process.stdin.setRawMode(true); + await this.reporter.setRawMode(); } const result = await spawnSafe(data.command, data.options, pluginName, password) @@ -76,8 +90,7 @@ export abstract class BaseCommand extends Command { } finally { // Always disable raw mode after if (data.options.stdin) { - process.stdin.setRawMode(false); - await this.reporter.displayProgress(); + await this.reporter.disableRawMode(); } } }); @@ -132,6 +145,11 @@ export abstract class BaseCommand extends Command { } protected async catch(err: Error): Promise { + if (err instanceof PluginError && this.reporter) { + await this.reporter.displayPluginError(err); + process.exit(1); + } + prettyPrintError(err); process.exit(1); } @@ -157,6 +175,8 @@ export abstract class BaseCommand extends Command { } } - return ReporterType.DEFAULT; + if (!process.stdin.isTTY) console.log('Running in non-TTY shell. Defaulting to plain output.') + + return !process.stdin.isTTY ? ReporterType.PLAIN : ReporterType.DEFAULT; } } diff --git a/src/common/errors.ts b/src/common/errors.ts index 14c9a3f4..c3acaca3 100644 --- a/src/common/errors.ts +++ b/src/common/errors.ts @@ -1,5 +1,6 @@ import { ErrorObject } from 'ajv'; import chalk from 'chalk'; +import { PluginErrorData } from '@codifycli/schemas'; import { ResourceConfig } from '../entities/resource-config.js'; import { SourceMapCache } from '../parser/source-maps.js'; @@ -231,6 +232,24 @@ export class SpawnError extends CodifyError { } } +export class PluginError extends CodifyError { + name = 'PluginError'; + pluginName: string; + resourceType: string; + errorData: PluginErrorData; + + constructor(pluginName: string, resourceType: string, errorData: PluginErrorData) { + super(errorData.message); + this.pluginName = pluginName; + this.resourceType = resourceType; + this.errorData = errorData; + } + + formattedMessage(): string { + return this.message; + } +} + export function prettyPrintError(error: unknown): void { if (error instanceof CodifyError) { return console.error(chalk.red(error.formattedMessage())); diff --git a/src/common/initialize-plugins.ts b/src/common/initialize-plugins.ts index 6e8a7083..39cb5e95 100644 --- a/src/common/initialize-plugins.ts +++ b/src/common/initialize-plugins.ts @@ -45,6 +45,7 @@ export class PluginInitOrchestrator { if (!args.noProgress) ctx.subprocessFinished(SubProcessName.INITIALIZE_PLUGINS) project.removeResourcesUsingOsFilter(); + await project.removeResourcesUsingDistroFilter(); return { resourceDefinitions, pluginManager, project }; } diff --git a/src/connect/http-routes/create-command.ts b/src/connect/http-routes/create-command.ts index af8e5d4b..fab722d7 100644 --- a/src/connect/http-routes/create-command.ts +++ b/src/connect/http-routes/create-command.ts @@ -12,6 +12,7 @@ export enum ConnectCommand { PLAN = 'plan', IMPORT = 'import', REFRESH = 'refresh', + DESTROY = 'destroy', INIT = 'init', TEST = 'test', } @@ -80,7 +81,7 @@ export function createCommandHandler({ name, command, spawnCommand, onExit }: Pa }) pty.onExit(async ({ exitCode, signal }) => { - console.log(`Command ${name} exited with exit code`, exitCode); + console.log(`Command ${name} ( ${sessionId} ) exited with exit code`, exitCode); ws.send(Buffer.from(chalk.blue(`Session ended exit code ${exitCode}`), 'utf8')) const mainWs = SocketServer.get().getMainConnection(clientId); @@ -89,7 +90,7 @@ export function createCommandHandler({ name, command, spawnCommand, onExit }: Pa mainWs.send(JSON.stringify({ key: `finish:${sessionId}`, success: exitCode === 0, data })) // Send finish command only if client connection is still open } - ws.terminate(); + ws.close(); server.close(); }) diff --git a/src/connect/http-routes/handlers/destroy-handler.ts b/src/connect/http-routes/handlers/destroy-handler.ts new file mode 100644 index 00000000..3d3eadc1 --- /dev/null +++ b/src/connect/http-routes/handlers/destroy-handler.ts @@ -0,0 +1,53 @@ +import { spawn } from '@homebridge/node-pty-prebuilt-multiarch'; +import { ConfigFileSchema } from '@codifycli/schemas'; +import * as fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { WebSocket } from 'ws'; + +import { ConnectOrchestrator } from '../../../orchestrators/connect.js'; +import { ajv } from '../../../utils/ajv.js'; +import { ShellUtils } from '../../../utils/shell.js'; +import { Session } from '../../socket-server.js'; +import { ConnectCommand, createCommandHandler } from '../create-command.js'; + +const validator = ajv.compile(ConfigFileSchema); + +export function destroyHandler() { + const spawnCommand = async (body: Record, ws: WebSocket, session: Session) => { + const codifyConfig = body.config; + if (!codifyConfig) { + throw new Error('Unable to parse codify config'); + } + + if (!validator(codifyConfig)) { + throw new Error('Invalid codify config'); + } + + const tmpDir = await fs.mkdtemp(os.tmpdir() + '/'); + const filePath = path.join(tmpDir, 'codify.jsonc'); + await fs.writeFile(filePath, JSON.stringify(codifyConfig, null, 2)); + + session.additionalData.filePath = filePath; + + return spawn(ShellUtils.getDefaultShell(), ['-c', `${ConnectOrchestrator.nodeBinary} ${ConnectOrchestrator.rootCommand} destroy -p ${filePath}`], { + name: 'xterm-color', + cols: 80, + rows: 30, + cwd: process.env.HOME, + env: process.env + }); + } + + const onExit = async (exitCode: number, ws: WebSocket, session: Session) => { + if (session.additionalData.filePath) { + await fs.rm(session.additionalData.filePath as string, { recursive: true, force: true }); + } + } + + return createCommandHandler({ + name: ConnectCommand.DESTROY, + spawnCommand, + onExit + }); +} diff --git a/src/connect/http-routes/handlers/init-handler.ts b/src/connect/http-routes/handlers/init-handler.ts index 3d68dad2..d3be9d20 100644 --- a/src/connect/http-routes/handlers/init-handler.ts +++ b/src/connect/http-routes/handlers/init-handler.ts @@ -18,7 +18,8 @@ export function initHandler() { session.additionalData.filePath = filePath; session.additionalData.existingFile = '[]'; - return spawn(ShellUtils.getDefaultShell(), ['-c', `${ConnectOrchestrator.nodeBinary} ${ConnectOrchestrator.rootCommand} init -p ${filePath}`], { + const sensitiveFlag = body.includeSensitive ? ' --includeSensitive' : ''; + return spawn(ShellUtils.getDefaultShell(), ['-c', `${ConnectOrchestrator.nodeBinary} ${ConnectOrchestrator.rootCommand} init -p ${filePath}${sensitiveFlag} --skipBanner`], { name: 'xterm-color', cols: 80, rows: 30, diff --git a/src/connect/http-routes/handlers/resize-handler.ts b/src/connect/http-routes/handlers/resize-handler.ts new file mode 100644 index 00000000..d817af55 --- /dev/null +++ b/src/connect/http-routes/handlers/resize-handler.ts @@ -0,0 +1,32 @@ +import { Router } from 'express'; +import { SocketServer } from '../../socket-server.js'; + +export function resizeHandler(): Router { + const router = Router({ mergeParams: true }); + + router.post('/:sessionId', (req, res) => { + const { sessionId } = req.params; + const { cols, rows } = req.body; + + if (!cols || !rows) { + return res.status(400).json({ error: 'cols and rows required' }); + } + + const manager = SocketServer.get(); + const session = manager.getSession(sessionId); + + if (!session) { + return res.status(400).json({ error: 'SessionId does not exist' }); + } + + if (!session.pty) { + return res.status(304).json({ status: 'PTY not yet started' }); + } + + session.pty.resize(cols, rows); + + return res.status(204).json({}); + }); + + return router; +} diff --git a/src/connect/http-routes/router.ts b/src/connect/http-routes/router.ts index 98f44d5a..227b9537 100644 --- a/src/connect/http-routes/router.ts +++ b/src/connect/http-routes/router.ts @@ -1,11 +1,13 @@ import { Router } from 'express'; import { applyHandler } from './handlers/apply-handler.js'; +import { destroyHandler } from './handlers/destroy-handler.js'; import { importHandler } from './handlers/import-handler.js'; import defaultHandler from './handlers/index.js'; import { initHandler } from './handlers/init-handler.js'; import { planHandler } from './handlers/plan-handler.js'; import { refreshHandler } from './handlers/refresh-handler.js'; +import { resizeHandler } from './handlers/resize-handler.js'; import { terminalHandler } from './handlers/terminal-handler.js'; import { testHandler } from './handlers/test-handler.js'; @@ -13,9 +15,11 @@ const router = Router(); router.use('/', defaultHandler); router.use('/apply', applyHandler()); +router.use('/destroy', destroyHandler()); router.use('/plan', planHandler()) router.use('/import', importHandler()); router.use('/refresh', refreshHandler()); +router.use('/resize', resizeHandler()); router.use('/terminal', terminalHandler()); router.use('/init', initHandler()); router.use('/test', testHandler()); diff --git a/src/connect/socket-server.ts b/src/connect/socket-server.ts index aedd7c37..69344914 100644 --- a/src/connect/socket-server.ts +++ b/src/connect/socket-server.ts @@ -114,7 +114,7 @@ export class SocketServer { }); wss.on('close', () => { - console.log('Session closed'); + console.log('Session closed', sessionId); this.sessions.delete(sessionId); }) } diff --git a/src/entities/apply-result.ts b/src/entities/apply-result.ts new file mode 100644 index 00000000..656424de --- /dev/null +++ b/src/entities/apply-result.ts @@ -0,0 +1,53 @@ +import { ResourceOperation } from '@codifycli/schemas'; + +import { PluginError } from '../common/errors.js'; +import { ResourcePlan } from './plan.js'; + +export interface ApplyResultEntry { + id: string; + operation: ResourceOperation; + status: 'success' | 'failed' | 'skipped'; + error?: PluginError; +} + +export interface ApplyResult { + entries: ApplyResultEntry[]; + errors: PluginError[]; + + isPartialFailure(): boolean; +} + +export function createApplyResult( + succeededPlans: ResourcePlan[], + failedErrors: PluginError[], + skippedIds: Set, +): ApplyResult { + const failedByType = new Map(failedErrors.map((e) => [e.resourceType, e])); + + const entries: ApplyResultEntry[] = [ + ...succeededPlans.map((p) => ({ + id: p.id, + operation: p.operation, + status: 'success' as const, + })), + ...failedErrors.map((e) => ({ + id: e.resourceType, + operation: ResourceOperation.NOOP, + status: 'failed' as const, + error: e, + })), + ...[...skippedIds].map((id) => ({ + id, + operation: ResourceOperation.NOOP, + status: 'skipped' as const, + })), + ]; + + return { + entries, + errors: failedErrors, + isPartialFailure() { + return failedErrors.length > 0; + }, + }; +} diff --git a/src/entities/plan.ts b/src/entities/plan.ts index 704a342d..58ceb136 100644 --- a/src/entities/plan.ts +++ b/src/entities/plan.ts @@ -54,6 +54,30 @@ export class Plan { return this.raw.every((r) => r.operation === ResourceOperation.NOOP); } + computeTransitiveDependents(failedId: string): Set { + const reverseDeps = new Map>(); + for (const r of this.project.resourceConfigs) { + for (const depId of r.dependencyIds) { + if (!reverseDeps.has(depId)) reverseDeps.set(depId, new Set()); + reverseDeps.get(depId)!.add(r.id); + } + } + + const toSkip = new Set(); + const queue = [failedId]; + while (queue.length > 0) { + const current = queue.shift()!; + const dependents = reverseDeps.get(current) ?? new Set(); + for (const dep of dependents) { + if (!toSkip.has(dep)) { + toSkip.add(dep); + queue.push(dep); + } + } + } + return toSkip; + } + *[Symbol.iterator](): Iterator { for (const resource of this.resources) { yield resource; diff --git a/src/entities/project.test.ts b/src/entities/project.test.ts index ec849f67..a8db575e 100644 --- a/src/entities/project.test.ts +++ b/src/entities/project.test.ts @@ -1,7 +1,17 @@ -import { describe, expect, it } from 'vitest'; +import { LinuxDistro, ResourceOs } from '@codifycli/schemas'; +import { describe, expect, it, vi } from 'vitest'; + +import { OsUtils } from '../utils/os-utils.js'; import { Project } from './project.js'; import { ResourceConfig } from './resource-config.js'; -import { InMemoryFile } from '../parser/entities'; + +function makeResource(type: string, os?: ResourceOs[], distro?: LinuxDistro[]): ResourceConfig { + return new ResourceConfig({ type, ...(os ? { os } : {}), ...(distro ? { distro } : {}) }); +} + +function makeProject(...configs: ResourceConfig[]): Project { + return new Project(null, configs, []); +} describe('Project Unit Tests', () => { it('Can add unique names for duplicate resources', async () => { @@ -26,4 +36,219 @@ describe('Project Unit Tests', () => { // expect(project.resourceConfigs[3].id).to.eq('other') }) -}) + describe('removeResourcesUsingOsFilter', () => { + it('keeps resources with no os filter', () => { + vi.spyOn(OsUtils, 'getOs').mockReturnValue(ResourceOs.MACOS); + + const project = makeProject( + makeResource('tool-a'), + makeResource('tool-b'), + ); + + project.removeResourcesUsingOsFilter(); + + expect(project.resourceConfigs).toHaveLength(2); + }); + + it('keeps resources that match the current os', () => { + vi.spyOn(OsUtils, 'getOs').mockReturnValue(ResourceOs.MACOS); + + const project = makeProject( + makeResource('mac-tool', [ResourceOs.MACOS]), + makeResource('linux-tool', [ResourceOs.LINUX]), + ); + + project.removeResourcesUsingOsFilter(); + + expect(project.resourceConfigs).toHaveLength(1); + expect(project.resourceConfigs[0].type).toBe('mac-tool'); + }); + + it('keeps resources that list multiple os including the current one', () => { + vi.spyOn(OsUtils, 'getOs').mockReturnValue(ResourceOs.LINUX); + + const project = makeProject( + makeResource('cross-platform', [ResourceOs.MACOS, ResourceOs.LINUX]), + ); + + project.removeResourcesUsingOsFilter(); + + expect(project.resourceConfigs).toHaveLength(1); + }); + + it('removes resources whose os does not match the current os', () => { + vi.spyOn(OsUtils, 'getOs').mockReturnValue(ResourceOs.WINDOWS); + + const project = makeProject( + makeResource('mac-only', [ResourceOs.MACOS]), + makeResource('linux-only', [ResourceOs.LINUX]), + ); + + project.removeResourcesUsingOsFilter(); + + expect(project.resourceConfigs).toHaveLength(0); + }); + }); + + describe('removeResourcesUsingDistroFilter', () => { + it('does nothing on macOS (not Linux)', async () => { + vi.spyOn(OsUtils, 'isLinux').mockReturnValue(false); + + const project = makeProject( + makeResource('tool', undefined, [LinuxDistro.UBUNTU]), + ); + + await project.removeResourcesUsingDistroFilter(); + + expect(project.resourceConfigs).toHaveLength(1); + }); + + it('does nothing when distro cannot be determined', async () => { + vi.spyOn(OsUtils, 'isLinux').mockReturnValue(true); + vi.spyOn(OsUtils, 'getLinuxDistro').mockResolvedValue(undefined); + + const project = makeProject( + makeResource('tool', undefined, [LinuxDistro.UBUNTU]), + ); + + await project.removeResourcesUsingDistroFilter(); + + expect(project.resourceConfigs).toHaveLength(1); + }); + + it('keeps resources with no distro filter', async () => { + vi.spyOn(OsUtils, 'isLinux').mockReturnValue(true); + vi.spyOn(OsUtils, 'getLinuxDistro').mockResolvedValue(LinuxDistro.UBUNTU); + + const project = makeProject( + makeResource('tool-a'), + makeResource('tool-b'), + ); + + await project.removeResourcesUsingDistroFilter(); + + expect(project.resourceConfigs).toHaveLength(2); + }); + + it('keeps resources that match the current distro exactly', async () => { + vi.spyOn(OsUtils, 'isLinux').mockReturnValue(true); + vi.spyOn(OsUtils, 'getLinuxDistro').mockResolvedValue(LinuxDistro.UBUNTU); + + const project = makeProject( + makeResource('ubuntu-tool', undefined, [LinuxDistro.UBUNTU]), + makeResource('arch-tool', undefined, [LinuxDistro.ARCH]), + ); + + await project.removeResourcesUsingDistroFilter(); + + expect(project.resourceConfigs).toHaveLength(1); + expect(project.resourceConfigs[0].type).toBe('ubuntu-tool'); + }); + + it('keeps resources when current distro matches debian-based group', async () => { + vi.spyOn(OsUtils, 'isLinux').mockReturnValue(true); + vi.spyOn(OsUtils, 'getLinuxDistro').mockResolvedValue(LinuxDistro.UBUNTU); + + const project = makeProject( + makeResource('debian-tool', undefined, [LinuxDistro.DEBIAN_BASED]), + makeResource('rpm-tool', undefined, [LinuxDistro.RPM_BASED]), + ); + + await project.removeResourcesUsingDistroFilter(); + + expect(project.resourceConfigs).toHaveLength(1); + expect(project.resourceConfigs[0].type).toBe('debian-tool'); + }); + + it('keeps resources when current distro matches rpm-based group', async () => { + vi.spyOn(OsUtils, 'isLinux').mockReturnValue(true); + vi.spyOn(OsUtils, 'getLinuxDistro').mockResolvedValue(LinuxDistro.FEDORA); + + const project = makeProject( + makeResource('debian-tool', undefined, [LinuxDistro.DEBIAN_BASED]), + makeResource('rpm-tool', undefined, [LinuxDistro.RPM_BASED]), + ); + + await project.removeResourcesUsingDistroFilter(); + + expect(project.resourceConfigs).toHaveLength(1); + expect(project.resourceConfigs[0].type).toBe('rpm-tool'); + }); + + it('debian-based group covers all expected distros', async () => { + const debianDistros = [ + LinuxDistro.DEBIAN, + LinuxDistro.UBUNTU, + LinuxDistro.MINT, + LinuxDistro.POP_OS, + LinuxDistro.ELEMENTARY_OS, + LinuxDistro.KALI, + ]; + + vi.spyOn(OsUtils, 'isLinux').mockReturnValue(true); + + for (const distro of debianDistros) { + vi.spyOn(OsUtils, 'getLinuxDistro').mockResolvedValue(distro); + + const project = makeProject( + makeResource('tool', undefined, [LinuxDistro.DEBIAN_BASED]), + ); + + await project.removeResourcesUsingDistroFilter(); + + expect(project.resourceConfigs).toHaveLength(1); + } + }); + + it('rpm-based group covers all expected distros', async () => { + const rpmDistros = [ + LinuxDistro.FEDORA, + LinuxDistro.CENTOS, + LinuxDistro.RHEL, + LinuxDistro.AMAZON_LINUX, + LinuxDistro.OPENSUSE, + LinuxDistro.SUSE, + ]; + + vi.spyOn(OsUtils, 'isLinux').mockReturnValue(true); + + for (const distro of rpmDistros) { + vi.spyOn(OsUtils, 'getLinuxDistro').mockResolvedValue(distro); + + const project = makeProject( + makeResource('tool', undefined, [LinuxDistro.RPM_BASED]), + ); + + await project.removeResourcesUsingDistroFilter(); + + expect(project.resourceConfigs).toHaveLength(1); + } + }); + + it('removes resources when no distro in filter matches the current distro', async () => { + vi.spyOn(OsUtils, 'isLinux').mockReturnValue(true); + vi.spyOn(OsUtils, 'getLinuxDistro').mockResolvedValue(LinuxDistro.ARCH); + + const project = makeProject( + makeResource('tool', undefined, [LinuxDistro.UBUNTU, LinuxDistro.DEBIAN]), + ); + + await project.removeResourcesUsingDistroFilter(); + + expect(project.resourceConfigs).toHaveLength(0); + }); + + it('keeps resources that list multiple distros including the current one', async () => { + vi.spyOn(OsUtils, 'isLinux').mockReturnValue(true); + vi.spyOn(OsUtils, 'getLinuxDistro').mockResolvedValue(LinuxDistro.ARCH); + + const project = makeProject( + makeResource('tool', undefined, [LinuxDistro.UBUNTU, LinuxDistro.ARCH]), + ); + + await project.removeResourcesUsingDistroFilter(); + + expect(project.resourceConfigs).toHaveLength(1); + }); + }); +}); diff --git a/src/entities/project.ts b/src/entities/project.ts index fc371c91..587da3da 100644 --- a/src/entities/project.ts +++ b/src/entities/project.ts @@ -15,7 +15,6 @@ import { ResourceDefinitionMap } from '../plugins/plugin-manager.js'; import { DependencyGraphResolver } from '../utils/dependency-graph-resolver.js'; import { groupBy } from '../utils/index.js'; import { OsUtils } from '../utils/os-utils.js'; -import { ShellUtils } from '../utils/shell.js'; import { ConfigBlock, ConfigType } from './config.js'; import { type Plan } from './plan.js'; import { ProjectConfig } from './project-config.js'; @@ -187,12 +186,12 @@ ${JSON.stringify(projectConfigs, null, 2)}`); } if (os.type() === OS.Linux) { - const currentDistro = await ShellUtils.getLinuxDistro(); + const currentDistro = await OsUtils.getLinuxDistro(); if (!currentDistro) { throw new Error('Unable to determine Linux distribution'); } - this.resourceConfigs.filter((c) => { + const distroInvalidConfigs = this.resourceConfigs.filter((c) => { const distros = resourceDefinitions.get(c.type)?.linuxDistros; if (!distros) { return false; @@ -201,8 +200,8 @@ ${JSON.stringify(projectConfigs, null, 2)}`); return !distros.includes(currentDistro); }); - if (invalidConfigs.length > 0) { - throw new LinuxDistroNotSupportedError(invalidConfigs, this.sourceMaps); + if (distroInvalidConfigs.length > 0) { + throw new LinuxDistroNotSupportedError(distroInvalidConfigs, this.sourceMaps); } } } @@ -217,6 +216,25 @@ ${JSON.stringify(projectConfigs, null, 2)}`); }); } + async removeResourcesUsingDistroFilter() { + if (!OsUtils.isLinux()) { + return; + } + + const currentDistro = await OsUtils.getLinuxDistro(); + if (!currentDistro) { + return; + } + + this.resourceConfigs = this.resourceConfigs.filter((r) => { + if (!r.distro || r.distro.length === 0) { + return true; + } + + return r.distro.some((d) => OsUtils.distroMatchesCurrent(d, currentDistro)); + }); + } + resolveDependenciesAndCalculateEvalOrder(resourceDefinitions?: ResourceDefinitionMap) { this.resolveResourceDependencies(resourceDefinitions); this.calculateEvaluationOrder(); diff --git a/src/entities/resource-config.ts b/src/entities/resource-config.ts index 3d1d2f32..e2016706 100644 --- a/src/entities/resource-config.ts +++ b/src/entities/resource-config.ts @@ -1,4 +1,4 @@ -import { ResourceJson, ResourceOs, ResourceConfig as SchemaResourceConfig } from '@codifycli/schemas'; +import { LinuxDistro, ResourceJson, ResourceOs, ResourceConfig as SchemaResourceConfig } from '@codifycli/schemas'; import { deepEqual } from '../utils/index.js'; import { ConfigBlock, ConfigType } from './config.js'; @@ -29,6 +29,7 @@ export class ResourceConfig implements ConfigBlock { name?: string; dependsOn: string[]; os?: ResourceOs[]; + distro?: LinuxDistro[]; sourceMapKey?: string; // Calculated @@ -38,12 +39,13 @@ export class ResourceConfig implements ConfigBlock { resourceInfo?: ResourceInfo; constructor(config: SchemaResourceConfig, sourceMapKey?: string) { - const { dependsOn, name, type, os, ...parameters } = config; + const { dependsOn, name, type, os, distro, ...parameters } = config; this.raw = config; this.type = type; this.name = name; this.os = os; + this.distro = distro; this.parameters = parameters ?? {}; this.dependsOn = dependsOn ?? [] this.sourceMapKey = sourceMapKey; @@ -65,7 +67,8 @@ export class ResourceConfig implements ConfigBlock { type: this.type, ...(excludeName || !this.name ? {} : { name: this.name }), ...(this.dependsOn.length > 0 ? { dependsOn: this.dependsOn } : {}), - ...(this.os && this.os?.length > 0 ? { os: this.os } : {}) + ...(this.os && this.os?.length > 0 ? { os: this.os } : {}), + ...(this.distro && this.distro?.length > 0 ? { distro: this.distro } : {}) }; } diff --git a/src/events/context.ts b/src/events/context.ts index b6b38dc8..c3b0dfac 100644 --- a/src/events/context.ts +++ b/src/events/context.ts @@ -31,6 +31,12 @@ export enum ProcessName { TERMINATE = 'terminate', } +export enum SubprocessFinishStatus { + SUCCESS, + FAILED, + SKIPPED, +} + export enum SubProcessName { APPLYING_RESOURCE = 'apply_resource_', GENERATE_PLAN = 'generate_plan', @@ -65,7 +71,8 @@ export const ctx = new class { } log(...args: unknown[]) { - this.emitter.emit(Event.STDOUT, ...args); + const message = args.join(' '); + this.emitter.emit(Event.STDOUT, message.endsWith('\n') ? message : message + '\n'); } pluginStdout(name: string, ...args: unknown[]) { @@ -82,7 +89,8 @@ export const ctx = new class { return; } - this.emitter.emit(Event.DEBUG, ...args); + const message = args.join(' '); + this.emitter.emit(Event.DEBUG, message.endsWith('\n') ? message : message + '\n'); } async process(name: string, fn: (() => Promise)): Promise { @@ -105,8 +113,8 @@ export const ctx = new class { this.emitter.emit(Event.SUB_PROCESS_START, name, additionalName); } - subprocessFinished(name: string, additionalName?: string) { - this.emitter.emit(Event.SUB_PROCESS_FINISH, name, additionalName); + subprocessFinished(name: string, additionalName?: string, status: SubprocessFinishStatus = SubprocessFinishStatus.SUCCESS) { + this.emitter.emit(Event.SUB_PROCESS_FINISH, name, additionalName, status); } commandRequested(pluginName: string, data: CommandRequestData) { @@ -136,7 +144,7 @@ export const ctx = new class { async subprocess(name: string, run: () => Promise): Promise { this.emitter.emit(Event.SUB_PROCESS_START, name); const result = await run(); - this.emitter.emit(Event.SUB_PROCESS_FINISH, name); + this.emitter.emit(Event.SUB_PROCESS_FINISH, name, undefined, SubprocessFinishStatus.SUCCESS); return result; } diff --git a/src/orchestrators/apply.ts b/src/orchestrators/apply.ts index 69bc9ade..0b6aa559 100644 --- a/src/orchestrators/apply.ts +++ b/src/orchestrators/apply.ts @@ -1,6 +1,8 @@ import { ProcessName, ctx } from '../events/context.js'; +import { DefaultReporter } from '../ui/reporters/default-reporter.js'; import { Reporter } from '../ui/reporters/reporter.js'; -import { sleep } from '../utils/index.js'; +import { SleepInhibitor } from '../utils/sleep-inhibitor.js'; +import { VerbosityLevel } from '../utils/verbosity-level.js'; import { PlanOrchestrator } from './plan.js'; export interface ApplyArgs { @@ -8,6 +10,8 @@ export interface ApplyArgs { secure?: boolean; verbosityLevel?: number; noProgress?: boolean; + autoApprove?: boolean; + allowSleep?: boolean; } export const ApplyOrchestrator = { @@ -20,25 +24,45 @@ export const ApplyOrchestrator = { return process.exit(0); } - const confirm = await reporter.promptConfirmation('Do you want to continue?') - if (!confirm) { - return process.exit(0); + if (!args.autoApprove) { + const confirm = await reporter.promptConfirmation('Do you want to continue?') + if (!confirm) { + return process.exit(0); + } } - + const { plan, pluginManager, project } = planResult; const filteredPlan = plan.filterNoopResources() - if (!args.noProgress) ctx.processStarted(ProcessName.APPLY); - if (!args.noProgress) await reporter.displayProgress(); + let currentVerbosity = args.verbosityLevel ?? 0; + if (reporter instanceof DefaultReporter) { + reporter.onVerbosityToggle(async () => { + currentVerbosity = currentVerbosity === 0 ? 3 : 0; + await pluginManager.setVerbosityLevel(currentVerbosity); + }); + } + + const inhibitor = args.allowSleep ? null : SleepInhibitor.start(); + if (inhibitor && reporter instanceof DefaultReporter) { + reporter.setSleepPrevented(true); + } - await pluginManager.apply(project, filteredPlan); - if (!args.noProgress) ctx.processFinished(ProcessName.APPLY); + try { + if (!args.noProgress) ctx.processStarted(ProcessName.APPLY); + if (!args.noProgress) await reporter.displayProgress(); - // Need to sleep to wait for the message to display before we exit - await sleep(100); + const applyResult = await pluginManager.apply(project, filteredPlan); + + if (!args.noProgress) ctx.processFinished(ProcessName.APPLY); + + await reporter.displayApplyComplete(applyResult); + + if (applyResult.isPartialFailure()) { + process.exit(1); + } + } finally { + inhibitor?.stop(); + } - reporter.displayMessage(` -🎉 Finished applying 🎉 -Open a new terminal or source '.zshrc' for the new changes to be reflected`); }, }; diff --git a/src/orchestrators/destroy.ts b/src/orchestrators/destroy.ts index 136c3799..2d266fe7 100644 --- a/src/orchestrators/destroy.ts +++ b/src/orchestrators/destroy.ts @@ -5,7 +5,9 @@ import { ResourceConfig } from '../entities/resource-config.js'; import { ResourceInfo } from '../entities/resource-info.js'; import { ProcessName, SubProcessName, ctx } from '../events/context.js'; import { PluginManager, ResourceDefinitionMap } from '../plugins/plugin-manager.js'; +import { DefaultReporter } from '../ui/reporters/default-reporter.js'; import { PromptType, Reporter } from '../ui/reporters/reporter.js'; +import { SleepInhibitor } from '../utils/sleep-inhibitor.js'; import { wildCardMatch } from '../utils/wild-card-match.js'; export interface DestroyArgs { @@ -13,13 +15,15 @@ export interface DestroyArgs { path?: string; secureMode?: boolean; verbosityLevel?: number; + autoApprove?: boolean; + allowSleep?: boolean; } export class DestroyOrchestrator { static async run(args: DestroyArgs, reporter: Reporter) { const typeIds = args.typeIds?.filter(Boolean) - ctx.processStarted(ProcessName.DESTROY) + ctx.processStarted(ProcessName.PLAN) const initializationResult = await PluginInitOrchestrator.run( { ...args, allowEmptyProject: true, }, @@ -35,10 +39,12 @@ export class DestroyOrchestrator { ? await DestroyOrchestrator.destroyExistingProject(reporter, initializationResult) : await DestroyOrchestrator.destroySpecificResources(typeIds, reporter, initializationResult) + ctx.processFinished(ProcessName.DESTROY) + plan.sortByEvalOrder(project.evaluationOrder); destroyProject.removeNoopFromEvaluationOrder(plan); - reporter.displayPlan(plan); + await reporter.displayPlan(plan); // Short circuit and exit if every change is NOOP if (plan.isEmpty()) { @@ -46,21 +52,44 @@ export class DestroyOrchestrator { return; } - const confirm = await reporter.promptConfirmation('Do you want to destroy?') - if (!confirm) { - return; + if (!args.autoApprove) { + const confirm = await reporter.promptConfirmation('Do you want to destroy?') + if (!confirm) { + return; + } } + ctx.processStarted(ProcessName.DESTROY) + const filteredPlan = plan.filterNoopResources() - await reporter.displayProgress(); - await ctx.process(ProcessName.DESTROY, () => - pluginManager.apply(destroyProject, filteredPlan) - ) + let currentVerbosity = args.verbosityLevel ?? 0; + if (reporter instanceof DefaultReporter) { + reporter.onVerbosityToggle(async () => { + currentVerbosity = currentVerbosity === 0 ? 3 : 0; + await pluginManager.setVerbosityLevel(currentVerbosity); + }); + } + + const inhibitor = args.allowSleep ? null : SleepInhibitor.start(); + if (inhibitor && reporter instanceof DefaultReporter) { + reporter.setSleepPrevented(true); + } + + try { + await reporter.displayProgress(); + const applyResult = await ctx.process(ProcessName.DESTROY, () => + pluginManager.apply(destroyProject, filteredPlan) + ) - await reporter.displayMessage(` -🎉 Finished applying 🎉 -Open a new terminal or source '.zshrc' for the new changes to be reflected`); + await reporter.displayApplyComplete(applyResult); + + if (applyResult.isPartialFailure()) { + process.exit(1); + } + } finally { + inhibitor?.stop(); + } } /** This method is responsible for generating a plan for specific resources specified by the user */ diff --git a/src/orchestrators/edit.ts b/src/orchestrators/edit.ts index f182528b..cd6be22a 100644 --- a/src/orchestrators/edit.ts +++ b/src/orchestrators/edit.ts @@ -1,27 +1,53 @@ -import { Config } from '@oclif/core'; import open from 'open'; import { DashboardApiClient } from '../api/dashboard/index.js'; import { config } from '../config.js'; import { LoginHelper } from '../connect/login-helper.js'; import { Reporter } from '../ui/reporters/reporter.js'; +import { getDesktopAppPath, getDesktopDownloadUrl, installDesktopApp } from '../utils/desktop-installer.js'; import { ConnectOrchestrator } from './connect.js'; -import { LoginOrchestrator } from './login.js'; export class EditOrchestrator { static async run(rootCommand: string, reporter: Reporter) { - const login = LoginHelper.get()?.isLoggedIn; - if (!login) { - console.log('User is not logged in. Attempting to log in...') - await LoginOrchestrator.run(); + const desktopPath = await getDesktopAppPath(); + + if (desktopPath) { + await open(desktopPath); + return; + } + + const download = getDesktopDownloadUrl(); + if (download) { + const shouldInstall = await reporter.promptConfirmation( + 'Codify desktop app is not installed. Would you like to download and install it?' + ); + + await reporter.hide(); + + if (shouldInstall) { + await installDesktopApp(reporter, download.url, download.platform); + const installedPath = await getDesktopAppPath(); + if (installedPath) { + await open(installedPath); + } + return; + } } + await EditOrchestrator.openDashboard(rootCommand, reporter); + } + + private static async openDashboard(rootCommand: string, reporter: Reporter) { + const isLoggedIn = LoginHelper.get()?.isLoggedIn; + let defaultDocumentId: null | string = null; - try { - defaultDocumentId = await DashboardApiClient.getDefaultDocumentId(); - } catch { - console.warn('Mismatch accounts between local and dashboard. Cannot open default document') + if (isLoggedIn) { + try { + defaultDocumentId = await DashboardApiClient.getDefaultDocumentId(); + } catch { + // ignore — just open homepage + } } const url = defaultDocumentId @@ -31,11 +57,11 @@ export class EditOrchestrator { await ConnectOrchestrator.run(rootCommand, reporter, false, (code) => { open(`${url}?connection_code=${code}`); console.log( -`Opening default Codify file: +`Opening Codify dashboard: ${url}?connection_code=${code} Starting connection. If unsuccessful, manually enter the code: -${code}`) +${code}`); }); } } diff --git a/src/orchestrators/import.ts b/src/orchestrators/import.ts index a1e6c15d..b7ee7479 100644 --- a/src/orchestrators/import.ts +++ b/src/orchestrators/import.ts @@ -116,7 +116,7 @@ export class ImportOrchestrator { ctx.processFinished(ProcessName.IMPORT) - reporter.displayImportResult(importResult, false); + await reporter.displayImportResult(importResult, false); resourceInfoList.push(...(await pluginManager.getMultipleResourceInfo( project.resourceConfigs.map((r) => r.type) @@ -194,8 +194,8 @@ export class ImportOrchestrator { } // No writes - reporter.displayImportResult(importResult, true); - reporter.displayMessage('\n🎉 Imported completed 🎉') + await reporter.displayImportResult(importResult, true); + await reporter.displayMessage('\n🎉 Imported completed 🎉') await sleep(100); } @@ -244,17 +244,17 @@ export class ImportOrchestrator { // No changes to be made if (diffs.every((d) => d.modification.diff === '')) { - reporter.displayMessage('\nNo changes are needed! Exiting...') + await reporter.displayMessage('\nNo changes are needed! Exiting...') // Wait for the message to display before we exit await sleep(100); return; } - reporter.displayFileModifications(diffs); + await reporter.displayFileModifications(diffs); const shouldSave = await reporter.promptConfirmation('Save the changes?'); if (!shouldSave) { - reporter.displayMessage('\nSkipping save! Exiting...'); + await reporter.displayMessage('\nSkipping save! Exiting...'); // Wait for the message to display before we exit await sleep(100); @@ -265,7 +265,7 @@ export class ImportOrchestrator { await FileUpdater.write(diff.file, diff.modification.newFile); } - reporter.displayMessage('\n🎉 Imported completed and saved to file 🎉'); + await reporter.displayMessage('\n🎉 Imported completed and saved to file 🎉'); // Wait for the message to display before we exit await sleep(100); @@ -448,11 +448,11 @@ ${JSON.stringify(unsupportedTypeIds)}`); const newFile = JSON.stringify(importResult.result.map((r) => r.raw), null, 2); const diff = prettyFormatFileDiff('', newFile); - reporter.displayFileModifications([{ file: filePath, modification: { newFile, diff } }]); + await reporter.displayFileModifications([{ file: filePath, modification: { newFile, diff } }]); const shouldSave = await reporter.promptConfirmation(`Save the changes? (${filePath})`); if (!shouldSave) { - reporter.displayMessage('\nSkipping save! Exiting...'); + await reporter.displayMessage('\nSkipping save! Exiting...'); // Wait for the message to display before we exit await sleep(100); @@ -461,7 +461,7 @@ ${JSON.stringify(unsupportedTypeIds)}`); await FileUpdater.write(filePath, newFile); - reporter.displayMessage('\n🎉 Imported completed and saved to file 🎉'); + await reporter.displayMessage('\n🎉 Imported completed and saved to file 🎉'); // Wait for the message to display before we exit await sleep(100); diff --git a/src/orchestrators/init.ts b/src/orchestrators/init.ts index 73328c8e..97ef1f14 100644 --- a/src/orchestrators/init.ts +++ b/src/orchestrators/init.ts @@ -16,12 +16,13 @@ export interface InitArgs { path?: string; verbosityLevel?: number; includeSensitive?: boolean; + skipBanner?: boolean; } export const InitializeOrchestrator = { async run(args: InitArgs, reporter: Reporter) { - await reporter.displayInitBanner() + await reporter.displayInitBanner(args.skipBanner) ctx.processStarted(ProcessName.INIT) await reporter.displayProgress(); diff --git a/src/orchestrators/plan.ts b/src/orchestrators/plan.ts index ee97f966..05cd0617 100644 --- a/src/orchestrators/plan.ts +++ b/src/orchestrators/plan.ts @@ -48,7 +48,7 @@ export class PlanOrchestrator { if (!args.noProgress) ctx.processFinished(ProcessName.PLAN) await reporter.hide(); - reporter.displayPlan(plan); + await reporter.displayPlan(plan); return { plan, diff --git a/src/orchestrators/refresh.ts b/src/orchestrators/refresh.ts index ad1cc9dd..620f49f2 100644 --- a/src/orchestrators/refresh.ts +++ b/src/orchestrators/refresh.ts @@ -35,7 +35,7 @@ export class RefreshOrchestrator { ctx.processFinished(ProcessName.REFRESH); - reporter.displayImportResult(importResult, false); + await reporter.displayImportResult(importResult, false); // Special handling for remote-file resources. Offer to save them remotely if any changes are detected on import. diff --git a/src/orchestrators/test.ts b/src/orchestrators/test.ts index 114dbeb6..7ad745b3 100644 --- a/src/orchestrators/test.ts +++ b/src/orchestrators/test.ts @@ -130,7 +130,7 @@ export const TestOrchestrator = { // Short circuit and exit if every change is NOOP if (!planResult.plan.isEmpty()) { - reporter.displayPlan(planResult.plan); + await reporter.displayPlan(planResult.plan); const confirm = await reporter.promptConfirmation('The following resources will need to be installed (Tart VM - 25gb). Do you want to continue?') if (!confirm) { return process.exit(0); diff --git a/src/plugins/plugin-manager.ts b/src/plugins/plugin-manager.ts index 4f65ef40..9435bfd5 100644 --- a/src/plugins/plugin-manager.ts +++ b/src/plugins/plugin-manager.ts @@ -4,17 +4,19 @@ import { ValidateResponseData, } from '@codifycli/schemas'; -import { InternalError } from '../common/errors.js'; +import { InternalError, PluginError } from '../common/errors.js'; import { config } from '../config.js'; +import { ApplyResult, createApplyResult } from '../entities/apply-result.js'; import { Plan, ResourcePlan } from '../entities/plan.js'; import { Project } from '../entities/project.js'; import { ResourceConfig } from '../entities/resource-config.js'; import { ResourceInfo } from '../entities/resource-info.js'; -import { SubProcessName, ctx } from '../events/context.js'; +import { SubProcessName, SubprocessFinishStatus, ctx } from '../events/context.js'; import { groupBy } from '../utils/index.js'; import { registerKillListeners } from '../utils/register-kill-listeners.js'; import { Plugin } from './plugin.js'; import { PluginResolver } from './resolver.js'; +import { VerbosityLevel } from '../utils/verbosity-level.js'; type PluginName = string; type ResourceTypeId = string; @@ -136,8 +138,18 @@ export class PluginManager { return new Plan(result, project); } - async apply(project: Project, plan: Plan): Promise { + async apply(project: Project, plan: Plan): Promise { + const collectedErrors: PluginError[] = []; + const skippedIds = new Set(); + const succeededPlans: ResourcePlan[] = []; + for (const id of project.evaluationOrder ?? []) { + if (skippedIds.has(id)) { + ctx.subprocessStarted(SubProcessName.APPLYING_RESOURCE, id); + ctx.subprocessFinished(SubProcessName.APPLYING_RESOURCE, id, SubprocessFinishStatus.SKIPPED); + continue; + } + ctx.subprocessStarted(SubProcessName.APPLYING_RESOURCE, id); const resourcePlan = plan.getResourcePlan(id); @@ -151,13 +163,27 @@ export class PluginManager { throw new InternalError(`Unable to determine plugin for apply: ${resourceType}`); } - await this.plugins.get(pluginName)!.apply(resourcePlan); - - ctx.subprocessFinished(SubProcessName.APPLYING_RESOURCE, resourcePlan.id); + try { + await this.plugins.get(pluginName)!.apply(resourcePlan); + succeededPlans.push(resourcePlan); + ctx.subprocessFinished(SubProcessName.APPLYING_RESOURCE, resourcePlan.id, SubprocessFinishStatus.SUCCESS); + } catch (err) { + if (err instanceof PluginError) { + collectedErrors.push(err); + ctx.subprocessFinished(SubProcessName.APPLYING_RESOURCE, resourcePlan.id, SubprocessFinishStatus.FAILED); + const dependents = plan.computeTransitiveDependents(id); + for (const depId of dependents) skippedIds.add(depId); + } else { + throw err; + } + } } + + return createApplyResult(succeededPlans, collectedErrors, skippedIds); } async setVerbosityLevel(verbosityLevel: number): Promise { + VerbosityLevel.set(verbosityLevel); for (const plugin of this.plugins.values()) { await plugin.setVerbosityLevel(verbosityLevel); } diff --git a/src/plugins/plugin.ts b/src/plugins/plugin.ts index f6dfd568..7792be4c 100644 --- a/src/plugins/plugin.ts +++ b/src/plugins/plugin.ts @@ -1,4 +1,5 @@ import { + ErrorResponseDataSchema, GetResourceInfoResponseData, GetResourceInfoResponseDataSchema, ImportRequestData, @@ -11,6 +12,7 @@ import { PlanRequestData, PlanResponseData, PlanResponseDataSchema, + PluginErrorData, ResourceJson, ValidateResponseData, ValidateResponseDataSchema, @@ -18,9 +20,11 @@ import { import { ResourcePlan } from '../entities/plan.js'; import { ResourceConfig } from '../entities/resource-config.js'; +import { PluginError } from '../common/errors.js'; import { ajv } from '../utils/ajv.js'; import { PluginProcess } from './plugin-process.js'; +const errorResponseValidator = ajv.compile(ErrorResponseDataSchema); const initializeResponseValidator = ajv.compile(InitializeResponseDataSchema); const validateResponseValidator = ajv.compile(ValidateResponseDataSchema); const getResourceInfoResponseValidator = ajv.compile(GetResourceInfoResponseDataSchema); @@ -67,9 +71,9 @@ export class Plugin implements IPlugin { async validate(configs: ResourceConfig[]): Promise { const jsonConfigs = configs.map((c) => c.toJson()); const result = await this.process!.sendMessageForResult('validate', { configs: jsonConfigs }); - + if (!result.isSuccessful()) { - throw new Error(`Validate error for plugin: "${this.name}" \n\n${JSON.stringify(result.data, null, 2)}`); + throw new PluginError(this.name, 'validate', this.toErrorData(result.data)); } if (!this.validateValidateResponse(result.data)) { @@ -83,7 +87,7 @@ export class Plugin implements IPlugin { const result = await this.process!.sendMessageForResult('getResourceInfo', { type }); if (!result.isSuccessful()) { - throw new Error(`Unable to get info for resource: "${type}" from plugin: "${this.name}" \n\n` + result.data); + throw new PluginError(this.name, type, this.toErrorData(result.data)); } if (!this.validateGetResourceInfoResponse(result.data)) { @@ -100,7 +104,7 @@ export class Plugin implements IPlugin { }); if (!result.isSuccessful()) { - throw new Error(`Unable to match resource: "${resource.type}" from plugin: "${this.name}" \n\n` + result.data); + throw new PluginError(this.name, resource.type, this.toErrorData(result.data)); } if (!this.validateMatchResponse(result.data)) { @@ -110,12 +114,11 @@ export class Plugin implements IPlugin { return result.data; } - async import(config: ResourceJson, autoSearchAll = false): Promise { const result = await this.process!.sendMessageForResult('import', { ...config, autoSearchAll }); if (!result.isSuccessful()) { - throw new Error(`Unable import resource ${config.core.type} with plugin: "${this.name}" \n\n` + result.data); + throw new PluginError(this.name, config.core.type, this.toErrorData(result.data)); } if (!this.validateImportResponse(result.data)) { @@ -126,13 +129,10 @@ export class Plugin implements IPlugin { } async plan(request: PlanRequestData): Promise { - const result = await this.process!.sendMessageForResult( - 'plan', - request - ); + const result = await this.process!.sendMessageForResult('plan', request); if (!result.isSuccessful()) { - throw new Error(`Plan error for plugin: "${this.name}", resource: "${request.core.type}" \n\n` + result.data); + throw new PluginError(this.name, request.core.type, this.toErrorData(result.data)); } if (!this.validatePlanResponse(result.data)) { @@ -146,7 +146,7 @@ export class Plugin implements IPlugin { const result = await this.process!.sendMessageForResult('apply', { plan }); if (!result.isSuccessful()) { - throw new Error(`Apply error for plugin: "${this.name}", resource: "${plan.resourceType}" \n\n` + result.data); + throw new PluginError(this.name, plan.resourceType, this.toErrorData(result.data)); } } @@ -154,8 +154,15 @@ export class Plugin implements IPlugin { const result = await this.process!.sendMessageForResult('setVerbosityLevel', { verbosityLevel }); if (!result.isSuccessful()) { - throw new Error(`Set verbosity error for plugin: "${this.name}" \n\n` + result.data); + throw new PluginError(this.name, 'setVerbosityLevel', this.toErrorData(result.data)); + } + } + + private toErrorData(data: unknown): PluginErrorData { + if (errorResponseValidator(data)) { + return data as unknown as PluginErrorData; } + return { errorType: 'unknown', message: typeof data === 'string' ? data : JSON.stringify(data, null, 2) }; } kill() { diff --git a/src/plugins/resolver.ts b/src/plugins/resolver.ts index f87c3ec6..3f8b5cba 100644 --- a/src/plugins/resolver.ts +++ b/src/plugins/resolver.ts @@ -7,6 +7,7 @@ import path from 'node:path'; import { PluginInfo } from '../api//backend/types.js'; import { ApiClient } from '../api/backend/index.js'; import { ctx } from '../events/context.js'; +import { VERSION } from '../config.js'; import { Plugin } from './plugin.js'; const PLUGIN_CACHE_DIR = path.resolve(os.homedir(), '.codify/plugins') @@ -31,7 +32,7 @@ export class PluginResolver { // Fetch the latest plugin info from the server const latestPluginInfo = await ApiClient - .searchPlugins(networkPluginDefs.map(([name, version]) => ({ name, version }))) + .searchPlugins(networkPluginDefs.map(([name, version]) => ({ name, version })), VERSION) .catch((error: Error) => { console.warn('Unable to fetch latest plugin info'); ctx.debug(`Unable to fetch latest plugin info:\n${error.message}`); diff --git a/src/ui/apply-result-formatter.ts b/src/ui/apply-result-formatter.ts new file mode 100644 index 00000000..af7ea4b5 --- /dev/null +++ b/src/ui/apply-result-formatter.ts @@ -0,0 +1,40 @@ +import chalk from 'chalk'; +import { ResourceOperation } from '@codifycli/schemas'; + +import { ApplyResultEntry } from '../entities/apply-result.js'; + +export function applyEntryLabel(entry: ApplyResultEntry): string { + if (entry.status === 'failed') return 'failed'; + if (entry.status === 'skipped') return 'skipped'; + switch (entry.operation) { + case ResourceOperation.CREATE: return 'installed'; + case ResourceOperation.DESTROY: return 'destroyed'; + case ResourceOperation.MODIFY: + case ResourceOperation.RECREATE: return 'modified'; + default: return 'applied'; + } +} + +export function applyEntryInkColor(entry: ApplyResultEntry): string { + if (entry.status === 'failed') return 'red'; + if (entry.status === 'skipped') return 'gray'; + switch (entry.operation) { + case ResourceOperation.CREATE: return 'green'; + case ResourceOperation.DESTROY: return 'red'; + case ResourceOperation.MODIFY: + case ResourceOperation.RECREATE: return '#d4a017'; + default: return 'white'; + } +} + +export function applyEntryChalkColor(entry: ApplyResultEntry): (s: string) => string { + if (entry.status === 'failed') return chalk.red; + if (entry.status === 'skipped') return chalk.gray; + switch (entry.operation) { + case ResourceOperation.CREATE: return chalk.green; + case ResourceOperation.DESTROY: return chalk.red; + case ResourceOperation.MODIFY: + case ResourceOperation.RECREATE: return chalk.yellow; + default: return (s) => s; + } +} diff --git a/src/ui/components/default-component.tsx b/src/ui/components/default-component.tsx index 61faad09..9186714e 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -1,14 +1,13 @@ import { Form, FormProps } from '@codifycli/ink-form'; -import { PasswordInput, TextInput } from '@inkjs/ui'; -import chalk from 'chalk'; -import { Box, Static, Text } from 'ink'; +import { Box, Static, Text, useStdout } from 'ink'; import SelectInput from 'ink-select-input'; import { useAtom } from 'jotai'; -import { selectAtom } from 'jotai/utils'; import { EventEmitter } from 'node:events'; -import React, { useLayoutEffect, useState } from 'react'; +import React, { useLayoutEffect } from 'react'; -import { Plan } from '../../entities/plan.js'; +import { ApplyResult } from '../../entities/apply-result.js'; +import { Plan, ResourcePlan } from '../../entities/plan.js'; +import { prettyFormatResourcePlan } from '../plan-pretty-printer.js'; import { FileModificationResult } from '../../generators/index.js'; import { ImportResult } from '../../orchestrators/import.js'; import { RenderEvent } from '../reporters/reporter.js'; @@ -20,44 +19,39 @@ import { InitBanner } from './init/InitBanner.js'; import { MultiSelect } from './multi-select/MultiSelect.js'; import { PlanComponent } from './plan/plan.js'; import { ProgressDisplay } from './progress/progress-display.js'; +import { ApplyComplete } from './widgets/ApplyComplete.js'; import { PromptPressKeyToContinue } from './widgets/PromptPressKeyToContinue.js'; +import { SudoPasswordInput } from './widgets/SudoPasswordInput.js'; +import { TextInput } from './widgets/TextInput.js'; export function DefaultComponent(props: { emitter: EventEmitter + onWriteReady?: (write: (data: string) => void) => void }) { - const { emitter } = props - const [disableSudoPrompt, setDisableSudoPrompt] = useState(false); + const { emitter, onWriteReady } = props const [{ status: renderStatus, data: renderData }] = useAtom(store.renderState); + const { write } = useStdout(); - // Use layoutEffect runs before the first render, whereas useEffect runs after useLayoutEffect(() => { - const logListener = (log: string) => { - console.log(chalk.cyan(log)); - }; - - emitter.on(RenderEvent.LOG, logListener); - - const disableSudoPrompt = (isDisabled: boolean) => { - setDisableSudoPrompt(isDisabled); - } - - emitter.on(RenderEvent.DISABLE_SUDO_PROMPT, disableSudoPrompt) - - return () => { - emitter.off(RenderEvent.LOG, logListener); - emitter.off(RenderEvent.DISABLE_SUDO_PROMPT, disableSudoPrompt); - } + onWriteReady?.(write); }, []); - return + return { renderStatus === RenderStatus.DISPLAY_MESSAGE && ( {renderData as string} ) } + { + renderStatus === RenderStatus.APPLY_COMPLETE && ( + { + (result, idx) => + } + ) + } { renderStatus === RenderStatus.PROGRESS && ( - + ) } { @@ -65,6 +59,17 @@ export function DefaultComponent(props: { (plan, idx) => } } + { + renderStatus === RenderStatus.PLUGIN_ERROR && ( + { + (messages, idx) => ( + + {messages.map((msg, i) => {msg})} + + ) + } + ) + } { renderStatus === RenderStatus.PROMPT_CONFIRMATION && ( @@ -91,13 +96,14 @@ export function DefaultComponent(props: { } { renderStatus === RenderStatus.SUDO_PROMPT && ( - - Password: - {/* Use sudoAttemptCount as a hack to reset password input between attempts */} - { - emitter.emit(RenderEvent.SUDO_PROMPT_RESULT, password); - }}/> - + emitter.emit(RenderEvent.SUDO_PROMPT_RESULT, password)} + onCancel={() => emitter.emit(RenderEvent.SUDO_PASSWORD_CANCEL)} + /> ) } { diff --git a/src/ui/components/import/import-result.tsx b/src/ui/components/import/import-result.tsx index 31065e8a..d665d3ed 100644 --- a/src/ui/components/import/import-result.tsx +++ b/src/ui/components/import/import-result.tsx @@ -1,4 +1,3 @@ -import { OrderedList } from '@inkjs/ui'; import { Box, Text } from 'ink'; import React from 'react'; @@ -15,13 +14,11 @@ export function ImportResultComponent(props: { { result.length > 0 && !props.showConfigs && ( Successfully imported the following configs: - - { - result.map((r, idx) => - {r.type} - ) - } - + + {result.map((r, idx) => ( + {idx + 1}. {r.type} + ))} + ) } { @@ -39,13 +36,11 @@ export function ImportResultComponent(props: { { errors.length > 0 && ( The following configs failed to import: - - { - errors.map((e, idx) => - {e} - ) - } - + + {errors.map((e, idx) => ( + {idx + 1}. {e} + ))} + ) } diff --git a/src/ui/components/init/InitBanner.tsx b/src/ui/components/init/InitBanner.tsx index 2ce30022..921d4a0e 100644 --- a/src/ui/components/init/InitBanner.tsx +++ b/src/ui/components/init/InitBanner.tsx @@ -1,5 +1,4 @@ -import { Select } from '@inkjs/ui'; -import { Box, Static, Text } from 'ink'; +import { Box, Static, Text, useInput } from 'ink'; import BigText from 'ink-big-text'; import Gradient from 'ink-gradient'; import EventEmitter from 'node:events'; @@ -8,6 +7,12 @@ import React from 'react'; import { RenderEvent } from '../../reporters/reporter.js'; export function InitBanner(props: { emitter: EventEmitter }) { + useInput((_, key) => { + if (key.return) { + props.emitter.emit(RenderEvent.PROMPT_RESULT); + } + }); + return { () => @@ -20,6 +25,6 @@ export function InitBanner(props: { emitter: EventEmitter }) { Codify will scan your system for any supported programs or settings and automatically generate configs for you. } -