diff --git a/package.json b/package.json index 81bd00f..a156b78 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,7 @@ }, "repository": "codifycli/codify", "scripts": { + "postinstall": "[ -f node_modules/oclif/lib/tarballs/bin.js ] && tsx scripts/patch-oclif.ts || true", "build": "shx rm -rf dist && tsc -b", "build:release": "npm run pkg && ./scripts/notarize.sh", "lint": "tsc", @@ -144,7 +145,7 @@ "deploy": "npm run pkg && npm run notarize && npm run upload", "prepublishOnly": "npm run build" }, - "version": "1.1.0-beta6", + "version": "1.1.0-beta8", "bugs": "https://github.com/codifycli/codify/issues", "keywords": [ "oclif", diff --git a/scripts/patch-oclif.ts b/scripts/patch-oclif.ts new file mode 100644 index 0000000..822d233 --- /dev/null +++ b/scripts/patch-oclif.ts @@ -0,0 +1,132 @@ +// Patches node_modules/oclif/lib/tarballs/bin.js to inject bash logic into the shell script +// that oclif generates during `oclif pack tarballs`. This runs via the `postinstall` npm script +// so it re-applies automatically after any `npm install` that updates oclif. +// +// Why: Node.js takes 500ms–1s to start. By handling simple cases in the shell script we can +// give instant feedback before Node launches. +// +// What the injected bash does (inside the else block, before the "$NODE ... $DIR/run" line): +// - codify --help / -h → cats dist/static/help.txt and exits (no Node startup) +// - codify --help / -h → cats dist/static/-help.txt and exits +// - codify --version / -v → cats dist/static/version.txt and exits +// - codify apply/destroy/plan → prints "Running Codify ..." immediately +// (suppressed when --output json or -o json is passed) +// - everything else → falls through to normal Node.js launch +// +// Static files (dist/static/*.txt) are generated in scripts/pkg.ts after the esbuild step. +// Missing static files are guarded by [ -f ] so all cases fall back to Node gracefully. +// +// Note: console.log('Running Codify apply/destroy...') was removed from src/commands/apply.ts +// and src/commands/destroy.ts to prevent double-printing (shell prints first, Node would repeat it). +// +// Also patches node_modules/oclif/lib/commands/pack/macos.js to add +// `sudo rm -rf ~/.local/share/codify` to the macOS installer's preinstall script. +// This fixes an oclif bug where the auto-updater cache (~/.local/share/codify) isn't cleared +// on fresh installs, causing the old cached version to be used. The patch must happen before +// `oclif pack macos` runs — modifying the .pkg after the fact breaks notarization. +// +// If oclif upgrades and changes either file's structure, this script exits with code 1 so the +// breakage is immediately visible. +import { existsSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BIN_JS = path.join(__dirname, '../node_modules/oclif/lib/tarballs/bin.js'); +const MACOS_JS = path.join(__dirname, '../node_modules/oclif/lib/commands/pack/macos.js'); + +if (!existsSync(BIN_JS)) { + console.log('oclif bin.js not found (likely production install). Skipping.'); + process.exit(0); +} + +let content = await fs.readFile(BIN_JS, 'utf8'); + +if (content.includes('CODIFY_PATCH_START')) { + console.log('Removing existing patch to reapply...'); + content = content.replace(/ # CODIFY_PATCH_START[\s\S]*?# CODIFY_PATCH_END[^\n]*\n/, ''); +} + +const SEARCH = ' if [ "\\$DEBUG" == "*" ]; then\n echoerr'; +const idx = content.lastIndexOf(SEARCH); +if (idx === -1) { + console.error('ERROR: Could not find insertion point in oclif bin.js. The oclif version may have changed.'); + process.exit(1); +} + +// Patch uses \\$ so that it survives the JS string — in the generated shell script each \\$ becomes \$ +// which bash then interprets as a literal $ (not a template substitution in the JS template literal). +// Bash default-value syntax ${1:-} is avoided since ${...} would be evaluated as a JS template expression. +const PATCH = ` # CODIFY_PATCH_START — do not remove this marker + _first_arg="" + if [ "\\$#" -gt 0 ]; then _first_arg="\\$1"; fi + _second_arg="" + if [ "\\$#" -gt 1 ]; then _second_arg="\\$2"; fi + if [ "\\$_first_arg" = "--help" ] || [ "\\$_first_arg" = "-h" ]; then + _help_file="\\$DIR/../dist/static/help.txt" + if [ -f "\\$_help_file" ]; then cat "\\$_help_file"; exit 0; fi + fi + if [ "\\$_second_arg" = "--help" ] || [ "\\$_second_arg" = "-h" ]; then + _cmd_help_file="\\$DIR/../dist/static/\\$_first_arg-help.txt" + if [ -f "\\$_cmd_help_file" ]; then cat "\\$_cmd_help_file"; exit 0; fi + fi + if [ "\\$_first_arg" = "--version" ] || [ "\\$_first_arg" = "-v" ] || [ "\\$_first_arg" = "version" ]; then + _version_file="\\$DIR/../dist/static/version.txt" + if [ -f "\\$_version_file" ]; then cat "\\$_version_file"; exit 0; fi + fi + _cmd="\\$_first_arg" + if [ "\\$_cmd" = "apply" ] || [ "\\$_cmd" = "destroy" ] || [ "\\$_cmd" = "plan" ]; then + _json_output=0 + _prev="" + for _a in "\\$@"; do + if [ "\\$_a" = "--output=json" ] || [ "\\$_a" = "-o=json" ]; then _json_output=1; break; fi + if [ "\\$_prev" = "--output" ] || [ "\\$_prev" = "-o" ]; then + if [ "\\$_a" = "json" ]; then _json_output=1; break; fi + fi + _prev="\\$_a" + done + if [ "\\$_json_output" -eq 0 ]; then echo "Running Codify \\$_cmd..."; fi + fi + # CODIFY_PATCH_END — do not remove this marker +`; + +const patched = content.slice(0, idx) + PATCH + content.slice(idx); + +// Use exec to replace the shell process with Node rather than spawning a child. +// This avoids an extra process in memory and ensures signals go directly to Node. +const NODE_LAUNCH = ' "\\$NODE" '; +const NODE_LAUNCH_EXEC = ' exec "\\$NODE" '; +let withExec = patched; +if (patched.includes(NODE_LAUNCH) && !patched.includes(NODE_LAUNCH_EXEC)) { + withExec = patched.replace(NODE_LAUNCH, NODE_LAUNCH_EXEC); +} else if (!patched.includes(NODE_LAUNCH_EXEC)) { + console.error('ERROR: Could not find Node launch line to add exec. The oclif version may have changed.'); + process.exit(1); +} + +await fs.writeFile(BIN_JS, withExec, 'utf8'); +console.log('Successfully patched oclif bin.js'); + +// Patch macos.js preinstall script to also clear the auto-updater cache directory. +// Oclif's auto-updater stores binaries in ~/.local/share/codify and the macOS installer +// doesn't clean this up, so fresh installs still run the old cached version. +// We must patch the template before `oclif pack macos` runs — modifying the .pkg after +// the fact breaks notarization since the binary has been tampered with. +const SEARCH_PREINSTALL = 'sudo rm -rf /usr/local/bin/${config.bin}\n${additionalCLI'; +const PATCH_PREINSTALL = 'sudo rm -rf /usr/local/bin/${config.bin}\nsudo rm -rf ~/.local/share/${config.dirname}\n${additionalCLI'; + +if (!existsSync(MACOS_JS)) { + console.log('oclif macos.js not found. Skipping preinstall patch.'); +} else { + const macosContent = await fs.readFile(MACOS_JS, 'utf8'); + if (macosContent.includes(PATCH_PREINSTALL)) { + console.log('oclif macos.js preinstall already patched. Skipping.'); + } else if (!macosContent.includes(SEARCH_PREINSTALL)) { + console.error('ERROR: Could not find preinstall insertion point in oclif macos.js. The oclif version may have changed.'); + process.exit(1); + } else { + await fs.writeFile(MACOS_JS, macosContent.replace(SEARCH_PREINSTALL, PATCH_PREINSTALL), 'utf8'); + console.log('Successfully patched oclif macos.js preinstall script'); + } +} diff --git a/scripts/pkg.ts b/scripts/pkg.ts index 2a30868..543d909 100644 --- a/scripts/pkg.ts +++ b/scripts/pkg.ts @@ -30,12 +30,34 @@ await Promise.all([ console.log(chalk.magenta('Esbuild src')) execSync('tsx esbuild.ts', { shell: 'zsh' }) +console.log(chalk.magenta('Generating static help/version files')) +await fs.mkdir('./.build/dist/static', { recursive: true }); +const helpOutput = execSync('./bin/dev.js --help', { + shell: 'zsh', + env: { ...process.env, FORCE_COLOR: '1' }, +}).toString(); +const versionOutput = execSync('./bin/dev.js --version', { shell: 'zsh' }).toString().trim(); +await fs.writeFile('./.build/dist/static/help.txt', helpOutput, 'utf8'); +await fs.writeFile('./.build/dist/static/version.txt', versionOutput + '\n', 'utf8'); + +const commandFiles = await fs.readdir('./src/commands'); +const commands = commandFiles + .filter(f => f.endsWith('.ts') && !f.startsWith('index')) + .map(f => f.replace(/\.ts$/, '')); +for (const cmd of commands) { + const cmdHelp = execSync(`./bin/dev.js ${cmd} --help`, { + shell: 'zsh', + env: { ...process.env, FORCE_COLOR: '1' }, + }).toString(); + await fs.writeFile(`./.build/dist/static/${cmd}-help.txt`, cmdHelp, 'utf8'); +} +console.log(chalk.magenta(`Generated help files for: ${commands.join(', ')}`)) + console.log(chalk.magenta('Install production dependencies')) execSync('npm install --production', { cwd: './.build', shell: 'zsh' }) console.log(chalk.magenta('Running oclif pkg macos')) execSync('oclif pack macos -r .', { cwd: './.build', shell: 'zsh' }); -await patchMacOsInstallers() console.log(chalk.magenta('Running oclif pkg tarballs')) execSync('oclif pack tarballs -r . -t darwin-arm64,darwin-x64,linux-x64,linux-arm64', { cwd: './.build', shell: 'zsh' }) @@ -51,25 +73,3 @@ async function ignoreError(fn: () => Promise | any): Promise { } catch (e) { } } - -// Oclif has a bug where the installer doesn't clear out the auto-updater location. This causes older versions -// to be re-used even with a clean install -// Comment this out because it does not work with MacOS notary tool. It fails verification -async function patchMacOsInstallers() { - // console.log(chalk.magenta('Patching MacOS installers with bug fix')) - // - // const pkgFolder = './.build/dist/macos'; - // const files = await fs.readdir(pkgFolder) - // const pkgFiles = files.filter((name) => name.endsWith('.pkg')) - // - // for (const pkgFile of pkgFiles) { - // const pkgPath = path.join(pkgFolder, pkgFile); - // const tmpPath = path.join(pkgFolder, 'tmp'); - // - // execSync(`pkgutil --expand ${pkgPath} ${tmpPath}`) - // await fs.appendFile(path.join(tmpPath, 'Scripts', 'preinstall'), '\nsudo rm -rf ~/.local/share/codify', 'utf8'); - // execSync(`pkgutil --flatten ${tmpPath} ${pkgPath} `) - // execSync(`rm -rf ${tmpPath}`); - // console.log(chalk.magenta(`Done patching installer ${pkgFile}`)) - // } -} diff --git a/src/commands/apply.ts b/src/commands/apply.ts index 7ef83d1..1241476 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -50,11 +50,6 @@ For more information, visit: https://codifycli.com/docs/commands/apply public async run(): Promise { const { flags, args } = await this.parse(Apply) - - if (flags.output !== 'json') { - console.log('Running Codify apply...') - } - if (flags.path && args.pathArgs) { throw new Error('Cannot specify both --path and path argument'); } diff --git a/src/commands/destroy.ts b/src/commands/destroy.ts index 85609aa..36961f9 100644 --- a/src/commands/destroy.ts +++ b/src/commands/destroy.ts @@ -47,10 +47,6 @@ For more information, visit: https://codifycli.com/docs/commands/destory` public async run(): Promise { const { flags, raw } = await this.parse(Destroy) - if (flags.output !== 'json') { - console.log('Running Codify destroy...') - } - const args = raw .filter((r) => r.type === 'arg') .map((r) => r.input); diff --git a/src/connect/http-routes/create-command.ts b/src/connect/http-routes/create-command.ts index af8e5d4..f5e9a4b 100644 --- a/src/connect/http-routes/create-command.ts +++ b/src/connect/http-routes/create-command.ts @@ -12,6 +12,7 @@ export enum ConnectCommand { PLAN = 'plan', IMPORT = 'import', REFRESH = 'refresh', + DESTROY = 'destroy', INIT = 'init', TEST = 'test', } diff --git a/src/connect/http-routes/handlers/destroy-handler.ts b/src/connect/http-routes/handlers/destroy-handler.ts new file mode 100644 index 0000000..3d3eadc --- /dev/null +++ b/src/connect/http-routes/handlers/destroy-handler.ts @@ -0,0 +1,53 @@ +import { spawn } from '@homebridge/node-pty-prebuilt-multiarch'; +import { ConfigFileSchema } from '@codifycli/schemas'; +import * as fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { WebSocket } from 'ws'; + +import { ConnectOrchestrator } from '../../../orchestrators/connect.js'; +import { ajv } from '../../../utils/ajv.js'; +import { ShellUtils } from '../../../utils/shell.js'; +import { Session } from '../../socket-server.js'; +import { ConnectCommand, createCommandHandler } from '../create-command.js'; + +const validator = ajv.compile(ConfigFileSchema); + +export function destroyHandler() { + const spawnCommand = async (body: Record, ws: WebSocket, session: Session) => { + const codifyConfig = body.config; + if (!codifyConfig) { + throw new Error('Unable to parse codify config'); + } + + if (!validator(codifyConfig)) { + throw new Error('Invalid codify config'); + } + + const tmpDir = await fs.mkdtemp(os.tmpdir() + '/'); + const filePath = path.join(tmpDir, 'codify.jsonc'); + await fs.writeFile(filePath, JSON.stringify(codifyConfig, null, 2)); + + session.additionalData.filePath = filePath; + + return spawn(ShellUtils.getDefaultShell(), ['-c', `${ConnectOrchestrator.nodeBinary} ${ConnectOrchestrator.rootCommand} destroy -p ${filePath}`], { + name: 'xterm-color', + cols: 80, + rows: 30, + cwd: process.env.HOME, + env: process.env + }); + } + + const onExit = async (exitCode: number, ws: WebSocket, session: Session) => { + if (session.additionalData.filePath) { + await fs.rm(session.additionalData.filePath as string, { recursive: true, force: true }); + } + } + + return createCommandHandler({ + name: ConnectCommand.DESTROY, + spawnCommand, + onExit + }); +} diff --git a/src/connect/http-routes/router.ts b/src/connect/http-routes/router.ts index 6de1264..227b953 100644 --- a/src/connect/http-routes/router.ts +++ b/src/connect/http-routes/router.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import { applyHandler } from './handlers/apply-handler.js'; +import { destroyHandler } from './handlers/destroy-handler.js'; import { importHandler } from './handlers/import-handler.js'; import defaultHandler from './handlers/index.js'; import { initHandler } from './handlers/init-handler.js'; @@ -14,6 +15,7 @@ const router = Router(); router.use('/', defaultHandler); router.use('/apply', applyHandler()); +router.use('/destroy', destroyHandler()); router.use('/plan', planHandler()) router.use('/import', importHandler()); router.use('/refresh', refreshHandler());