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..6b690e8a 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.2", "bugs": "https://github.com/codifycli/codify/issues", "keywords": [ "oclif", 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/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..421b3c82 100644 --- a/src/ui/plan-pretty-printer.ts +++ b/src/ui/plan-pretty-printer.ts @@ -91,14 +91,31 @@ function prettyFormatModifyPlan(plan: ResourcePlan): string { ]; for (const parameter of plan.parameters) { - // TODO: Add support for object types as well in the future 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 +144,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 +152,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 +160,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 +222,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.ts b/src/utils/shell.ts index 29709d39..d194970c 100644 --- a/src/utils/shell.ts +++ b/src/utils/shell.ts @@ -2,6 +2,8 @@ import { LinuxDistro } from '@codifycli/schemas'; 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'; const exec = util.promisify(cp.exec); @@ -50,6 +52,82 @@ 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 getLinuxDistro(): Promise { for (const candidate of ['/etc/os-release', '/usr/lib/os-release']) { let osRelease: string; diff --git a/test/utils/plan-pretty-printer.test.ts b/test/utils/plan-pretty-printer.test.ts index 6047aa4d..97dcd538 100644 --- a/test/utils/plan-pretty-printer.test.ts +++ b/test/utils/plan-pretty-printer.test.ts @@ -48,6 +48,79 @@ 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('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',