diff --git a/package-lock.json b/package-lock.json index ab8a6b82..82a1a790 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "codify", - "version": "1.1.0-beta6", + "version": "1.1.1-beta.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codify", - "version": "1.1.0-beta6", + "version": "1.1.1-beta.6", + "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@codifycli/ink-form": "0.0.12", - "@codifycli/schemas": "1.1.0-beta8", - "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5", + "@codifycli/schemas": "1.2.0", + "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", "@mischnic/json-sourcemap": "^0.1.1", "@oclif/core": "^4.0.8", "@oclif/plugin-autocomplete": "^3.2.24", @@ -50,7 +51,7 @@ "codify": "bin/run.js" }, "devDependencies": { - "@codifycli/plugin-core": "^1.1.0-beta19", + "@codifycli/plugin-core": "^1.2.0", "@oclif/prettier-config": "^0.2.1", "@types/chalk": "^2.2.0", "@types/cors": "^2.8.19", @@ -1089,13 +1090,13 @@ } }, "node_modules/@codifycli/plugin-core": { - "version": "1.1.0-beta19", - "resolved": "https://registry.npmjs.org/@codifycli/plugin-core/-/plugin-core-1.1.0-beta19.tgz", - "integrity": "sha512-ci8QU2xn3Zl50EdCA1ymi2KiwDQO43t27fG7cRqBnbCpQZgVtlSyV18xLd3td6rzigVVDNtCSY3a6ZayM7zhpg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@codifycli/plugin-core/-/plugin-core-1.2.0.tgz", + "integrity": "sha512-GvGRSZ1xtwF5TiiauV/VUGNnJPQ6TUhtGZfXqnIwCozdPgTFp3AYH49q7Pbd7AYAG+5pnFUa9J4yO6WNUfDeWA==", "dev": true, "license": "ISC", "dependencies": { - "@codifycli/schemas": "^1.1.0-beta8", + "@codifycli/schemas": "^1.2.0", "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", "ajv": "^8.18.0", "ajv-formats": "^2.1.1", @@ -1113,21 +1114,6 @@ "node": ">=22.0.0" } }, - "node_modules/@codifycli/plugin-core/node_modules/@homebridge/node-pty-prebuilt-multiarch": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@homebridge/node-pty-prebuilt-multiarch/-/node-pty-prebuilt-multiarch-0.13.1.tgz", - "integrity": "sha512-ccQ60nMcbEGrQh0U9E6x0ajW9qJNeazpcM/9CH6J8leyNtJgb+gu24WTBAfBUVeO486ZhscnaxLEITI2HXwhow==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-addon-api": "^7.1.0", - "prebuild-install": "^7.1.2" - }, - "engines": { - "node": ">=18.0.0 <25.0.0" - } - }, "node_modules/@codifycli/plugin-core/node_modules/ajv-formats": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", @@ -1161,9 +1147,9 @@ } }, "node_modules/@codifycli/schemas": { - "version": "1.1.0-beta8", - "resolved": "https://registry.npmjs.org/@codifycli/schemas/-/schemas-1.1.0-beta8.tgz", - "integrity": "sha512-2PLCPmU2mtDilqx71uQIjpZLnvqSkdSR+BgImN6eRbRWKJcfltBEONPAlRhRU74kAyURpqCfDSLKTYa1MqLxZw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@codifycli/schemas/-/schemas-1.2.0.tgz", + "integrity": "sha512-ZUx8+IsW8ZvBWu+ilbUsK+vE1oMaiDvsDlgQoe92scRZUh1pFMPw6303N1T9BTep+sooRE5X4Y9IRloPQOjRjQ==", "license": "ISC", "dependencies": { "ajv": "^8.18.0" @@ -2002,14 +1988,17 @@ } }, "node_modules/@homebridge/node-pty-prebuilt-multiarch": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@homebridge/node-pty-prebuilt-multiarch/-/node-pty-prebuilt-multiarch-0.12.0.tgz", - "integrity": "sha512-hJCGcfOnMeRh2KUdWPlVN/1egnfqI4yxgpDhqHSkF2DLn5fiJNdjEHHlcM1K2w9+QBmRE2D/wfmM4zUOb8aMyQ==", + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@homebridge/node-pty-prebuilt-multiarch/-/node-pty-prebuilt-multiarch-0.13.1.tgz", + "integrity": "sha512-ccQ60nMcbEGrQh0U9E6x0ajW9qJNeazpcM/9CH6J8leyNtJgb+gu24WTBAfBUVeO486ZhscnaxLEITI2HXwhow==", "hasInstallScript": true, "license": "MIT", "dependencies": { "node-addon-api": "^7.1.0", "prebuild-install": "^7.1.2" + }, + "engines": { + "node": ">=18.0.0 <25.0.0" } }, "node_modules/@humanfs/core": { diff --git a/package.json b/package.json index 75e64435..0e46cb16 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ }, "dependencies": { "@codifycli/ink-form": "0.0.12", - "@codifycli/schemas": "1.1.0-beta8", - "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5", + "@codifycli/schemas": "1.2.0", + "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", "@mischnic/json-sourcemap": "^0.1.1", "@oclif/core": "^4.0.8", "@oclif/plugin-autocomplete": "^3.2.24", @@ -43,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.1.0-beta19", + "@codifycli/plugin-core": "^1.2.0", "@oclif/prettier-config": "^0.2.1", "@types/chalk": "^2.2.0", "@types/cors": "^2.8.19", @@ -145,7 +145,7 @@ "deploy": "npm run pkg && npm run notarize && npm run upload", "prepublishOnly": "npm run build" }, - "version": "1.1.0", + "version": "1.2.0-beta.4", "bugs": "https://github.com/codifycli/codify/issues", "keywords": [ "oclif", diff --git a/src/common/errors.ts b/src/common/errors.ts index c3acaca3..bad384ef 100644 --- a/src/common/errors.ts +++ b/src/common/errors.ts @@ -250,6 +250,69 @@ export class PluginError extends CodifyError { } } +export class ShellValidationError extends CodifyError { + name = 'ShellValidationError'; + timedOut: boolean; + capturedOutput: string; + rcFiles: string[]; + + constructor(timedOut: boolean, capturedOutput: string, rcFiles: string[]) { + super(timedOut + ? 'Shell initialization timed out — a shell rc file may be waiting for input' + : 'Shell initialization produced unexpected output' + ); + this.timedOut = timedOut; + this.capturedOutput = capturedOutput; + this.rcFiles = rcFiles; + } + + formattedMessage(): string { + const rcFileList = this.rcFiles.map((f) => chalk.white(` • ${f}`)).join('\n'); + const indentedOutput = (text: string) => (text || '(none)') + .split('\n') + .map((l) => chalk.red('│ ') + chalk.white(l)) + .join('\n'); + + if (this.timedOut) { + return [ + chalk.bold('Shell Validation Error: Shell timed out after 10 seconds'), + '', + 'Something in your shell initialization is waiting for interactive input', + chalk.white('(e.g. a password prompt, a `read` call, or an SSH key passphrase)'), + '', + chalk.white('Codify sources your interactive shell to install tools exactly as you would.') + + ' A shell that hangs on startup will prevent Codify from running.', + '', + chalk.bold('Output captured before timeout:'), + indentedOutput(this.capturedOutput), + '', + chalk.bold('Check the following shell rc files for interactive prompts:'), + rcFileList, + ].join('\n'); + } + + return [ + chalk.bold('Shell Validation Error: Unexpected output detected on shell startup'), + '', + chalk.bold('Unexpected output:'), + indentedOutput(this.capturedOutput), + '', + 'Your shell initialization is printing extra output', + chalk.white('(e.g. a greeting, banner, or debug message)'), + chalk.white('Codify sources your interactive shell to install tools exactly as you would.') + + ' Any extra output from your rc files will break plugin commands that parse shell output.', + '', + chalk.bold('Check the following rc files and wrap output-producing lines in an interactive guard:'), + rcFileList, + '', + chalk.bold('Example fix for .zshrc / .bashrc:'), + chalk.white(' if [[ "$TERM_PROGRAM" != "codify" ]]; then'), + chalk.white(' echo "your greeting here" # skipped when Codify sources your shell'), + chalk.white(' fi'), + ].join('\n'); + } +} + 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 39cb5e95..c336f1a8 100644 --- a/src/common/initialize-plugins.ts +++ b/src/common/initialize-plugins.ts @@ -11,6 +11,7 @@ import { CODIFY_FILE_REGEX, CodifyParser } from '../parser/index.js'; import { PluginManager, ResourceDefinitionMap } from '../plugins/plugin-manager.js'; import { Reporter } from '../ui/reporters/reporter.js'; import { FileUtils } from '../utils/file.js'; +import { ShellUtils } from '../utils/shell.js'; export interface InitializeArgs { path?: string; @@ -34,6 +35,8 @@ export class PluginInitOrchestrator { args: InitializeArgs, reporter: Reporter, ): Promise { + await ShellUtils.validateShell(); + const project = await PluginInitOrchestrator.parseProject( args, reporter diff --git a/src/entities/apply-note.ts b/src/entities/apply-note.ts new file mode 100644 index 00000000..47ac8710 --- /dev/null +++ b/src/entities/apply-note.ts @@ -0,0 +1,4 @@ +export interface ApplyNote { + message: string; + resourceType?: string; +} diff --git a/src/entities/apply-result.ts b/src/entities/apply-result.ts index 656424de..c65f7abe 100644 --- a/src/entities/apply-result.ts +++ b/src/entities/apply-result.ts @@ -1,6 +1,7 @@ import { ResourceOperation } from '@codifycli/schemas'; import { PluginError } from '../common/errors.js'; +import { ApplyNote } from './apply-note.js'; import { ResourcePlan } from './plan.js'; export interface ApplyResultEntry { @@ -13,6 +14,7 @@ export interface ApplyResultEntry { export interface ApplyResult { entries: ApplyResultEntry[]; errors: PluginError[]; + notes: ApplyNote[]; isPartialFailure(): boolean; } @@ -21,9 +23,8 @@ export function createApplyResult( succeededPlans: ResourcePlan[], failedErrors: PluginError[], skippedIds: Set, + notes: ApplyNote[] = [], ): ApplyResult { - const failedByType = new Map(failedErrors.map((e) => [e.resourceType, e])); - const entries: ApplyResultEntry[] = [ ...succeededPlans.map((p) => ({ id: p.id, @@ -46,6 +47,7 @@ export function createApplyResult( return { entries, errors: failedErrors, + notes, isPartialFailure() { return failedErrors.length > 0; }, diff --git a/src/entities/depends-on-resolution.test.ts b/src/entities/depends-on-resolution.test.ts new file mode 100644 index 00000000..e4a0a129 --- /dev/null +++ b/src/entities/depends-on-resolution.test.ts @@ -0,0 +1,129 @@ +import { 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'; + +function makeProject(...configs: ResourceConfig[]): Project { + return new Project(null, configs, []); +} + +describe('dependsOn resolution', () => { + it('resolves by type — all resources of that type become dependencies', () => { + const a = new ResourceConfig({ type: 'npm', name: 'lodash' }); + const b = new ResourceConfig({ type: 'npm', name: 'react' }); + const c = new ResourceConfig({ type: 'node', dependsOn: ['npm'] }); + + const project = makeProject(a, b, c); + project.resolveDependenciesAndCalculateEvalOrder(); + + expect(c.dependencyIds).toContain('npm.lodash'); + expect(c.dependencyIds).toContain('npm.react'); + expect(project.evaluationOrder).toContain('npm.lodash'); + expect(project.evaluationOrder).toContain('npm.react'); + expect(project.evaluationOrder!.indexOf('node')).toBeGreaterThan(project.evaluationOrder!.indexOf('npm.lodash')); + expect(project.evaluationOrder!.indexOf('node')).toBeGreaterThan(project.evaluationOrder!.indexOf('npm.react')); + }); + + it('resolves by fully qualified id (type.name) — exactly one resource', () => { + const a = new ResourceConfig({ type: 'git-clone', name: 'my-repo' }); + const b = new ResourceConfig({ type: 'git-clone', name: 'other-repo' }); + const c = new ResourceConfig({ type: 'node', dependsOn: ['git-clone.my-repo'] }); + + const project = makeProject(a, b, c); + project.resolveDependenciesAndCalculateEvalOrder(); + + expect(c.dependencyIds).toEqual(['git-clone.my-repo']); + expect(c.dependencyIds).not.toContain('git-clone.other-repo'); + }); + + it('throws when a fully qualified id is not found', () => { + const a = new ResourceConfig({ type: 'git-clone', name: 'my-repo' }); + const c = new ResourceConfig({ type: 'node', dependsOn: ['git-clone.missing'] }); + + const project = makeProject(a, c); + expect(() => project.resolveDependenciesAndCalculateEvalOrder()).toThrow(/git-clone\.missing/); + }); + + it('resolves by name alone when unambiguous', () => { + const a = new ResourceConfig({ type: 'git-clone', name: 'my-repo' }); + const c = new ResourceConfig({ type: 'node', dependsOn: ['my-repo'] }); + + const project = makeProject(a, c); + project.resolveDependenciesAndCalculateEvalOrder(); + + expect(c.dependencyIds).toEqual(['git-clone.my-repo']); + }); + + it('resolves all resources sharing the same name (across different types)', () => { + const a = new ResourceConfig({ type: 'git-clone', name: 'shared' }); + const b = new ResourceConfig({ type: 'npm', name: 'shared' }); + const c = new ResourceConfig({ type: 'node', dependsOn: ['shared'] }); + + const project = makeProject(a, b, c); + project.resolveDependenciesAndCalculateEvalOrder(); + + expect(c.dependencyIds).toContain('git-clone.shared'); + expect(c.dependencyIds).toContain('npm.shared'); + }); + + it('throws when the reference matches nothing', () => { + const a = new ResourceConfig({ type: 'npm' }); + const c = new ResourceConfig({ type: 'node', dependsOn: ['nonexistent'] }); + + const project = makeProject(a, c); + expect(() => project.resolveDependenciesAndCalculateEvalOrder()).toThrow(/nonexistent/); + }); +}); + +describe('dependsOn resolution — OS filtering', () => { + it('drops dependsOn entries for resources removed by OS filter (fully qualified id)', () => { + vi.spyOn(OsUtils, 'getOs').mockReturnValue(ResourceOs.MACOS); + const apt = new ResourceConfig({ type: 'apt', name: 'linux-packages', os: [ResourceOs.LINUX] }); + const docker = new ResourceConfig({ type: 'docker', dependsOn: ['apt.linux-packages'] }); + + const project = makeProject(apt, docker); + project.removeResourcesUsingOsFilter(); // removes apt on macOS + project.resolveDependenciesAndCalculateEvalOrder(); + + expect(docker.dependencyIds).not.toContain('apt.linux-packages'); + }); + + it('drops dependsOn entries for resources removed by OS filter (type reference)', () => { + vi.spyOn(OsUtils, 'getOs').mockReturnValue(ResourceOs.MACOS); + const apt = new ResourceConfig({ type: 'apt', os: [ResourceOs.LINUX] }); + const docker = new ResourceConfig({ type: 'docker', dependsOn: ['apt'] }); + + const project = makeProject(apt, docker); + project.removeResourcesUsingOsFilter(); + project.resolveDependenciesAndCalculateEvalOrder(); + + expect(docker.dependencyIds).not.toContain('apt'); + }); + + it('keeps dependsOn entry when only some resources of a type are filtered', () => { + vi.spyOn(OsUtils, 'getOs').mockReturnValue(ResourceOs.MACOS); + const aptLinux = new ResourceConfig({ type: 'apt', name: 'linux', os: [ResourceOs.LINUX] }); + const aptAll = new ResourceConfig({ type: 'apt', name: 'all' }); // no OS filter + const docker = new ResourceConfig({ type: 'docker', dependsOn: ['apt'] }); + + const project = makeProject(aptLinux, aptAll, docker); + project.removeResourcesUsingOsFilter(); // removes apt.linux but keeps apt.all + project.resolveDependenciesAndCalculateEvalOrder(); + + expect(docker.dependencyIds).toContain('apt.all'); + }); + + it('reproduces the reported cross-OS config scenario without throwing', () => { + vi.spyOn(OsUtils, 'getOs').mockReturnValue(ResourceOs.MACOS); + const homebrew = new ResourceConfig({ type: 'homebrew', name: 'macos-packages', os: [ResourceOs.MACOS] }); + const apt = new ResourceConfig({ type: 'apt', name: 'linux-packages', os: [ResourceOs.LINUX] }); + const docker = new ResourceConfig({ type: 'docker', dependsOn: ['homebrew.macos-packages', 'apt.linux-packages'] }); + const nvm = new ResourceConfig({ type: 'nvm', dependsOn: ['homebrew.macos-packages', 'apt.linux-packages'] }); + + const project = makeProject(homebrew, apt, docker, nvm); + project.removeResourcesUsingOsFilter(); // on macOS: removes apt, keeps homebrew + + expect(() => project.resolveDependenciesAndCalculateEvalOrder()).not.toThrow(); + }); +}); diff --git a/src/entities/project.ts b/src/entities/project.ts index 587da3da..23b1ba04 100644 --- a/src/entities/project.ts +++ b/src/entities/project.ts @@ -207,13 +207,15 @@ ${JSON.stringify(projectConfigs, null, 2)}`); } removeResourcesUsingOsFilter() { + const before = new Set(this.resourceConfigs.map((r) => r.id)); this.resourceConfigs = this.resourceConfigs.filter((r) => { if (!r.os) { return true; } return r.os.includes(OsUtils.getOs()); - }); + }); + this.pruneDependsOnForRemovedResources(before); } async removeResourcesUsingDistroFilter() { @@ -226,6 +228,7 @@ ${JSON.stringify(projectConfigs, null, 2)}`); return; } + const before = new Set(this.resourceConfigs.map((r) => r.id)); this.resourceConfigs = this.resourceConfigs.filter((r) => { if (!r.distro || r.distro.length === 0) { return true; @@ -233,6 +236,7 @@ ${JSON.stringify(projectConfigs, null, 2)}`); return r.distro.some((d) => OsUtils.distroMatchesCurrent(d, currentDistro)); }); + this.pruneDependsOnForRemovedResources(before); } resolveDependenciesAndCalculateEvalOrder(resourceDefinitions?: ResourceDefinitionMap) { @@ -318,27 +322,60 @@ ${JSON.stringify(projectConfigs, null, 2)}`); } } - /** - * This function supports both full (type.name) and partial IDs (type) when matching. It's meant - * for the dependsOn field to simplify dependency resolution for. users. - * @param resourceMap - * @param idOrType - * @private - */ private getMatchingResourceIds( resourceMap: Map, idOrType: string ): string[] { - const hasName = idOrType.includes('.'); - - if (hasName) { - // Full ID (type.name): return exact match or empty array - return resourceMap.has(idOrType) ? [idOrType] : []; - } else { - // Partial ID (type only): return all resources with this type - return [...resourceMap.values()] - .filter((resource) => resource.type === idOrType) - .map((resource) => resource.id); + // Fully qualified ID (type.name): must match exactly or throw + if (idOrType.includes('.')) { + if (!resourceMap.has(idOrType)) { + throw new Error(`dependsOn reference "${idOrType}" was not found`); + } + return [idOrType]; + } + + // Type match: all resources of that type + const byType = [...resourceMap.values()].filter((r) => r.type === idOrType); + if (byType.length > 0) { + return byType.map((r) => r.id); + } + + // Name match: all resources with that name + const byName = [...resourceMap.values()].filter((r) => r.name === idOrType); + return byName.map((r) => r.id); // empty array if 0 → caller throws + } + + // After OS/distro filtering, remove dependsOn entries that pointed exclusively to + // resources that were filtered out. This allows cross-OS dependsOn references + // (e.g. depending on an apt resource from a docker resource) without errors. + private pruneDependsOnForRemovedResources(idsBefore: Set) { + const remainingIds = new Set(this.resourceConfigs.map((r) => r.id)); + const removedIds = [...idsBefore].filter((id) => !remainingIds.has(id)); + if (removedIds.length === 0) { + return; + } + + const removedSet = new Set(removedIds); + const remaining = this.resourceConfigs; + + for (const r of remaining) { + r.dependsOn = r.dependsOn.filter((ref) => { + // Fully qualified (type.name): drop if that exact id was removed + if (ref.includes('.')) { + return !removedSet.has(ref); + } + // Type reference: drop only if no remaining resource has that type + const anyRemainingOfType = remaining.some((rm) => rm.type === ref); + if (removedIds.some((id) => id === ref || id.startsWith(`${ref}.`))) { + return anyRemainingOfType; + } + // Name reference: drop only if no remaining resource has that name + const anyRemainingWithName = remaining.some((rm) => rm.name === ref); + if (removedIds.some((id) => id.endsWith(`.${ref}`))) { + return anyRemainingWithName; + } + return true; + }); } } } diff --git a/src/events/context.ts b/src/events/context.ts index c3b0dfac..b6349270 100644 --- a/src/events/context.ts +++ b/src/events/context.ts @@ -1,4 +1,4 @@ -import { CommandRequestData, CommandRequestResponseData } from '@codifycli/schemas'; +import { ApplyNoteRequestData, CommandRequestData, CommandRequestResponseData } from '@codifycli/schemas'; import { EventEmitter } from 'node:events'; export enum Event { @@ -18,6 +18,7 @@ export enum Event { PRESS_KEY_TO_CONTINUE_COMPLETED = 'press_key_to_continue_completed', CODIFY_LOGIN_CREDENTIALS_REQUEST = 'codify_login_credentials_request', CODIFY_LOGIN_CREDENTIALS_COMPLETED = 'codify_login_credentials_completed', + APPLY_NOTE_REQUEST = 'apply_note_request', } export enum ProcessName { @@ -141,6 +142,10 @@ export const ctx = new class { this.emitter.emit(Event.CODIFY_LOGIN_CREDENTIALS_COMPLETED, pluginName, credentials); } + applyNoteRequested(pluginName: string, data: ApplyNoteRequestData) { + this.emitter.emit(Event.APPLY_NOTE_REQUEST, pluginName, data); + } + async subprocess(name: string, run: () => Promise): Promise { this.emitter.emit(Event.SUB_PROCESS_START, name); const result = await run(); diff --git a/src/plugins/plugin-manager.ts b/src/plugins/plugin-manager.ts index 9435bfd5..bb5f07a7 100644 --- a/src/plugins/plugin-manager.ts +++ b/src/plugins/plugin-manager.ts @@ -1,4 +1,5 @@ import { + ApplyNoteRequestData, ImportResponseData, ResourceDefinition, ResourceJson, ValidateResponseData, @@ -6,12 +7,13 @@ import { import { InternalError, PluginError } from '../common/errors.js'; import { config } from '../config.js'; +import { ApplyNote } from '../entities/apply-note.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, SubprocessFinishStatus, ctx } from '../events/context.js'; +import { Event, 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'; @@ -142,6 +144,12 @@ export class PluginManager { const collectedErrors: PluginError[] = []; const skippedIds = new Set(); const succeededPlans: ResourcePlan[] = []; + const collectedNotes: ApplyNote[] = []; + + const noteListener = (_pluginName: string, data: ApplyNoteRequestData) => { + collectedNotes.push({ message: data.message, resourceType: data.resourceType }); + }; + ctx.on(Event.APPLY_NOTE_REQUEST, noteListener); for (const id of project.evaluationOrder ?? []) { if (skippedIds.has(id)) { @@ -179,7 +187,8 @@ export class PluginManager { } } - return createApplyResult(succeededPlans, collectedErrors, skippedIds); + ctx.emitter.removeListener(Event.APPLY_NOTE_REQUEST, noteListener); + return createApplyResult(succeededPlans, collectedErrors, skippedIds, collectedNotes); } async setVerbosityLevel(verbosityLevel: number): Promise { diff --git a/src/plugins/plugin-process.ts b/src/plugins/plugin-process.ts index 63e6f8fc..813c3372 100644 --- a/src/plugins/plugin-process.ts +++ b/src/plugins/plugin-process.ts @@ -1,4 +1,6 @@ import { + ApplyNoteRequestData, + ApplyNoteRequestDataSchema, CommandRequestData, CommandRequestDataSchema, CommandRequestResponseData, @@ -19,6 +21,7 @@ import { PluginMessage } from './plugin-message.js'; export const ipcMessageValidator = ajv.compile(IpcMessageV2Schema); export const commandRequestValidator = ajv.compile(CommandRequestDataSchema); export const pressKeyToContinueRequestValidator = ajv.compile(PressKeyToContinueRequestDataSchema); +export const applyNoteRequestValidator = ajv.compile(ApplyNoteRequestDataSchema); const DEFAULT_NODE_MODULES_DIR = '/usr/local/lib/codify/node_modules/' @@ -122,6 +125,21 @@ export class PluginProcess { } + if (message.cmd === MessageCmd.APPLY_NOTE_REQUEST) { + const { data, requestId } = message; + if (!applyNoteRequestValidator(data)) { + throw new Error(`Invalid apply note request from plugin ${pluginName}. ${JSON.stringify(applyNoteRequestValidator.errors, null, 2)}`); + } + + process.send({ + cmd: returnMessageCmd(MessageCmd.APPLY_NOTE_REQUEST), + requestId, + data: {}, + }); + + return ctx.applyNoteRequested(pluginName, data as unknown as ApplyNoteRequestData); + } + if (message.cmd === MessageCmd.CODIFY_CREDENTIALS_REQUEST) { if (pluginName !== 'default') { throw new Error(`Only the default Codify plugin is able to request Codify credentials. ${pluginName}`); diff --git a/src/ui/components/widgets/ApplyComplete.tsx b/src/ui/components/widgets/ApplyComplete.tsx index 8ec2d792..d146f24a 100644 --- a/src/ui/components/widgets/ApplyComplete.tsx +++ b/src/ui/components/widgets/ApplyComplete.tsx @@ -91,12 +91,33 @@ export function ApplyComplete({ result }: { result: ApplyResult }) { )} - {!isPartial && ( - - Open a new terminal or source '.zshrc' for the new changes to be reflected + {result.notes.length > 0 && ( + + {groupNotesByMessage(result.notes).map(({ message, resourceTypes }) => ( + + {'⚠ '} + {resourceTypes.length > 0 && {resourceTypes.join(', ')}: } + {message} + + ))} )} ); } + +function groupNotesByMessage(notes: ApplyResult['notes']): { message: string; resourceTypes: string[] }[] { + const map = new Map(); + for (const note of notes) { + const existing = map.get(note.message); + if (existing) { + if (note.resourceType && !existing.includes(note.resourceType)) { + existing.push(note.resourceType); + } + } else { + map.set(note.message, note.resourceType ? [note.resourceType] : []); + } + } + return [...map.entries()].map(([message, resourceTypes]) => ({ message, resourceTypes })); +} diff --git a/src/ui/plan-pretty-printer.ts b/src/ui/plan-pretty-printer.ts index 4d05a931..1835145e 100644 --- a/src/ui/plan-pretty-printer.ts +++ b/src/ui/plan-pretty-printer.ts @@ -91,14 +91,35 @@ function prettyFormatModifyPlan(plan: ResourcePlan): string { ]; for (const parameter of plan.parameters) { - // TODO: Add support for object types as well in the future + if (isPlainObject(parameter.previousValue) && parameter.operation === ParameterOperation.NOOP) { + continue; + } + if ((Array.isArray(parameter.previousValue) || parameter.previousValue === null) && (Array.isArray(parameter.newValue) || parameter.newValue === null) && !(parameter.previousValue === null && parameter.newValue === null) && !parameter.isSensitive ) { - const line = formatArray(parameter); - builder.push(line); + builder.push(formatArray(parameter)); + } else if ( + !parameter.isSensitive + && isPlainObject(parameter.previousValue) + && isPlainObject(parameter.newValue) + && parameter.operation === ParameterOperation.MODIFY + ) { + builder.push(formatObjectDiff(parameter.name, parameter.previousValue, parameter.newValue)); + } else if ( + !parameter.isSensitive + && isPlainObject(parameter.newValue) + && (parameter.operation === ParameterOperation.ADD || parameter.operation === ParameterOperation.NOOP) + ) { + builder.push(formatObjectSingleSide(parameter.name, parameter.newValue, parameter.operation)); + } else if ( + !parameter.isSensitive + && isPlainObject(parameter.previousValue) + && parameter.operation === ParameterOperation.REMOVE + ) { + builder.push(formatObjectSingleSide(parameter.name, parameter.previousValue, parameter.operation)); } else { const formattedParameter = formatParameter(parameter); @@ -127,7 +148,7 @@ function formatParameter(parameter: PlanResponseData['parameters'][0]): string { return typeof parameter.newValue === 'string' ? `"${parameter.name}": "${escapeNewlines(value as string)}",` - : `"${parameter.name}": ${value},` + : `"${parameter.name}": ${typeof value === 'object' ? JSON.stringify(value) : value},` } case ParameterOperation.ADD: { @@ -135,7 +156,7 @@ function formatParameter(parameter: PlanResponseData['parameters'][0]): string { return typeof parameter.newValue === 'string' ? chalk.green(`"${parameter.name}": "${escapeNewlines(value as string)}",`) - : chalk.green(`"${parameter.name}": ${value},`) + : chalk.green(`"${parameter.name}": ${typeof value === 'object' ? JSON.stringify(value) : value},`) } case ParameterOperation.REMOVE: { @@ -143,16 +164,20 @@ function formatParameter(parameter: PlanResponseData['parameters'][0]): string { return typeof parameter.previousValue === 'string' ? chalk.red(`"${parameter.name}": "${escapeNewlines(value as string)}",`) - : chalk.red(`"${parameter.name}": ${value},`) + : chalk.red(`"${parameter.name}": ${typeof value === 'object' ? JSON.stringify(value) : value},`) } case ParameterOperation.MODIFY: { const newValue = parameter.isSensitive ? '[Sensitive]' : parameter.newValue; const previousValue = parameter.isSensitive ? '[Sensitive]' : parameter.previousValue; - return typeof parameter.newValue === 'string' && typeof parameter.previousValue === 'string' - ? `"${parameter.name}": "${escapeNewlines(previousValue as string)}" -> "${escapeNewlines(newValue as string)}",` - : `"${parameter.name}": ${previousValue} -> ${newValue},` + if (typeof parameter.newValue === 'string' && typeof parameter.previousValue === 'string') { + return `"${parameter.name}": "${escapeNewlines(previousValue as string)}" -> "${escapeNewlines(newValue as string)}",`; + } + + const prevFormatted = typeof previousValue === 'object' ? JSON.stringify(previousValue) : previousValue; + const newFormatted = typeof newValue === 'object' ? JSON.stringify(newValue) : newValue; + return `"${parameter.name}": ${prevFormatted} -> ${newFormatted},`; } } } @@ -201,6 +226,91 @@ function operationSymbol(operation: ParameterOperation): string { } } +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function formatObjectDiff(name: string, previousValue: Record, newValue: Record): string { + const allKeys = Array.from(new Set([...Object.keys(previousValue), ...Object.keys(newValue)])); + + type Entry = { op: 'noop' | 'add' | 'remove' | 'modify'; key: string; prev?: unknown; next?: unknown }; + const entries: Entry[] = allKeys.map((key) => { + const inPrev = Object.hasOwn(previousValue, key); + const inNext = Object.hasOwn(newValue, key); + if (!inPrev) return { op: 'add', key, next: newValue[key] }; + if (!inNext) return { op: 'remove', key, prev: previousValue[key] }; + if (JSON.stringify(previousValue[key]) === JSON.stringify(newValue[key])) { + return { op: 'noop', key, next: newValue[key] }; + } + return { op: 'modify', key, prev: previousValue[key], next: newValue[key] }; + }); + + const CONTEXT = 2; + const includedIndices = new Set(); + for (let i = 0; i < entries.length; i++) { + if (entries[i].op !== 'noop') { + for (let j = Math.max(0, i - CONTEXT); j <= Math.min(entries.length - 1, i + CONTEXT); j++) { + includedIndices.add(j); + } + } + } + + // Layout: every line uses [3 spaces][sym][2 spaces (JSON indent)][content] + // sym col=3, content col=6 — matches the outer block's "~ content" (sym=0, content=5) + 1 level deeper + const resultLines: string[] = [`${chalk.yellow('~')} "${name}": {`]; + let lastIncluded = -1; + + for (let i = 0; i < entries.length; i++) { + if (!includedIndices.has(i)) continue; + if (lastIncluded !== -1 && i > lastIncluded + 1) { + resultLines.push(' ...'); + } + lastIncluded = i; + const { op, key, prev, next } = entries[i]; + + // All inner lines: sym at col 4, content at col 7 (2-space JSON indent relative to { at col 5). + // Noop uses a space for sym so content stays at col 7. + if (op === 'noop') { + resultLines.push(` "${key}": ${formatValue(next)},`); + } else if (op === 'add') { + resultLines.push(` ${chalk.green('+')} ${chalk.green(`"${key}": ${formatValue(next)},`)}`); + } else if (op === 'remove') { + resultLines.push(` ${chalk.red('-')} ${chalk.red(`"${key}": ${formatValue(prev)},`)}`); + } else { + resultLines.push(` ${chalk.yellow('~')} ${chalk.yellow(`"${key}": ${formatValue(prev)} -> ${formatValue(next)},`)}`); + } + } + + resultLines.push(' },'); + return resultLines.join('\n'); +} + +function formatValue(value: unknown): string { + if (typeof value === 'string') return `"${value}"`; + if (value === null || value === undefined) return String(value); + return JSON.stringify(value); +} + +const OBJECT_SINGLE_SIDE_MAX_LINES = 20; + +function formatObjectSingleSide(name: string, value: object, operation: ParameterOperation): string { + const json = JSON.stringify(value, null, 2); + const lines = json.split('\n'); + const truncated = lines.length > OBJECT_SINGLE_SIDE_MAX_LINES; + const visibleLines = truncated ? lines.slice(0, OBJECT_SINGLE_SIDE_MAX_LINES) : lines; + + const colorFn = operation === ParameterOperation.REMOVE ? chalk.red : chalk.green; + const sym = operationSymbol(operation); + + const formatted = visibleLines + .map((l, idx) => idx === 0 ? `"${name}": ${l}` : l) + .map((l) => ` ${colorFn(l)}`) + .map((l, idx) => idx === 0 ? sym + l : ` ${l}`) + .join('\n'); + + return truncated ? formatted + '\n ...' : formatted + ','; +} + function formatArray(parameter: PlanResponseData['parameters'][0]): string { const { name, newValue, operation, previousValue } = parameter; const a = previousValue as null | unknown[]; diff --git a/src/ui/reporters/json-reporter.ts b/src/ui/reporters/json-reporter.ts index 84533a6d..d1bcd163 100644 --- a/src/ui/reporters/json-reporter.ts +++ b/src/ui/reporters/json-reporter.ts @@ -99,6 +99,7 @@ export class JsonReporter implements Reporter { resourceType: error.resourceType, data: error.errorData.data, })), + notes: result.notes, }, null, 2)); } } diff --git a/src/ui/reporters/plain-reporter.ts b/src/ui/reporters/plain-reporter.ts index c988cf92..cedad432 100644 --- a/src/ui/reporters/plain-reporter.ts +++ b/src/ui/reporters/plain-reporter.ts @@ -196,9 +196,30 @@ Use this init flow to get started quickly with Codify. for (const error of result.errors) { await this.displayPluginError(error); } - } else { + } + + if (result.notes.length > 0) { ctx.log(''); - ctx.log('Open a new terminal or source \'.zshrc\' for the new changes to be reflected'); + const grouped = groupNotesByMessage(result.notes); + for (const { message, resourceTypes } of grouped) { + const prefix = resourceTypes.length > 0 ? `${resourceTypes.join(', ')}: ` : ''; + ctx.log(chalk.yellow(`⚠ ${prefix}${message}`)); + } + } + } +} + +function groupNotesByMessage(notes: ApplyResult['notes']): { message: string; resourceTypes: string[] }[] { + const map = new Map(); + for (const note of notes) { + const existing = map.get(note.message); + if (existing) { + if (note.resourceType && !existing.includes(note.resourceType)) { + existing.push(note.resourceType); + } + } else { + map.set(note.message, note.resourceType ? [note.resourceType] : []); } } + return [...map.entries()].map(([message, resourceTypes]) => ({ message, resourceTypes })); } diff --git a/src/utils/desktop-installer.ts b/src/utils/desktop-installer.ts index c3f790cd..b90aa86e 100644 --- a/src/utils/desktop-installer.ts +++ b/src/utils/desktop-installer.ts @@ -8,22 +8,22 @@ import { OsUtils } from './os-utils.js'; import { spawn } from './spawn.js'; const DESKTOP_APP_PATHS = { - darwin: '/Applications/Codify.app', - linux: '/usr/bin/codify', + darwin: '/Applications/CodifyApp.app', + linux: '/usr/bin/codify-desktop', }; const DOWNLOAD_URLS: Record> = { darwin: { - arm64: 'https://releases-desktop.codifycli.com/channels/stable/Codify_aarch64.dmg', - x64: 'https://releases-desktop.codifycli.com/channels/stable/Codify_x64.dmg', + arm64: 'https://releases-desktop.codifycli.com/channels/stable/CodifyApp_aarch64.dmg', + x64: 'https://releases-desktop.codifycli.com/channels/stable/CodifyApp_x64.dmg', }, linux_deb: { - arm64: 'https://releases-desktop.codifycli.com/channels/stable/Codify_arm64.deb', - x64: 'https://releases-desktop.codifycli.com/channels/stable/Codify_amd64.deb', + arm64: 'https://releases-desktop.codifycli.com/channels/stable/CodifyApp_arm64.deb', + x64: 'https://releases-desktop.codifycli.com/channels/stable/CodifyApp_amd64.deb', }, linux_rpm: { - aarch64: 'https://releases-desktop.codifycli.com/channels/stable/Codify_aarch64.rpm', - x64: 'https://releases-desktop.codifycli.com/channels/stable/Codify_x86_64.rpm', + aarch64: 'https://releases-desktop.codifycli.com/channels/stable/CodifyApp_aarch64.rpm', + x64: 'https://releases-desktop.codifycli.com/channels/stable/CodifyApp_x86_64.rpm', }, }; @@ -79,14 +79,14 @@ export async function installDesktopApp(reporter: Reporter, url: string, platfor try { console.log('Installing Codify desktop app...'); await spawn(`hdiutil attach ${tmpFile} -mountpoint ${mountPoint} -nobrowse -quiet`); - await spawn(`cp -R ${mountPoint}/Codify.app /Applications/Codify.app`); + await spawn(`cp -R ${mountPoint}/CodifyApp.app /Applications/CodifyApp.app`); } finally { await spawn(`hdiutil detach ${mountPoint} -quiet`).catch(() => {}); await fs.unlink(tmpFile).catch(() => {}); } } else { const password = await reporter.promptSudo('codify-installer', { - command: platform === 'linux_deb' ? `dpkg -i ${tmpFile}` : `rpm -i ${tmpFile}`, + command: platform === 'linux_deb' ? `apt install -y ${tmpFile}` : `rpm -i ${tmpFile}`, options: { requiresRoot: true }, }); @@ -97,12 +97,22 @@ export async function installDesktopApp(reporter: Reporter, url: string, platfor try { console.log('Installing Codify desktop app...'); - const cmd = platform === 'linux_deb' ? `dpkg -i ${tmpFile}` : `rpm -i ${tmpFile}`; - await spawn(cmd, { requiresRoot: true }, undefined, password); + const cmd = platform === 'linux_deb' ? `apt install -y ${tmpFile}` : `rpm -i ${tmpFile}`; + try { + await spawn(cmd, { requiresRoot: true }, undefined, password); + } catch (e) { + if (platform === 'linux_deb') { + console.log('Fixing broken dependencies...'); + await spawn('apt-get install -f -y', { requiresRoot: true }, undefined, password); + await spawn(cmd, { requiresRoot: true }, undefined, password); + } else { + throw e; + } + } } finally { await fs.unlink(tmpFile).catch(() => {}); } } console.log('Codify desktop app installed successfully.'); -} \ No newline at end of file +} diff --git a/src/utils/shell.test.ts b/src/utils/shell.test.ts new file mode 100644 index 00000000..ec720e38 --- /dev/null +++ b/src/utils/shell.test.ts @@ -0,0 +1,56 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { ShellValidationError } from '../common/errors.js'; +import { ShellUtils } from './shell.js'; + +describe('ShellUtils.validateShell', () => { + it('passes on a clean shell', async () => { + await expect(ShellUtils.validateShell()).resolves.toBeUndefined(); + }); + + describe('dirty output', () => { + let originalShell: string | undefined; + let tmpScript: string; + + beforeEach(async () => { + originalShell = process.env.SHELL; + + // Write a wrapper script that prints unexpected output before delegating + const realShell = originalShell ?? '/bin/sh'; + tmpScript = path.join(os.tmpdir(), `codify-test-shell-${Date.now()}.sh`); + await fs.writeFile( + tmpScript, + `#!/bin/sh\necho "unexpected banner output"\nexec ${realShell} "$@"\n`, + { mode: 0o755 }, + ); + + process.env.SHELL = tmpScript; + }); + + afterEach(async () => { + process.env.SHELL = originalShell; + await fs.unlink(tmpScript).catch(() => {}); + }); + + it('throws ShellValidationError with timedOut=false when shell emits unexpected output', async () => { + await expect(ShellUtils.validateShell()).rejects.toMatchObject({ + name: 'ShellValidationError', + timedOut: false, + }); + }); + + it('includes the unexpected output in the error', async () => { + try { + await ShellUtils.validateShell(); + expect.fail('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(ShellValidationError); + const shellErr = err as ShellValidationError; + expect(shellErr.capturedOutput).toContain('unexpected banner output'); + } + }); + }); +}); diff --git a/src/utils/shell.ts b/src/utils/shell.ts index 29709d39..ea9a828f 100644 --- a/src/utils/shell.ts +++ b/src/utils/shell.ts @@ -1,7 +1,13 @@ import { LinuxDistro } from '@codifycli/schemas'; +import * as pty from '@homebridge/node-pty-prebuilt-multiarch'; import cp from 'node:child_process'; import * as fs from 'node:fs/promises'; import util from 'node:util'; +import os from 'node:os'; +import path from 'node:path'; +import stripAnsi from 'strip-ansi'; + +import { ShellValidationError } from '../common/errors.js'; const exec = util.promisify(cp.exec); @@ -50,6 +56,123 @@ export const ShellUtils = { return process.env.SHELL!; }, + getPrimaryShellRc(): string { + return this.getShellRcFiles()[0]; + }, + + getShellRcFiles(): string[] { + const shell = process.env.SHELL || os.userInfo().shell || ''; + const homeDir = os.homedir(); + + if (shell.endsWith('bash')) { + // Linux typically uses .bashrc, macOS uses .bash_profile + if (ShellUtils.isLinux()) { + return [ + path.join(homeDir, '.bashrc'), + path.join(homeDir, '.bash_profile'), + path.join(homeDir, '.profile'), + ]; + } + + return [ + path.join(homeDir, '.bash_profile'), + path.join(homeDir, '.bashrc'), + path.join(homeDir, '.profile'), + ]; + } + + if (shell.endsWith('zsh')) { + return [ + path.join(homeDir, '.zshrc'), + path.join(homeDir, '.zprofile'), + path.join(homeDir, '.zshenv'), + ]; + } + + if (shell.endsWith('sh')) { + return [ + path.join(homeDir, '.profile'), + ] + } + + if (shell.endsWith('ksh')) { + return [ + path.join(homeDir, '.profile'), + path.join(homeDir, '.kshrc'), + ] + } + + if (shell.endsWith('csh')) { + return [ + path.join(homeDir, '.cshrc'), + path.join(homeDir, '.login'), + path.join(homeDir, '.logout'), + ] + } + + if (shell.endsWith('fish')) { + return [ + path.join(homeDir, '.config/fish/config.fish'), + ] + } + + // Default to bash-style files + return [ + path.join(homeDir, '.bashrc'), + path.join(homeDir, '.bash_profile'), + path.join(homeDir, '.profile'), + ]; + }, + + isMacOS(): boolean { + return os.platform() === 'darwin'; + }, + + isLinux(): boolean { + return os.platform() === 'linux'; + }, + + async validateShell(): Promise { + const SENTINEL = 'CODIFY_SHELL_CHECK_OK'; + const TIMEOUT_MS = 10_000; + const shell = ShellUtils.getDefaultShell(); + const output: string[] = []; + + await new Promise((resolve, reject) => { + const mPty = pty.spawn(shell, ['-i', '-c', `echo '${SENTINEL}'`], { + cols: 80, + rows: 24, + env: { ...process.env as Record, TERM_PROGRAM: 'codify' }, + }); + + mPty.onData((data) => output.push(data)); + + const timer = setTimeout(() => { + mPty.kill(); + const captured = stripAnsi(output.join('').trim()); + reject(new ShellValidationError(true, captured, ShellUtils.getShellRcFiles())); + }, TIMEOUT_MS); + + mPty.onExit(() => { + clearTimeout(timer); + const captured = stripAnsi(output.join('').trim()); + + const lines = captured + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .filter((l) => !l.includes(`echo '${SENTINEL}'`) && l !== SENTINEL); + + const matchesSentinel = lines.length === 0; + if (!matchesSentinel) { + reject(new ShellValidationError(false, lines.join('\n'), ShellUtils.getShellRcFiles())); + } else { + resolve(); + } + }); + }); + }, + async getLinuxDistro(): Promise { for (const candidate of ['/etc/os-release', '/usr/lib/os-release']) { let osRelease: string; diff --git a/src/utils/spawn.ts b/src/utils/spawn.ts index 058620ee..f7bb8fd2 100644 --- a/src/utils/spawn.ts +++ b/src/utils/spawn.ts @@ -80,7 +80,12 @@ export async function spawnSafe(cmd: string, options?: SpawnOptions, pluginName? const initialCols = process.stdout.columns ?? 80; const initialRows = process.stdout.rows ?? 24; - const command = options?.requiresRoot ? `sudo -k >/dev/null 2>&1; sudo -S <<< "${password}" -E ${ShellUtils.getDefaultShell()} ${options?.interactive ? '-i' : ''} -c "${cmd.replaceAll('"', '\\"')}"` : cmd; + // zsh autocorrect prompts (e.g. "zsh: correct 'config' to '.config' [nyae]?") will hang + // the pty waiting for interactive input. Can't be disabled via env var; must unset the options explicitly. + const disableAutocorrect = ShellUtils.getShell() === Shell.ZSH ? 'unsetopt CORRECT CORRECT_ALL 2>/dev/null; ' : ''; + const command = options?.requiresRoot + ? `sudo -k >/dev/null 2>&1; sudo -S <<< "${password}" -E ${ShellUtils.getDefaultShell()} ${options?.interactive ? '-i' : ''} -c "${(disableAutocorrect + cmd).replaceAll('"', '\\"')}"` + : `${disableAutocorrect}${cmd}`; const args = options?.interactive ? ['-i', '-c', command] : ['-c', command] // Run the command in a pty for interactivity diff --git a/test/utils/plan-pretty-printer.test.ts b/test/utils/plan-pretty-printer.test.ts index 6047aa4d..cae92cf9 100644 --- a/test/utils/plan-pretty-printer.test.ts +++ b/test/utils/plan-pretty-printer.test.ts @@ -48,6 +48,156 @@ describe('Plan pretty printer', () => { console.log(prettyFormatResourcePlan(new ResourcePlan(plan))) }) + it('Can diff nested objects in modify plans', () => { + const plan: PlanResponseData = { + planId: 'id', + resourceType: 'macos-settings', + operation: ResourceOperation.MODIFY, + isStateful: false, + parameters: [ + { + name: 'mouse', + previousValue: { naturalScrolling: false }, + newValue: { naturalScrolling: true }, + operation: ParameterOperation.MODIFY, + isSensitive: false, + }, + { + name: 'trackpad', + previousValue: { + tapToClick: true, + scrollSpeed: 1, + pointerSpeed: 3, + naturalScrolling: false, + twoFingerSwipe: true, + }, + newValue: { + tapToClick: true, + scrollSpeed: 1, + pointerSpeed: 5, + naturalScrolling: false, + twoFingerSwipe: true, + }, + operation: ParameterOperation.MODIFY, + isSensitive: false, + }, + ] + } + + console.log(prettyFormatResourcePlan(new ResourcePlan(plan))) + }) + + it('Does not show object NOOPs in modify plans', () => { + const plan: PlanResponseData = { + planId: 'id', + resourceType: 'macos-settings', + operation: ResourceOperation.MODIFY, + isStateful: false, + parameters: [ + { + name: 'mouse', + previousValue: { naturalScrolling: false, speed: 1.5 }, + newValue: { naturalScrolling: false, speed: 1.5 }, + operation: ParameterOperation.NOOP, + isSensitive: false, + }, + { + name: 'keyboard', + previousValue: { initialKeyRepeat: 68, pressAndHold: false, fnKeysAsStandardKeys: true }, + newValue: { initialKeyRepeat: 68, pressAndHold: true, fnKeysAsStandardKeys: true }, + operation: ParameterOperation.MODIFY, + isSensitive: false, + }, + { + name: 'dock', + previousValue: { autohide: false, minimizeEffect: 'genie', position: 'bottom', showRecents: true }, + newValue: { autohide: false, minimizeEffect: 'genie', position: 'bottom', showRecents: true }, + operation: ParameterOperation.NOOP, + isSensitive: false, + }, + ] + } + + const result = prettyFormatResourcePlan(new ResourcePlan(plan)); + console.log(result); + // mouse and dock are NOOPs — they must not appear in output + if (result.includes('"mouse"') || result.includes('"dock"')) { + throw new Error('Object NOOP parameters should not appear in modify plan output'); + } + }) + + it('Hides object NOOPs and shows only modified object (plan response scenario)', () => { + const plan: PlanResponseData = { + planId: '28ec438e-0214-41bd-8498-6c3aa0382032', + resourceType: 'macos-settings', + operation: ResourceOperation.MODIFY, + isStateful: false, + parameters: [ + { + name: 'mouse', + previousValue: { naturalScrolling: true, speed: 1.5 }, + newValue: { naturalScrolling: true, speed: 1.5 }, + operation: ParameterOperation.NOOP, + isSensitive: false, + }, + { + name: 'keyboard', + previousValue: { pressAndHold: false, fnKeysAsStandardKeys: true }, + newValue: { keyRepeat: 6, initialKeyRepeat: 68, pressAndHold: true, fnKeysAsStandardKeys: true }, + operation: ParameterOperation.MODIFY, + isSensitive: false, + }, + { + name: 'dock', + previousValue: { position: 'bottom' }, + newValue: { position: 'bottom' }, + operation: ParameterOperation.NOOP, + isSensitive: false, + }, + ] + } + + const result = prettyFormatResourcePlan(new ResourcePlan(plan)); + console.log(result); + if (result.includes('"mouse"') || result.includes('"dock"')) { + throw new Error('Object NOOP parameters should not appear in modify plan output'); + } + }) + + it('Can diff nested objects with adds, removes, and modifies', () => { + const plan: PlanResponseData = { + planId: '18d9dbbc-9dd1-4581-9a6a-db146d44c829', + resourceType: 'macos-settings', + operation: ResourceOperation.MODIFY, + isStateful: false, + parameters: [ + { + name: 'mouse', + previousValue: { naturalScrolling: true, speed: 1.5 }, + newValue: { naturalScrolling: false, speed: 1.5 }, + operation: ParameterOperation.MODIFY, + isSensitive: false, + }, + { + name: 'keyboard', + previousValue: { pressAndHold: false, fnKeysAsStandardKeys: true }, + newValue: { keyRepeat: 6, initialKeyRepeat: 68, pressAndHold: true, fnKeysAsStandardKeys: true }, + operation: ParameterOperation.MODIFY, + isSensitive: false, + }, + { + name: 'dock', + previousValue: { position: 'bottom', autohide: true, minimizeEffect: 'scale' }, + newValue: { position: 'bottom', autohide: false, showRecents: true, minimizeEffect: 'genie' }, + operation: ParameterOperation.MODIFY, + isSensitive: false, + }, + ] + } + + console.log(prettyFormatResourcePlan(new ResourcePlan(plan))) + }) + it('Can print modify and re-create plans', () => { const plan: PlanResponseData = { planId: 'id',