diff --git a/package-lock.json b/package-lock.json index 2bda35f..29dfcf3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@codifycli/plugin-core", - "version": "1.0.0-beta6", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@codifycli/plugin-core", - "version": "1.0.0-beta6", + "version": "1.2.0", "license": "ISC", "dependencies": { - "@codifycli/schemas": "1.0.0", + "@codifycli/schemas": "^1.2.0", "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", "ajv": "^8.18.0", "ajv-formats": "^2.1.1", @@ -17,7 +17,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": { @@ -94,9 +94,9 @@ } }, "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.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" @@ -8216,16 +8216,16 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { diff --git a/package.json b/package.json index 98bdb42..d7a6477 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codifycli/plugin-core", - "version": "1.0.1", + "version": "1.2.0", "description": "TypeScript library for building Codify plugins to manage system resources (applications, CLI tools, settings) through infrastructure-as-code", "main": "dist/index.js", "typings": "dist/index.d.ts", @@ -35,7 +35,7 @@ }, "license": "ISC", "dependencies": { - "@codifycli/schemas": "1.0.0", + "@codifycli/schemas": "^1.2.0", "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", "ajv": "^8.18.0", "ajv-formats": "^2.1.1", @@ -43,7 +43,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" }, "devDependencies": { diff --git a/src/common/apply-notes.ts b/src/common/apply-notes.ts new file mode 100644 index 0000000..6a98531 --- /dev/null +++ b/src/common/apply-notes.ts @@ -0,0 +1,12 @@ +import path from 'node:path'; + +import { Utils } from '../utils/index.js'; + +export const ApplyNotes = { + RESTART_REQUIRED: 'A system restart is required for changes to take effect.', + NEW_SHELL_REQUIRED: 'Open a new terminal session for the changes to be reflected.', + sourceShellRc(): string { + const rc = path.basename(Utils.getPrimaryShellRc()); + return `Source '~/${rc}' for the changes to be reflected.`; + }, +} as const; diff --git a/src/common/errors.ts b/src/common/errors.ts index eefe577..57b4ba0 100644 --- a/src/common/errors.ts +++ b/src/common/errors.ts @@ -4,13 +4,15 @@ export class ApplyValidationError extends Error { resourceType: string; resourceName?: string; plan: Plan; + logs: string[]; - constructor(plan: Plan) { + constructor(plan: Plan, logs: string[] = []) { super(`Failed to apply changes to resource: "${plan.resourceId}". Additional changes are needed to complete apply.\nChanges remaining:\n${ApplyValidationError.prettyPrintPlan(plan)}`); this.resourceType = plan.coreParameters.type; this.resourceName = plan.coreParameters.name; this.plan = plan; + this.logs = logs; } private static prettyPrintPlan(plan: Plan): string { diff --git a/src/index.ts b/src/index.ts index 1584cb9..b4155ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { MessageHandler } from './messages/handlers.js'; import { Plugin } from './plugin/plugin.js'; +export * from './common/apply-notes.js' export * from './errors.js' export * from './messages/sender.js' export * from './plan/change-set.js' diff --git a/src/messages/handlers.ts b/src/messages/handlers.ts index 53b591a..1e93ee1 100644 --- a/src/messages/handlers.ts +++ b/src/messages/handlers.ts @@ -16,6 +16,7 @@ import { MessageStatus, PlanRequestDataSchema, PlanResponseDataSchema, + PluginErrorData, ResourceSchema, SetVerbosityRequestDataSchema, ValidateRequestDataSchema, @@ -24,6 +25,7 @@ import { import { Ajv, SchemaObject, ValidateFunction } from 'ajv'; import addFormats from 'ajv-formats'; +import { ApplyValidationError } from '../common/errors.js'; import { SudoError } from '../errors.js'; import { Plugin } from '../plugin/plugin.js'; @@ -157,25 +159,31 @@ export class MessageHandler { // @ts-expect-error TS2239 const cmd = message.cmd + '_Response'; + // @ts-expect-error TS2239 + const requestId = message.requestId || undefined; + + let errorPayload: PluginErrorData; if (e instanceof SudoError) { - return process.send?.({ - cmd, - // @ts-expect-error TS2239 - requestId: message.requestId || undefined, - data: `Plugin: '${this.plugin.name}'. Forbidden usage of sudo for command '${e.command}'. Please contact the plugin developer to fix this.`, - status: MessageStatus.ERROR, - }) + errorPayload = { + errorType: 'sudo_error', + message: `Plugin: '${this.plugin.name}'. Forbidden usage of sudo for command '${e.command}'. Please contact the plugin developer to fix this.`, + data: { command: e.command, pluginName: this.plugin.name }, + }; + } else if (e instanceof ApplyValidationError) { + errorPayload = { + errorType: 'apply_validation', + message: e.message, + data: { plan: e.plan.toResponse(), logs: e.logs }, + }; + } else { + const isDebug = process.env.DEBUG?.includes('*') ?? false; + errorPayload = { + errorType: 'unknown', + message: isDebug ? (e.stack ?? e.message) : e.message, + }; } - const isDebug = process.env.DEBUG?.includes('*') ?? false; - - process.send?.({ - cmd, - // @ts-expect-error TS2239 - requestId: message.requestId || undefined, - data: isDebug ? e.stack : e.message, - status: MessageStatus.ERROR, - }) + process.send?.({ cmd, requestId, data: errorPayload, status: MessageStatus.ERROR }); } } diff --git a/src/messages/sender.ts b/src/messages/sender.ts index 8e4d288..0a8ab03 100644 --- a/src/messages/sender.ts +++ b/src/messages/sender.ts @@ -1,4 +1,10 @@ -import { IpcMessageV2, IpcMessageV2Schema, MessageCmd, PressKeyToContinueRequestData } from '@codifycli/schemas'; +import { + ApplyNoteRequestData, + IpcMessageV2, + IpcMessageV2Schema, + MessageCmd, + PressKeyToContinueRequestData +} from '@codifycli/schemas'; import { Ajv } from 'ajv'; import { nanoid } from 'nanoid'; @@ -21,6 +27,20 @@ class CodifyCliSenderImpl { }) } + async sendApplyNote(message: string, resourceType?: string): Promise { + if (!process.send || !process.connected) { + return; + } + + await this.sendAndWaitForResponse({ + cmd: MessageCmd.APPLY_NOTE_REQUEST, + data: { + message, + ...(resourceType && { resourceType }), + } + }); + } + async getCodifyCliCredentials(): Promise { const data = await this.sendAndWaitForResponse({ cmd: MessageCmd.CODIFY_CREDENTIALS_REQUEST, diff --git a/src/plan/change-set.test.ts b/src/plan/change-set.test.ts index a9ac405..5e386ca 100644 --- a/src/plan/change-set.test.ts +++ b/src/plan/change-set.test.ts @@ -107,6 +107,7 @@ describe('Change set tests', () => { const parameterSettings = new ParsedResourceSettings({ id: 'type', + operatingSystems: [], parameterSettings: { propA: { type: 'array' } } @@ -129,6 +130,7 @@ describe('Change set tests', () => { const parameterSettings = new ParsedResourceSettings({ id: 'type', + operatingSystems: [], parameterSettings: { propA: { type: 'array' } } @@ -152,6 +154,7 @@ describe('Change set tests', () => { const parameterSettings = new ParsedResourceSettings({ id: 'type', + operatingSystems: [], parameterSettings: { propA: { canModify: true } } @@ -176,6 +179,7 @@ describe('Change set tests', () => { const parameterSettings = new ParsedResourceSettings({ id: 'type', + operatingSystems: [], parameterSettings: { propA: { canModify: true }, propB: { canModify: true } @@ -196,6 +200,7 @@ describe('Change set tests', () => { const parameterSettings = new ParsedResourceSettings({ id: 'type', + operatingSystems: [], parameterSettings: { propA: { type: 'array' } }, @@ -213,6 +218,7 @@ describe('Change set tests', () => { const parameterSettings = new ParsedResourceSettings({ id: 'type', + operatingSystems: [], parameterSettings: { propA: { type: 'array' } }, @@ -229,6 +235,7 @@ describe('Change set tests', () => { const parameterSettings = new ParsedResourceSettings({ id: 'type', + operatingSystems: [], parameterSettings: { propA: { type: 'array' } }, @@ -245,6 +252,7 @@ describe('Change set tests', () => { const parameterSettings = new ParsedResourceSettings({ id: 'type', + operatingSystems: [], parameterSettings: { propA: { type: 'array', @@ -259,12 +267,50 @@ describe('Change set tests', () => { expect(result.parameterChanges[0].operation).to.eq(ParameterOperation.MODIFY); }) + it('excludes setting parameters from destroy change set', () => { + const settings = { + id: 'type', + operatingSystems: [], + parameterSettings: { + propA: {}, + propB: { setting: true }, + }, + }; + + const cs = ChangeSet.destroy({ propA: 'val', propB: true }, settings as any); + expect(cs.parameterChanges.length).to.eq(1); + expect(cs.parameterChanges[0].name).to.eq('propA'); + expect(cs.parameterChanges[0].operation).to.eq(ParameterOperation.REMOVE); + expect(cs.operation).to.eq(ResourceOperation.DESTROY); + }) + + it('excludes multiple setting parameters from destroy change set', () => { + const settings = { + id: 'type', + operatingSystems: [], + parameterSettings: { + skipAlreadyInstalledCasks: { type: 'boolean', default: true, setting: true }, + onlyPlanUserInstalled: { type: 'boolean', default: true, setting: true }, + directory: { type: 'directory' }, + }, + }; + + const cs = ChangeSet.destroy( + { skipAlreadyInstalledCasks: true, onlyPlanUserInstalled: true, directory: '/opt/homebrew' }, + settings as any + ); + expect(cs.parameterChanges.length).to.eq(1); + expect(cs.parameterChanges[0].name).to.eq('directory'); + expect(cs.operation).to.eq(ResourceOperation.DESTROY); + }) + it('correctly determines array equality 5', () => { const arrA = [{ key1: 'b' }, { key1: 'a' }, { key1: 'a' }]; const arrB = [{ key1: 'a' }, { key1: 'a' }, { key1: 'b' }]; const parameterSettings = new ParsedResourceSettings({ id: 'type', + operatingSystems: [], parameterSettings: { propA: { type: 'array', diff --git a/src/plan/change-set.ts b/src/plan/change-set.ts index 2f90298..aa7b979 100644 --- a/src/plan/change-set.ts +++ b/src/plan/change-set.ts @@ -94,6 +94,7 @@ export class ChangeSet { static destroy(current: Partial, settings?: ResourceSettings): ChangeSet { const parameterChanges = Object.entries(current) + .filter(([k]) => !settings?.parameterSettings?.[k]?.setting) .map(([k, v]) => ({ name: k, operation: ParameterOperation.REMOVE, diff --git a/src/plugin/plugin.ts b/src/plugin/plugin.ts index 9ebcc48..6b55def 100644 --- a/src/plugin/plugin.ts +++ b/src/plugin/plugin.ts @@ -11,7 +11,8 @@ import { PlanRequestData, PlanResponseData, ResourceConfig, - ResourceJson, SetVerbosityRequestData, + ResourceJson, + SetVerbosityRequestData, ValidateRequestData, ValidateResponseData } from '@codifycli/schemas'; @@ -30,15 +31,22 @@ import { VerbosityLevel } from '../utils/verbosity-level.js'; export class Plugin { planStorage: Map>; planPty = new BackgroundPty(); + minSupportedCliVersion: string | undefined; constructor( public name: string, - public resourceControllers: Map> + public resourceControllers: Map>, + options?: { minSupportedCliVersion?: string } ) { this.planStorage = new Map(); + this.minSupportedCliVersion = options?.minSupportedCliVersion; } - static create(name: string, resources: Resource[]) { + static create( + name: string, + resources: Resource[], + options?: { minSupportedCliVersion?: string } + ) { const controllers = resources .map((resource) => new ResourceController(resource)) @@ -46,7 +54,7 @@ export class Plugin { controllers.map((r) => [r.typeId, r] as const) ); - return new Plugin(name, controllersMap); + return new Plugin(name, controllersMap, options); } async initialize(data: InitializeRequestData): Promise { @@ -59,6 +67,7 @@ export class Plugin { } return { + minSupportedCliVersion: this.minSupportedCliVersion, resourceDefinitions: [...this.resourceControllers.values()] .map((r) => { const sensitiveParameters = Object.entries(r.settings.parameterSettings ?? {}) @@ -127,7 +136,9 @@ export class Plugin { operatingSystems: resource.settings.operatingSystems, linuxDistros: resource.settings.linuxDistros, sensitiveParameters, - allowMultiple + allowMultiple, + defaultConfig: resource.settings.defaultConfig, + exampleConfigs: resource.settings.exampleConfigs, } } @@ -171,9 +182,11 @@ export class Plugin { throw new Error(`Resource type not found: ${core.type}`); } - const validation = await this.resourceControllers - .get(core.type)! - .validate(core, parameters); + const validation = await ptyLocalStorage.run(this.planPty, () => + this.resourceControllers + .get(core.type)! + .validate(core, parameters) + ); validationResults.push(validation); } @@ -237,7 +250,11 @@ export class Plugin { throw new Error('Malformed plan with resource that cannot be found'); } - await ptyLocalStorage.run(new SequentialPty(), async () => resource.apply(plan)) + let applyLogs: string[] = []; + await ptyLocalStorage.run(new SequentialPty(), async () => { + await resource.apply(plan); + applyLogs = getPty().getLogs(); + }); // Validate using desired/desired. If the apply was successful, no changes should be reported back. // Default back desired back to current if it is not defined (for destroys only) @@ -255,7 +272,7 @@ export class Plugin { }) if (validationPlan.requiresChanges()) { - throw new ApplyValidationError(plan); + throw new ApplyValidationError(validationPlan, applyLogs); } } diff --git a/src/pty/background-pty.ts b/src/pty/background-pty.ts index ce96179..82b8917 100644 --- a/src/pty/background-pty.ts +++ b/src/pty/background-pty.ts @@ -148,6 +148,10 @@ export class BackgroundPty implements IPty { }) } + getLogs(): string[] { + return []; + } + private getDefaultShell(): string { return process.env.SHELL!; } diff --git a/src/pty/index.ts b/src/pty/index.ts index a23a78b..ea5f2be 100644 --- a/src/pty/index.ts +++ b/src/pty/index.ts @@ -34,6 +34,7 @@ export interface SpawnOptions { env?: Record; interactive?: boolean; requiresRoot?: boolean; + requiresSudoAskpass?: boolean; stdin?: boolean; disableWrapping?: boolean; } @@ -43,8 +44,11 @@ export class SpawnError extends Error { cmd: string; exitCode: number; - constructor(cmd: string, exitCode: number, data: string) { - super(`Spawn Error: on command "${cmd}" with exit code: ${exitCode}\nOutput:\n${data}`); + constructor(cmd: string, exitCode: number, data: string, logs?: string[]) { + const logSection = logs?.length + ? `\nLast logs:\n${logs.join('\n')}` + : ''; + super(`Spawn Error: on command "${cmd}" with exit code: ${exitCode}\nOutput:\n${data}${logSection}`); this.data = data; this.cmd = cmd; @@ -59,6 +63,8 @@ export interface IPty { spawnSafe(cmd: string | string[], options?: SpawnOptions): Promise kill(): Promise<{ exitCode: number, signal?: number | undefined }> + + getLogs(): string[] } export function getPty(): IPty { diff --git a/src/pty/seqeuntial-pty.ts b/src/pty/seqeuntial-pty.ts index ab0e7c7..b12e69a 100644 --- a/src/pty/seqeuntial-pty.ts +++ b/src/pty/seqeuntial-pty.ts @@ -28,11 +28,26 @@ const validateSudoRequestResponse = ajv.compile(CommandRequestResponseDataSchema * without a tty (or even a stdin) attached so interactive commands will not work. */ export class SequentialPty implements IPty { + private logBuffer: string[] = []; + private static readonly MAX_LOG_LINES = 30; + + getLogs(): string[] { + return [...this.logBuffer]; + } + + private appendLog(data: string): void { + const lines = data.split('\n'); + this.logBuffer.push(...lines); + if (this.logBuffer.length > SequentialPty.MAX_LOG_LINES) { + this.logBuffer = this.logBuffer.slice(-SequentialPty.MAX_LOG_LINES); + } + } + async spawn(cmd: string | string[], options?: SpawnOptions): Promise { const spawnResult = await this.spawnSafe(cmd, options); if (spawnResult.status !== 'success') { - throw new SpawnError(Array.isArray(cmd) ? cmd.join('\n') : cmd, spawnResult.exitCode, spawnResult.data); + throw new SpawnError(Array.isArray(cmd) ? cmd.join('\n') : cmd, spawnResult.exitCode, spawnResult.data, this.logBuffer); } return spawnResult; @@ -46,11 +61,13 @@ export class SequentialPty implements IPty { } // If sudo is required, we must delegate to the main codify process. - if (options?.stdin || options?.requiresRoot) { + if (options?.stdin || options?.requiresRoot || options?.requiresSudoAskpass) { return this.externalSpawn(cmd, options); } - console.log(`Running command: ${Array.isArray(cmd) ? cmd.join('\\\n') : cmd}` + (options?.cwd ? `(${options?.cwd})` : '')) + const cmdLine = `Running command: ${Array.isArray(cmd) ? cmd.join('\\\n') : cmd}` + (options?.cwd ? `(${options?.cwd})` : ''); + console.log(cmdLine); + this.appendLog(cmdLine); return new Promise((resolve) => { const output: string[] = []; @@ -87,6 +104,7 @@ export class SequentialPty implements IPty { } output.push(data.toString()); + this.appendLog(data.toString()); }) const resizeListener = () => { @@ -100,10 +118,12 @@ export class SequentialPty implements IPty { mPty.onExit((result) => { process.stdout.off('resize', resizeListener); + const raw = stripAnsi(output.join('')).replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim(); + resolve({ status: result.exitCode === 0 ? SpawnStatus.SUCCESS : SpawnStatus.ERROR, exitCode: result.exitCode, - data: stripAnsi(output.join('\n').trim()), + data: raw, }) }) }) diff --git a/src/resource/parsed-resource-settings.ts b/src/resource/parsed-resource-settings.ts index b19f3ab..b82e590 100644 --- a/src/resource/parsed-resource-settings.ts +++ b/src/resource/parsed-resource-settings.ts @@ -6,6 +6,7 @@ import { StatefulParameterController } from '../stateful-parameter/stateful-para import { ArrayParameterSetting, DefaultParameterSetting, + ExampleConfigs, InputTransformation, ParameterSetting, resolveElementEqualsFn, @@ -56,6 +57,9 @@ export class ParsedResourceSettings implements Re isSensitive?: boolean; + defaultConfig!: Partial; + exampleConfigs!: ExampleConfigs; + private settings: ResourceSettings; constructor(settings: ResourceSettings) { @@ -72,7 +76,7 @@ export class ParsedResourceSettings implements Re if (ctx.path.length === 0) { ctx.jsonSchema.title = settings.id; ctx.jsonSchema.description = schema.description ?? settings.description ?? `${settings.id} resource. Can be used to manage ${settings.id}`; - ctx.jsonSchema.$comment = (schema.meta() as Record).$comment; + ctx.jsonSchema.$comment = (schema.meta() as Record | undefined)?.$comment; } } }) as JSONSchemaType diff --git a/src/resource/resource-settings.ts b/src/resource/resource-settings.ts index 6acac52..a497486 100644 --- a/src/resource/resource-settings.ts +++ b/src/resource/resource-settings.ts @@ -15,6 +15,17 @@ import { import { ParsedResourceSettings } from './parsed-resource-settings.js'; import { RefreshContext } from './resource.js'; +export interface ExampleConfig extends Record { + title: string; + configs: Array>; + description?: string; +} + +export interface ExampleConfigs { + example1?: ExampleConfig; + example2?: ExampleConfig; +} + export interface InputTransformation { to: (input: any) => Promise | any; from: (current: any, original: any) => Promise | any; @@ -194,6 +205,19 @@ export interface ResourceSettings { */ refreshMapper?: (input: Partial, context: RefreshContext) => Partial; } + + /** + * Represents the default config that is added to the editor with prefilled properties. For some resources + * + * @type {Partial} + */ + defaultConfig?: Partial + + /** + * A collection of example configs used to give users an idea on how to use this specific resource. These examples + * don't need to be limited to just the current resource. They can include other resources as well + */ + exampleConfigs?: ExampleConfigs } /** diff --git a/src/utils/file-utils.ts b/src/utils/file-utils.ts index be45571..dda9e2f 100644 --- a/src/utils/file-utils.ts +++ b/src/utils/file-utils.ts @@ -4,6 +4,8 @@ import path from 'node:path'; import { Readable } from 'node:stream'; import { finished } from 'node:stream/promises'; +import { ApplyNotes } from '../common/apply-notes.js'; +import { CodifyCliSender } from '../messages/sender.js'; import { Utils } from './index.js'; const SPACE_REGEX = /^\s*$/ @@ -26,11 +28,14 @@ export class FileUtils { } static async addToShellRc(line: string): Promise { + await FileUtils.createShellRcIfNotExists(); + const lineToInsert = addLeadingSpacer( addTrailingSpacer(line) ); await fs.appendFile(Utils.getPrimaryShellRc(), lineToInsert) + await CodifyCliSender.sendApplyNote(ApplyNotes.sourceShellRc()); function addLeadingSpacer(line: string): string { return line.startsWith('\n') @@ -46,6 +51,8 @@ export class FileUtils { } static async addAllToShellRc(lines: string[]): Promise { + await FileUtils.createShellRcIfNotExists(); + const formattedLines = '\n' + lines.join('\n') + '\n'; const shellRc = Utils.getPrimaryShellRc(); @@ -53,6 +60,7 @@ export class FileUtils { ${lines.join('\n')}`) await fs.appendFile(shellRc, formattedLines) + await CodifyCliSender.sendApplyNote(ApplyNotes.sourceShellRc()); } /** @@ -62,6 +70,8 @@ ${lines.join('\n')}`) * @param prepend - Whether to prepend the path to the existing PATH variable. */ static async addPathToShellRc(value: string, prepend: boolean): Promise { + await FileUtils.createShellRcIfNotExists(); + if (await Utils.isDirectoryOnPath(value)) { return; } @@ -71,10 +81,11 @@ ${lines.join('\n')}`) if (prepend) { await fs.appendFile(shellRc, `\nexport PATH=$PATH:${value};`, { encoding: 'utf8' }); - return; + } else { + await fs.appendFile(shellRc, `\nexport PATH=${value}:$PATH;`, { encoding: 'utf8' }); } - await fs.appendFile(shellRc, `\nexport PATH=${value}:$PATH;`, { encoding: 'utf8' }); + await CodifyCliSender.sendApplyNote(ApplyNotes.sourceShellRc()); } static async removeFromFile(filePath: string, search: string): Promise { @@ -228,4 +239,11 @@ ${lines.join('\n')}`) } } } + + static async createShellRcIfNotExists(): Promise { + const shellRc = Utils.getPrimaryShellRc(); + if (!await FileUtils.fileExists(shellRc)) { + await fs.writeFile(shellRc, '', 'utf8'); + } + } } diff --git a/src/utils/functions.ts b/src/utils/functions.ts index 888f0c0..16011b0 100644 --- a/src/utils/functions.ts +++ b/src/utils/functions.ts @@ -10,10 +10,11 @@ export function splitUserConfig( ...(config.name ? { name: config.name } : {}), ...(config.dependsOn ? { dependsOn: config.dependsOn } : {}), ...(config.os ? { os: config.os } : {}), + ...(config.distro ? { distro: config.distro } : {}) }; // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { type, name, dependsOn, os, ...parameters } = config; + const { type, name, dependsOn, os, distro, ...parameters } = config; return { parameters: parameters as T, diff --git a/src/utils/index.ts b/src/utils/index.ts index 13dcd76..844369c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -4,7 +4,7 @@ import * as fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { SpawnStatus, getPty } from '../pty/index.js'; +import { getPty, SpawnStatus } from '../pty/index.js'; export function isDebug(): boolean { return process.env.DEBUG != null && process.env.DEBUG.includes('codify'); // TODO: replace with debug library @@ -73,7 +73,7 @@ export const Utils = { }, getShell(): Shell | undefined { - const shell = process.env.SHELL || ''; + const shell = process.env.SHELL || os.userInfo().shell || ''; if (shell.endsWith('bash')) { return Shell.BASH @@ -108,7 +108,7 @@ export const Utils = { }, getShellRcFiles(): string[] { - const shell = process.env.SHELL || ''; + const shell = process.env.SHELL || os.userInfo().shell || ''; const homeDir = os.homedir(); if (shell.endsWith('bash')) { @@ -209,9 +209,9 @@ Brew can be installed using Codify: const isAptInstalled = await $.spawnSafe('which apt'); if (isAptInstalled.status === SpawnStatus.SUCCESS) { await $.spawn('apt-get update', { requiresRoot: true }); - const { status, data } = await $.spawnSafe(`apt-get -y install ${packageName}`, { + const { status, data } = await $.spawnSafe(`apt-get -y -qq install -o Dpkg::Use-Pty=0 -o Dpkg::Progress-Fancy=0 ${packageName}`, { requiresRoot: true, - env: { DEBIAN_FRONTEND: 'noninteractive', NEEDRESTART_MODE: 'a' } + env: { DEBIAN_FRONTEND: 'noninteractive', NEEDRESTART_MODE: 'a', } }); if (status === SpawnStatus.ERROR && data.includes('E: dpkg was interrupted, you must manually run \'sudo dpkg --configure -a\' to correct the problem.')) { @@ -225,7 +225,24 @@ Brew can be installed using Codify: } if (status === SpawnStatus.ERROR) { - throw new Error(`Failed to install package ${packageName} via apt: ${data}`); + // Attempt to fix broken dependencies then retry + const fixResult = await $.spawnSafe('apt-get install -f -y -o Dpkg::Use-Pty=0 -o Dpkg::Progress-Fancy=0', { + requiresRoot: true, + env: { DEBIAN_FRONTEND: 'noninteractive', NEEDRESTART_MODE: 'a' } + }); + + if (fixResult.status === SpawnStatus.ERROR) { + throw new Error(`Failed to install package ${packageName} via apt: ${data}`); + } + + const retryResult = await $.spawnSafe(`apt-get -y -qq install -o Dpkg::Use-Pty=0 -o Dpkg::Progress-Fancy=0 ${packageName}`, { + requiresRoot: true, + env: { DEBIAN_FRONTEND: 'noninteractive', NEEDRESTART_MODE: 'a' } + }); + + if (retryResult.status === SpawnStatus.ERROR) { + throw new Error(`Failed to install package ${packageName} via apt after fixing dependencies: ${retryResult.data}`); + } } } @@ -265,7 +282,7 @@ Brew can be installed using Codify: if (Utils.isLinux()) { const isAptInstalled = await $.spawnSafe('which apt'); if (isAptInstalled.status === SpawnStatus.SUCCESS) { - const { status } = await $.spawnSafe(`apt-get autoremove -y --purge ${packageName}`, { + const { status } = await $.spawnSafe(`apt-get -qq autoremove -y -o Dpkg::Use-Pty=0 -o Dpkg::Progress-Fancy=0 --purge ${packageName}`, { requiresRoot: true, env: { DEBIAN_FRONTEND: 'noninteractive', NEEDRESTART_MODE: 'a' } }); diff --git a/src/utils/internal-utils.test.ts b/src/utils/internal-utils.test.ts index dc63d8d..1323eb5 100644 --- a/src/utils/internal-utils.test.ts +++ b/src/utils/internal-utils.test.ts @@ -9,6 +9,7 @@ describe('Utils tests', () => { name: 'name', dependsOn: ['a', 'b', 'c'], os: ['linux'], + distro: ['debian-based'], propA: 'propA', propB: 'propB', propC: 'propC',