@codifycli/plugin-core 1.2.5-beta.2 → 1.2.5-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,6 +49,6 @@ export declare class ChangeSet<T extends StringIndexedObject> {
49
49
  */
50
50
  private static calculateParameterChanges;
51
51
  private static combineResourceOperations;
52
- private static isAbsent;
52
+ static isAbsent(v: unknown): boolean;
53
53
  private static isSame;
54
54
  }
package/dist/plan/plan.js CHANGED
@@ -55,13 +55,24 @@ export class Plan {
55
55
  settings,
56
56
  isStateful
57
57
  });
58
- const filteredCurrentParameters = Plan.filterCurrentParams({
58
+ let filteredCurrentParameters = Plan.filterCurrentParams({
59
59
  desired,
60
60
  current,
61
61
  state,
62
62
  settings,
63
63
  isStateful
64
64
  });
65
+ // A filtered result with at least one key, where every value is absent (empty array/object, null,
66
+ // undefined), carries no actionable state — treat it the same as no current parameters at all.
67
+ // This matters for resources managing an internal array (e.g. multiple declarations in one
68
+ // resource instance) where destroy-time filtering can legitimately narrow the array down to []
69
+ // once every declared item has been removed, without the resource itself disappearing from
70
+ // filteredCurrentParams. An empty object ({}, zero keys) is intentionally excluded — per the
71
+ // refresh() contract, {} means "exists with no state to track", distinct from "doesn't exist".
72
+ const filteredEntries = filteredCurrentParameters ? Object.values(filteredCurrentParameters) : [];
73
+ if (filteredCurrentParameters && filteredEntries.length > 0 && filteredEntries.every((v) => ChangeSet.isAbsent(v))) {
74
+ filteredCurrentParameters = null;
75
+ }
65
76
  // Empty
66
77
  if (!filteredCurrentParameters && !desired) {
67
78
  return new Plan(uuidV4(), ChangeSet.empty(), core, isStateful);
@@ -18,7 +18,10 @@ EventEmitter.defaultMaxListeners = 1000;
18
18
  */
19
19
  export class BackgroundPty {
20
20
  historyIgnore = Utils.getShell() === Shell.ZSH ? { HISTORY_IGNORE: '*' } : { HISTIGNORE: '*' };
21
- basePty = pty.spawn(this.getDefaultShell(), ['-i'], {
21
+ // Login + interactive (-l -i) so the user's rc files are sourced. GUI launches
22
+ // (e.g. the Codify desktop app) do not inherit env like TART_HOME / PATH additions
23
+ // from the parent process; these live in ~/.zshrc / ~/.zprofile.
24
+ basePty = pty.spawn(this.getDefaultShell(), ['-l', '-i'], {
22
25
  env: { ...process.env, ...this.historyIgnore },
23
26
  cols: 10_000, // Set to a really large value to prevent wrapping
24
27
  name: nanoid(6),
@@ -128,6 +131,6 @@ export class BackgroundPty {
128
131
  return [];
129
132
  }
130
133
  getDefaultShell() {
131
- return process.env.SHELL;
134
+ return Utils.getDefaultShell();
132
135
  }
133
136
  }
@@ -70,7 +70,11 @@ export class SequentialPty {
70
70
  // the pty waiting for interactive input. Can't be disabled via env var; must unset the options explicitly.
71
71
  const disableAutocorrect = Utils.getShell() === Shell.ZSH ? 'unsetopt CORRECT CORRECT_ALL 2>/dev/null; ' : '';
72
72
  const wrappedCmd = `${disableAutocorrect}${cmd}`;
73
- const args = options?.interactive ? ['-i', '-c', wrappedCmd] : ['-c', wrappedCmd];
73
+ // Use a login + interactive shell (-l -i) so the user's rc files are sourced.
74
+ // This matters for GUI launches (e.g. the Codify desktop app) where env vars
75
+ // like TART_HOME and PATH additions live in ~/.zshrc/~/.zprofile and are not
76
+ // inherited from the parent process.
77
+ const args = options?.interactive ? ['-l', '-i', '-c', wrappedCmd] : ['-c', wrappedCmd];
74
78
  // Run the command in a pty for interactivity
75
79
  const mPty = pty.spawn(this.getDefaultShell(), args, {
76
80
  ...options,
@@ -134,6 +138,6 @@ export class SequentialPty {
134
138
  });
135
139
  }
136
140
  getDefaultShell() {
137
- return process.env.SHELL;
141
+ return Utils.getDefaultShell();
138
142
  }
139
143
  }
@@ -53,6 +53,17 @@ export declare const Utils: {
53
53
  isHomebrewInstalled(): Promise<boolean>;
54
54
  isRosetta2Installed(): Promise<boolean>;
55
55
  getShell(): Shell | undefined;
56
+ /**
57
+ * Resolves the shell binary to launch commands with. `process.env.SHELL` is
58
+ * only set when Codify is launched from a terminal — GUI launches (e.g. the
59
+ * Codify desktop app via launchd) do not export it, in which case
60
+ * `node-pty` would fall back to `/bin/sh`, which does not source the user's
61
+ * `~/.zshrc`/`~/.bash_profile`. That drops user env (e.g. TART_HOME, PATH
62
+ * additions), breaking resource refresh. Fall back to the login shell from
63
+ * the passwd database (os.userInfo().shell) so we always use the user's real
64
+ * shell regardless of how Codify was started.
65
+ */
66
+ getDefaultShell(): string;
56
67
  getPrimaryShellRc(): string;
57
68
  getShellRcFiles(): string[];
58
69
  isDirectoryOnPath(directory: string): Promise<boolean>;
@@ -85,6 +85,19 @@ export const Utils = {
85
85
  }
86
86
  return undefined;
87
87
  },
88
+ /**
89
+ * Resolves the shell binary to launch commands with. `process.env.SHELL` is
90
+ * only set when Codify is launched from a terminal — GUI launches (e.g. the
91
+ * Codify desktop app via launchd) do not export it, in which case
92
+ * `node-pty` would fall back to `/bin/sh`, which does not source the user's
93
+ * `~/.zshrc`/`~/.bash_profile`. That drops user env (e.g. TART_HOME, PATH
94
+ * additions), breaking resource refresh. Fall back to the login shell from
95
+ * the passwd database (os.userInfo().shell) so we always use the user's real
96
+ * shell regardless of how Codify was started.
97
+ */
98
+ getDefaultShell() {
99
+ return process.env.SHELL || os.userInfo().shell || '/bin/zsh';
100
+ },
88
101
  getPrimaryShellRc() {
89
102
  return this.getShellRcFiles()[0];
90
103
  },
@@ -180,7 +193,10 @@ Brew can be installed using Codify:
180
193
  if (brewOpts?.flags)
181
194
  flags.push(...brewOpts.flags);
182
195
  const flagStr = flags.length > 0 ? `${flags.join(' ')} ` : '';
183
- await $.spawn(`brew install ${flagStr}${packageName}`, { interactive: true, env: { HOMEBREW_NO_AUTO_UPDATE: 1, NONINTERACTIVE: 1 } });
196
+ // Redirect stdin from /dev/null so Homebrew's ask.rb detects a non-TTY stdin
197
+ // and skips any [y/n] confirmation prompts (e.g. "Do you want to proceed with the installation?").
198
+ // NONINTERACTIVE=1 alone is not sufficient — Homebrew's prompt only checks tty?, not that var.
199
+ await $.spawn(`brew install ${flagStr}${packageName} < /dev/null`, { interactive: true, env: { HOMEBREW_NO_AUTO_UPDATE: 1, NONINTERACTIVE: 1 } });
184
200
  return;
185
201
  }
186
202
  const useApt = forcePackageManager === PackageManager.APT || (!forcePackageManager && Utils.isLinux());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codifycli/plugin-core",
3
- "version": "1.2.5-beta.2",
3
+ "version": "1.2.5-beta.4",
4
4
  "description": "TypeScript library for building Codify plugins to manage system resources (applications, CLI tools, settings) through infrastructure-as-code",
5
5
  "main": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",
@@ -227,7 +227,7 @@ export class ChangeSet<T extends StringIndexedObject> {
227
227
  return orderOfOperations[Math.max(indexPrev, indexNext)];
228
228
  }
229
229
 
230
- private static isAbsent(v: unknown): boolean {
230
+ static isAbsent(v: unknown): boolean {
231
231
  if (v === null || v === undefined) return true;
232
232
  if (Array.isArray(v)) return v.length === 0;
233
233
  if (typeof v === 'object') return Object.keys(v as object).length === 0;
@@ -226,6 +226,39 @@ describe('Plan entity tests', () => {
226
226
  })
227
227
  })
228
228
 
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
+
229
262
  it('Doesn\'t filters array parameters if filtering is disabled', async () => {
230
263
  const resource = new class extends TestResource {
231
264
  getSettings(): ResourceSettings<any> {
package/src/plan/plan.ts CHANGED
@@ -96,7 +96,7 @@ export class Plan<T extends StringIndexedObject> {
96
96
  isStateful
97
97
  });
98
98
 
99
- const filteredCurrentParameters = Plan.filterCurrentParams<T>({
99
+ let filteredCurrentParameters = Plan.filterCurrentParams<T>({
100
100
  desired,
101
101
  current,
102
102
  state,
@@ -104,6 +104,18 @@ export class Plan<T extends StringIndexedObject> {
104
104
  isStateful
105
105
  });
106
106
 
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
+
107
119
  // Empty
108
120
  if (!filteredCurrentParameters && !desired) {
109
121
  return new Plan(
@@ -21,7 +21,10 @@ EventEmitter.defaultMaxListeners = 1000;
21
21
  */
22
22
  export class BackgroundPty implements IPty {
23
23
  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'], {
25
28
  env: { ...process.env, ...this.historyIgnore },
26
29
  cols: 10_000, // Set to a really large value to prevent wrapping
27
30
  name: nanoid(6),
@@ -156,6 +159,6 @@ export class BackgroundPty implements IPty {
156
159
  }
157
160
 
158
161
  private getDefaultShell(): string {
159
- return process.env.SHELL!;
162
+ return Utils.getDefaultShell();
160
163
  }
161
164
  }
@@ -92,7 +92,11 @@ export class SequentialPty implements IPty {
92
92
  // the pty waiting for interactive input. Can't be disabled via env var; must unset the options explicitly.
93
93
  const disableAutocorrect = Utils.getShell() === Shell.ZSH ? 'unsetopt CORRECT CORRECT_ALL 2>/dev/null; ' : '';
94
94
  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]
96
100
 
97
101
  // Run the command in a pty for interactivity
98
102
  const mPty = pty.spawn(this.getDefaultShell(), args, {
@@ -175,6 +179,6 @@ export class SequentialPty implements IPty {
175
179
  }
176
180
 
177
181
  private getDefaultShell(): string {
178
- return process.env.SHELL!;
182
+ return Utils.getDefaultShell();
179
183
  }
180
184
  }
@@ -132,6 +132,20 @@ export const Utils = {
132
132
  return undefined;
133
133
  },
134
134
 
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
+
135
149
 
136
150
  getPrimaryShellRc(): string {
137
151
  return this.getShellRcFiles()[0];
@@ -244,7 +258,10 @@ Brew can be installed using Codify:
244
258
  if (brewOpts?.adopt) flags.push('--adopt');
245
259
  if (brewOpts?.flags) flags.push(...brewOpts.flags);
246
260
  const flagStr = flags.length > 0 ? `${flags.join(' ')} ` : '';
247
- await $.spawn(`brew install ${flagStr}${packageName}`, { interactive: true, env: { HOMEBREW_NO_AUTO_UPDATE: 1, NONINTERACTIVE: 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 } });
248
265
  return;
249
266
  }
250
267