Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
132 changes: 132 additions & 0 deletions scripts/patch-oclif.ts
Original file line number Diff line number Diff line change
@@ -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 <cmd> --help / -h → cats dist/static/<cmd>-help.txt and exits
// - codify --version / -v → cats dist/static/version.txt and exits
// - codify apply/destroy/plan → prints "Running Codify <cmd>..." 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');
}
}
46 changes: 23 additions & 23 deletions scripts/pkg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand All @@ -51,25 +73,3 @@ async function ignoreError(fn: () => Promise<any> | any): Promise<void> {
} 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}`))
// }
}
5 changes: 0 additions & 5 deletions src/commands/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,6 @@ For more information, visit: https://codifycli.com/docs/commands/apply
public async run(): Promise<void> {
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');
}
Expand Down
4 changes: 0 additions & 4 deletions src/commands/destroy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,6 @@ For more information, visit: https://codifycli.com/docs/commands/destory`
public async run(): Promise<void> {
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);
Expand Down
1 change: 1 addition & 0 deletions src/connect/http-routes/create-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export enum ConnectCommand {
PLAN = 'plan',
IMPORT = 'import',
REFRESH = 'refresh',
DESTROY = 'destroy',
INIT = 'init',
TEST = 'test',
}
Expand Down
53 changes: 53 additions & 0 deletions src/connect/http-routes/handlers/destroy-handler.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, 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
});
}
2 changes: 2 additions & 0 deletions src/connect/http-routes/router.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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());
Expand Down
Loading