Skip to content

Commit 0ac4013

Browse files
committed
feat: empty absent state improvements. Added login + interactive shell.
1 parent 108222b commit 0ac4013

8 files changed

Lines changed: 78 additions & 9 deletions

File tree

bin/build.js

100644100755
File mode changed.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@codifycli/plugin-core",
3-
"version": "1.2.5",
3+
"version": "1.2.6",
44
"description": "TypeScript library for building Codify plugins to manage system resources (applications, CLI tools, settings) through infrastructure-as-code",
55
"main": "dist/index.js",
66
"typings": "dist/index.d.ts",

src/plan/change-set.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ export class ChangeSet<T extends StringIndexedObject> {
227227
return orderOfOperations[Math.max(indexPrev, indexNext)];
228228
}
229229

230-
private static isAbsent(v: unknown): boolean {
230+
static isAbsent(v: unknown): boolean {
231231
if (v === null || v === undefined) return true;
232232
if (Array.isArray(v)) return v.length === 0;
233233
if (typeof v === 'object') return Object.keys(v as object).length === 0;

src/plan/plan.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,39 @@ describe('Plan entity tests', () => {
226226
})
227227
})
228228

229+
it('Treats an empty-after-filtering array as no current state (already destroyed)', async () => {
230+
const resource = new class extends TestResource {
231+
getSettings(): ResourceSettings<any> {
232+
return {
233+
id: 'type',
234+
operatingSystems: [OS.Darwin],
235+
parameterSettings: {
236+
propZ: { type: 'array', isElementEqual: (a, b) => a === b }
237+
}
238+
}
239+
}
240+
241+
async refresh(): Promise<Partial<any> | null> {
242+
// The declared item is already gone from the system, but other unrelated items remain —
243+
// refresh() can't know which items belong to this resource's declared config, so it still
244+
// returns a non-empty object overall.
245+
return {
246+
propZ: ['some-other-unmanaged-item']
247+
}
248+
}
249+
}
250+
251+
const controller = new ResourceController(resource);
252+
const plan = await controller.plan(
253+
{ type: 'type' },
254+
null,
255+
{ propZ: ['20.15.0'] } as any,
256+
true
257+
)
258+
259+
expect(plan.changeSet.operation).to.eq(ResourceOperation.NOOP);
260+
})
261+
229262
it('Doesn\'t filters array parameters if filtering is disabled', async () => {
230263
const resource = new class extends TestResource {
231264
getSettings(): ResourceSettings<any> {

src/plan/plan.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,14 +96,26 @@ export class Plan<T extends StringIndexedObject> {
9696
isStateful
9797
});
9898

99-
const filteredCurrentParameters = Plan.filterCurrentParams<T>({
99+
let filteredCurrentParameters = Plan.filterCurrentParams<T>({
100100
desired,
101101
current,
102102
state,
103103
settings,
104104
isStateful
105105
});
106106

107+
// A filtered result with at least one key, where every value is absent (empty array/object, null,
108+
// undefined), carries no actionable state — treat it the same as no current parameters at all.
109+
// This matters for resources managing an internal array (e.g. multiple declarations in one
110+
// resource instance) where destroy-time filtering can legitimately narrow the array down to []
111+
// once every declared item has been removed, without the resource itself disappearing from
112+
// filteredCurrentParams. An empty object ({}, zero keys) is intentionally excluded — per the
113+
// refresh() contract, {} means "exists with no state to track", distinct from "doesn't exist".
114+
const filteredEntries = filteredCurrentParameters ? Object.values(filteredCurrentParameters) : [];
115+
if (filteredCurrentParameters && filteredEntries.length > 0 && filteredEntries.every((v) => ChangeSet.isAbsent(v))) {
116+
filteredCurrentParameters = null;
117+
}
118+
107119
// Empty
108120
if (!filteredCurrentParameters && !desired) {
109121
return new Plan(

src/pty/background-pty.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ EventEmitter.defaultMaxListeners = 1000;
2121
*/
2222
export class BackgroundPty implements IPty {
2323
private historyIgnore = Utils.getShell() === Shell.ZSH ? { HISTORY_IGNORE: '*' } : { HISTIGNORE: '*' };
24-
private basePty = pty.spawn(this.getDefaultShell(), ['-i'], {
24+
// Login + interactive (-l -i) so the user's rc files are sourced. GUI launches
25+
// (e.g. the Codify desktop app) do not inherit env like TART_HOME / PATH additions
26+
// from the parent process; these live in ~/.zshrc / ~/.zprofile.
27+
private basePty = pty.spawn(this.getDefaultShell(), ['-l', '-i'], {
2528
env: { ...process.env, ...this.historyIgnore },
2629
cols: 10_000, // Set to a really large value to prevent wrapping
2730
name: nanoid(6),
@@ -156,6 +159,6 @@ export class BackgroundPty implements IPty {
156159
}
157160

158161
private getDefaultShell(): string {
159-
return process.env.SHELL!;
162+
return Utils.getDefaultShell();
160163
}
161164
}

src/pty/seqeuntial-pty.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,11 @@ export class SequentialPty implements IPty {
9292
// the pty waiting for interactive input. Can't be disabled via env var; must unset the options explicitly.
9393
const disableAutocorrect = Utils.getShell() === Shell.ZSH ? 'unsetopt CORRECT CORRECT_ALL 2>/dev/null; ' : '';
9494
const wrappedCmd = `${disableAutocorrect}${cmd}`;
95-
const args = options?.interactive ? ['-i', '-c', wrappedCmd] : ['-c', wrappedCmd]
95+
// Use a login + interactive shell (-l -i) so the user's rc files are sourced.
96+
// This matters for GUI launches (e.g. the Codify desktop app) where env vars
97+
// like TART_HOME and PATH additions live in ~/.zshrc/~/.zprofile and are not
98+
// inherited from the parent process.
99+
const args = options?.interactive ? ['-l', '-i', '-c', wrappedCmd] : ['-c', wrappedCmd]
96100

97101
// Run the command in a pty for interactivity
98102
const mPty = pty.spawn(this.getDefaultShell(), args, {
@@ -175,6 +179,6 @@ export class SequentialPty implements IPty {
175179
}
176180

177181
private getDefaultShell(): string {
178-
return process.env.SHELL!;
182+
return Utils.getDefaultShell();
179183
}
180184
}

src/utils/index.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,20 @@ export const Utils = {
132132
return undefined;
133133
},
134134

135+
/**
136+
* Resolves the shell binary to launch commands with. `process.env.SHELL` is
137+
* only set when Codify is launched from a terminal — GUI launches (e.g. the
138+
* Codify desktop app via launchd) do not export it, in which case
139+
* `node-pty` would fall back to `/bin/sh`, which does not source the user's
140+
* `~/.zshrc`/`~/.bash_profile`. That drops user env (e.g. TART_HOME, PATH
141+
* additions), breaking resource refresh. Fall back to the login shell from
142+
* the passwd database (os.userInfo().shell) so we always use the user's real
143+
* shell regardless of how Codify was started.
144+
*/
145+
getDefaultShell(): string {
146+
return process.env.SHELL || os.userInfo().shell || '/bin/zsh';
147+
},
148+
135149

136150
getPrimaryShellRc(): string {
137151
return this.getShellRcFiles()[0];
@@ -244,7 +258,10 @@ Brew can be installed using Codify:
244258
if (brewOpts?.adopt) flags.push('--adopt');
245259
if (brewOpts?.flags) flags.push(...brewOpts.flags);
246260
const flagStr = flags.length > 0 ? `${flags.join(' ')} ` : '';
247-
await $.spawn(`brew install ${flagStr}${packageName}`, { interactive: true, env: { HOMEBREW_NO_AUTO_UPDATE: 1, HOMEBREW_NO_ASK: 1 } });
261+
// Redirect stdin from /dev/null so Homebrew's ask.rb detects a non-TTY stdin
262+
// and skips any [y/n] confirmation prompts (e.g. "Do you want to proceed with the installation?").
263+
// NONINTERACTIVE=1 alone is not sufficient — Homebrew's prompt only checks tty?, not that var.
264+
await $.spawn(`brew install ${flagStr}${packageName} < /dev/null`, { interactive: true, env: { HOMEBREW_NO_AUTO_UPDATE: 1, NONINTERACTIVE: 1 } });
248265
return;
249266
}
250267

@@ -350,7 +367,7 @@ Brew can be installed using Codify:
350367
const flagStr = flags.length > 0 ? `${flags.join(' ')} ` : '';
351368
const { status } = await $.spawnSafe(`brew uninstall ${flagStr}${packageName}`, {
352369
interactive: true,
353-
env: { HOMEBREW_NO_AUTO_UPDATE: 1, HOMEBREW_NO_ASK: 1 }
370+
env: { HOMEBREW_NO_AUTO_UPDATE: 1, NONINTERACTIVE: 1 }
354371
});
355372
return status === SpawnStatus.SUCCESS;
356373
}

0 commit comments

Comments
 (0)