Skip to content

Commit d266cb7

Browse files
Kevin/improve launcher-and-destroy (#62)
* feat: Patch the launcher script to handle simple tasks so that the NodeJS startup delay is less noticeable. * fix: For unescaped brackets in the patch file * fix: Added static help files for top level commands as well: codify apply, codify plan, codify ... * feat: Patch the NodeJS subshell launch in the bin script as well. Replace with exec * feat: Added MacOS pkg installer patching as well. Removed it from the pkg script. * feat: Added destroy to connect
1 parent baf0b0f commit d266cb7

8 files changed

Lines changed: 213 additions & 33 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
},
128128
"repository": "codifycli/codify",
129129
"scripts": {
130+
"postinstall": "[ -f node_modules/oclif/lib/tarballs/bin.js ] && tsx scripts/patch-oclif.ts || true",
130131
"build": "shx rm -rf dist && tsc -b",
131132
"build:release": "npm run pkg && ./scripts/notarize.sh",
132133
"lint": "tsc",
@@ -144,7 +145,7 @@
144145
"deploy": "npm run pkg && npm run notarize && npm run upload",
145146
"prepublishOnly": "npm run build"
146147
},
147-
"version": "1.1.0-beta6",
148+
"version": "1.1.0-beta8",
148149
"bugs": "https://github.com/codifycli/codify/issues",
149150
"keywords": [
150151
"oclif",

scripts/patch-oclif.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Patches node_modules/oclif/lib/tarballs/bin.js to inject bash logic into the shell script
2+
// that oclif generates during `oclif pack tarballs`. This runs via the `postinstall` npm script
3+
// so it re-applies automatically after any `npm install` that updates oclif.
4+
//
5+
// Why: Node.js takes 500ms–1s to start. By handling simple cases in the shell script we can
6+
// give instant feedback before Node launches.
7+
//
8+
// What the injected bash does (inside the else block, before the "$NODE ... $DIR/run" line):
9+
// - codify --help / -h → cats dist/static/help.txt and exits (no Node startup)
10+
// - codify <cmd> --help / -h → cats dist/static/<cmd>-help.txt and exits
11+
// - codify --version / -v → cats dist/static/version.txt and exits
12+
// - codify apply/destroy/plan → prints "Running Codify <cmd>..." immediately
13+
// (suppressed when --output json or -o json is passed)
14+
// - everything else → falls through to normal Node.js launch
15+
//
16+
// Static files (dist/static/*.txt) are generated in scripts/pkg.ts after the esbuild step.
17+
// Missing static files are guarded by [ -f ] so all cases fall back to Node gracefully.
18+
//
19+
// Note: console.log('Running Codify apply/destroy...') was removed from src/commands/apply.ts
20+
// and src/commands/destroy.ts to prevent double-printing (shell prints first, Node would repeat it).
21+
//
22+
// Also patches node_modules/oclif/lib/commands/pack/macos.js to add
23+
// `sudo rm -rf ~/.local/share/codify` to the macOS installer's preinstall script.
24+
// This fixes an oclif bug where the auto-updater cache (~/.local/share/codify) isn't cleared
25+
// on fresh installs, causing the old cached version to be used. The patch must happen before
26+
// `oclif pack macos` runs — modifying the .pkg after the fact breaks notarization.
27+
//
28+
// If oclif upgrades and changes either file's structure, this script exits with code 1 so the
29+
// breakage is immediately visible.
30+
import { existsSync } from 'node:fs';
31+
import fs from 'node:fs/promises';
32+
import path from 'node:path';
33+
import { fileURLToPath } from 'node:url';
34+
35+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
36+
const BIN_JS = path.join(__dirname, '../node_modules/oclif/lib/tarballs/bin.js');
37+
const MACOS_JS = path.join(__dirname, '../node_modules/oclif/lib/commands/pack/macos.js');
38+
39+
if (!existsSync(BIN_JS)) {
40+
console.log('oclif bin.js not found (likely production install). Skipping.');
41+
process.exit(0);
42+
}
43+
44+
let content = await fs.readFile(BIN_JS, 'utf8');
45+
46+
if (content.includes('CODIFY_PATCH_START')) {
47+
console.log('Removing existing patch to reapply...');
48+
content = content.replace(/ # CODIFY_PATCH_START[\s\S]*?# CODIFY_PATCH_END[^\n]*\n/, '');
49+
}
50+
51+
const SEARCH = ' if [ "\\$DEBUG" == "*" ]; then\n echoerr';
52+
const idx = content.lastIndexOf(SEARCH);
53+
if (idx === -1) {
54+
console.error('ERROR: Could not find insertion point in oclif bin.js. The oclif version may have changed.');
55+
process.exit(1);
56+
}
57+
58+
// Patch uses \\$ so that it survives the JS string — in the generated shell script each \\$ becomes \$
59+
// which bash then interprets as a literal $ (not a template substitution in the JS template literal).
60+
// Bash default-value syntax ${1:-} is avoided since ${...} would be evaluated as a JS template expression.
61+
const PATCH = ` # CODIFY_PATCH_START — do not remove this marker
62+
_first_arg=""
63+
if [ "\\$#" -gt 0 ]; then _first_arg="\\$1"; fi
64+
_second_arg=""
65+
if [ "\\$#" -gt 1 ]; then _second_arg="\\$2"; fi
66+
if [ "\\$_first_arg" = "--help" ] || [ "\\$_first_arg" = "-h" ]; then
67+
_help_file="\\$DIR/../dist/static/help.txt"
68+
if [ -f "\\$_help_file" ]; then cat "\\$_help_file"; exit 0; fi
69+
fi
70+
if [ "\\$_second_arg" = "--help" ] || [ "\\$_second_arg" = "-h" ]; then
71+
_cmd_help_file="\\$DIR/../dist/static/\\$_first_arg-help.txt"
72+
if [ -f "\\$_cmd_help_file" ]; then cat "\\$_cmd_help_file"; exit 0; fi
73+
fi
74+
if [ "\\$_first_arg" = "--version" ] || [ "\\$_first_arg" = "-v" ] || [ "\\$_first_arg" = "version" ]; then
75+
_version_file="\\$DIR/../dist/static/version.txt"
76+
if [ -f "\\$_version_file" ]; then cat "\\$_version_file"; exit 0; fi
77+
fi
78+
_cmd="\\$_first_arg"
79+
if [ "\\$_cmd" = "apply" ] || [ "\\$_cmd" = "destroy" ] || [ "\\$_cmd" = "plan" ]; then
80+
_json_output=0
81+
_prev=""
82+
for _a in "\\$@"; do
83+
if [ "\\$_a" = "--output=json" ] || [ "\\$_a" = "-o=json" ]; then _json_output=1; break; fi
84+
if [ "\\$_prev" = "--output" ] || [ "\\$_prev" = "-o" ]; then
85+
if [ "\\$_a" = "json" ]; then _json_output=1; break; fi
86+
fi
87+
_prev="\\$_a"
88+
done
89+
if [ "\\$_json_output" -eq 0 ]; then echo "Running Codify \\$_cmd..."; fi
90+
fi
91+
# CODIFY_PATCH_END — do not remove this marker
92+
`;
93+
94+
const patched = content.slice(0, idx) + PATCH + content.slice(idx);
95+
96+
// Use exec to replace the shell process with Node rather than spawning a child.
97+
// This avoids an extra process in memory and ensures signals go directly to Node.
98+
const NODE_LAUNCH = ' "\\$NODE" ';
99+
const NODE_LAUNCH_EXEC = ' exec "\\$NODE" ';
100+
let withExec = patched;
101+
if (patched.includes(NODE_LAUNCH) && !patched.includes(NODE_LAUNCH_EXEC)) {
102+
withExec = patched.replace(NODE_LAUNCH, NODE_LAUNCH_EXEC);
103+
} else if (!patched.includes(NODE_LAUNCH_EXEC)) {
104+
console.error('ERROR: Could not find Node launch line to add exec. The oclif version may have changed.');
105+
process.exit(1);
106+
}
107+
108+
await fs.writeFile(BIN_JS, withExec, 'utf8');
109+
console.log('Successfully patched oclif bin.js');
110+
111+
// Patch macos.js preinstall script to also clear the auto-updater cache directory.
112+
// Oclif's auto-updater stores binaries in ~/.local/share/codify and the macOS installer
113+
// doesn't clean this up, so fresh installs still run the old cached version.
114+
// We must patch the template before `oclif pack macos` runs — modifying the .pkg after
115+
// the fact breaks notarization since the binary has been tampered with.
116+
const SEARCH_PREINSTALL = 'sudo rm -rf /usr/local/bin/${config.bin}\n${additionalCLI';
117+
const PATCH_PREINSTALL = 'sudo rm -rf /usr/local/bin/${config.bin}\nsudo rm -rf ~/.local/share/${config.dirname}\n${additionalCLI';
118+
119+
if (!existsSync(MACOS_JS)) {
120+
console.log('oclif macos.js not found. Skipping preinstall patch.');
121+
} else {
122+
const macosContent = await fs.readFile(MACOS_JS, 'utf8');
123+
if (macosContent.includes(PATCH_PREINSTALL)) {
124+
console.log('oclif macos.js preinstall already patched. Skipping.');
125+
} else if (!macosContent.includes(SEARCH_PREINSTALL)) {
126+
console.error('ERROR: Could not find preinstall insertion point in oclif macos.js. The oclif version may have changed.');
127+
process.exit(1);
128+
} else {
129+
await fs.writeFile(MACOS_JS, macosContent.replace(SEARCH_PREINSTALL, PATCH_PREINSTALL), 'utf8');
130+
console.log('Successfully patched oclif macos.js preinstall script');
131+
}
132+
}

scripts/pkg.ts

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,34 @@ await Promise.all([
3030
console.log(chalk.magenta('Esbuild src'))
3131
execSync('tsx esbuild.ts', { shell: 'zsh' })
3232

33+
console.log(chalk.magenta('Generating static help/version files'))
34+
await fs.mkdir('./.build/dist/static', { recursive: true });
35+
const helpOutput = execSync('./bin/dev.js --help', {
36+
shell: 'zsh',
37+
env: { ...process.env, FORCE_COLOR: '1' },
38+
}).toString();
39+
const versionOutput = execSync('./bin/dev.js --version', { shell: 'zsh' }).toString().trim();
40+
await fs.writeFile('./.build/dist/static/help.txt', helpOutput, 'utf8');
41+
await fs.writeFile('./.build/dist/static/version.txt', versionOutput + '\n', 'utf8');
42+
43+
const commandFiles = await fs.readdir('./src/commands');
44+
const commands = commandFiles
45+
.filter(f => f.endsWith('.ts') && !f.startsWith('index'))
46+
.map(f => f.replace(/\.ts$/, ''));
47+
for (const cmd of commands) {
48+
const cmdHelp = execSync(`./bin/dev.js ${cmd} --help`, {
49+
shell: 'zsh',
50+
env: { ...process.env, FORCE_COLOR: '1' },
51+
}).toString();
52+
await fs.writeFile(`./.build/dist/static/${cmd}-help.txt`, cmdHelp, 'utf8');
53+
}
54+
console.log(chalk.magenta(`Generated help files for: ${commands.join(', ')}`))
55+
3356
console.log(chalk.magenta('Install production dependencies'))
3457
execSync('npm install --production', { cwd: './.build', shell: 'zsh' })
3558

3659
console.log(chalk.magenta('Running oclif pkg macos'))
3760
execSync('oclif pack macos -r .', { cwd: './.build', shell: 'zsh' });
38-
await patchMacOsInstallers()
3961

4062
console.log(chalk.magenta('Running oclif pkg tarballs'))
4163
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> | any): Promise<void> {
5173
} catch (e) {
5274
}
5375
}
54-
55-
// Oclif has a bug where the installer doesn't clear out the auto-updater location. This causes older versions
56-
// to be re-used even with a clean install
57-
// Comment this out because it does not work with MacOS notary tool. It fails verification
58-
async function patchMacOsInstallers() {
59-
// console.log(chalk.magenta('Patching MacOS installers with bug fix'))
60-
//
61-
// const pkgFolder = './.build/dist/macos';
62-
// const files = await fs.readdir(pkgFolder)
63-
// const pkgFiles = files.filter((name) => name.endsWith('.pkg'))
64-
//
65-
// for (const pkgFile of pkgFiles) {
66-
// const pkgPath = path.join(pkgFolder, pkgFile);
67-
// const tmpPath = path.join(pkgFolder, 'tmp');
68-
//
69-
// execSync(`pkgutil --expand ${pkgPath} ${tmpPath}`)
70-
// await fs.appendFile(path.join(tmpPath, 'Scripts', 'preinstall'), '\nsudo rm -rf ~/.local/share/codify', 'utf8');
71-
// execSync(`pkgutil --flatten ${tmpPath} ${pkgPath} `)
72-
// execSync(`rm -rf ${tmpPath}`);
73-
// console.log(chalk.magenta(`Done patching installer ${pkgFile}`))
74-
// }
75-
}

src/commands/apply.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,6 @@ For more information, visit: https://codifycli.com/docs/commands/apply
5050
public async run(): Promise<void> {
5151
const { flags, args } = await this.parse(Apply)
5252

53-
54-
if (flags.output !== 'json') {
55-
console.log('Running Codify apply...')
56-
}
57-
5853
if (flags.path && args.pathArgs) {
5954
throw new Error('Cannot specify both --path and path argument');
6055
}

src/commands/destroy.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,6 @@ For more information, visit: https://codifycli.com/docs/commands/destory`
4747
public async run(): Promise<void> {
4848
const { flags, raw } = await this.parse(Destroy)
4949

50-
if (flags.output !== 'json') {
51-
console.log('Running Codify destroy...')
52-
}
53-
5450
const args = raw
5551
.filter((r) => r.type === 'arg')
5652
.map((r) => r.input);

src/connect/http-routes/create-command.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export enum ConnectCommand {
1212
PLAN = 'plan',
1313
IMPORT = 'import',
1414
REFRESH = 'refresh',
15+
DESTROY = 'destroy',
1516
INIT = 'init',
1617
TEST = 'test',
1718
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { spawn } from '@homebridge/node-pty-prebuilt-multiarch';
2+
import { ConfigFileSchema } from '@codifycli/schemas';
3+
import * as fs from 'node:fs/promises';
4+
import os from 'node:os';
5+
import path from 'node:path';
6+
import { WebSocket } from 'ws';
7+
8+
import { ConnectOrchestrator } from '../../../orchestrators/connect.js';
9+
import { ajv } from '../../../utils/ajv.js';
10+
import { ShellUtils } from '../../../utils/shell.js';
11+
import { Session } from '../../socket-server.js';
12+
import { ConnectCommand, createCommandHandler } from '../create-command.js';
13+
14+
const validator = ajv.compile(ConfigFileSchema);
15+
16+
export function destroyHandler() {
17+
const spawnCommand = async (body: Record<string, unknown>, ws: WebSocket, session: Session) => {
18+
const codifyConfig = body.config;
19+
if (!codifyConfig) {
20+
throw new Error('Unable to parse codify config');
21+
}
22+
23+
if (!validator(codifyConfig)) {
24+
throw new Error('Invalid codify config');
25+
}
26+
27+
const tmpDir = await fs.mkdtemp(os.tmpdir() + '/');
28+
const filePath = path.join(tmpDir, 'codify.jsonc');
29+
await fs.writeFile(filePath, JSON.stringify(codifyConfig, null, 2));
30+
31+
session.additionalData.filePath = filePath;
32+
33+
return spawn(ShellUtils.getDefaultShell(), ['-c', `${ConnectOrchestrator.nodeBinary} ${ConnectOrchestrator.rootCommand} destroy -p ${filePath}`], {
34+
name: 'xterm-color',
35+
cols: 80,
36+
rows: 30,
37+
cwd: process.env.HOME,
38+
env: process.env
39+
});
40+
}
41+
42+
const onExit = async (exitCode: number, ws: WebSocket, session: Session) => {
43+
if (session.additionalData.filePath) {
44+
await fs.rm(session.additionalData.filePath as string, { recursive: true, force: true });
45+
}
46+
}
47+
48+
return createCommandHandler({
49+
name: ConnectCommand.DESTROY,
50+
spawnCommand,
51+
onExit
52+
});
53+
}

src/connect/http-routes/router.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Router } from 'express';
22

33
import { applyHandler } from './handlers/apply-handler.js';
4+
import { destroyHandler } from './handlers/destroy-handler.js';
45
import { importHandler } from './handlers/import-handler.js';
56
import defaultHandler from './handlers/index.js';
67
import { initHandler } from './handlers/init-handler.js';
@@ -14,6 +15,7 @@ const router = Router();
1415

1516
router.use('/', defaultHandler);
1617
router.use('/apply', applyHandler());
18+
router.use('/destroy', destroyHandler());
1719
router.use('/plan', planHandler())
1820
router.use('/import', importHandler());
1921
router.use('/refresh', refreshHandler());

0 commit comments

Comments
 (0)