slicejs-cli 3.0.0 → 3.0.2

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.
package/README.md CHANGED
@@ -45,6 +45,29 @@ npm run dev
45
45
  npm run slice -- get Button
46
46
  ```
47
47
 
48
+ 4. Use `slice` directly when the launcher command is available on your system
49
+ (commonly after a global install that puts `slice` in your PATH).
50
+ The launcher delegates to your nearest project-local `node_modules/slicejs-cli`
51
+ so project-pinned behavior is used from the project root and subdirectories.
52
+
53
+ ```bash
54
+ slice dev
55
+ slice build
56
+ slice version
57
+ ```
58
+
59
+ If `slice` is not available in your shell, use:
60
+
61
+ ```bash
62
+ npx slicejs-cli dev
63
+ ```
64
+
65
+ You can disable launcher delegation for a command when needed:
66
+
67
+ ```bash
68
+ SLICE_NO_LOCAL_DELEGATION=1 slice version
69
+ ```
70
+
48
71
  ### Global (Not Recommended)
49
72
 
50
73
  Global installations can lead to version mismatches and "works on my machine" issues.
@@ -55,7 +78,10 @@ npm install -g slicejs-cli
55
78
 
56
79
  ## Usage
57
80
 
58
- After installation, you can use the `slice` command directly:
81
+ After installation, prefer your project-local CLI. When the `slice` launcher command is
82
+ available, it automatically delegates to the nearest local `slicejs-cli` install.
83
+
84
+ Use the `slice` command directly:
59
85
 
60
86
  ```bash
61
87
  slice [command] [options]
@@ -67,6 +93,8 @@ Or with npx (without global install):
67
93
  npx slicejs-cli [command]
68
94
  ```
69
95
 
96
+ Use `npx slicejs-cli [command]` as a fallback when the `slice` launcher command is unavailable.
97
+
70
98
  ## Essential Commands
71
99
 
72
100
  ### Initialize a project
@@ -154,7 +182,7 @@ slice sync
154
182
  ```bash
155
183
  # Version info
156
184
  slice version
157
- slice -v
185
+ slice v
158
186
 
159
187
  # Updates (CLI and Framework)
160
188
  slice update # Check and prompt to update
@@ -325,16 +353,16 @@ slice init
325
353
  ### Command not found
326
354
 
327
355
  ```bash
328
- # Use npx if not installed globally
356
+ # If the launcher command is unavailable, run the local CLI via npx
329
357
  npx slicejs-cli dev
330
358
 
331
- # Or install globally
359
+ # Optional: install globally to expose the slice launcher command
332
360
  npm install -g slicejs-cli
333
361
  ```
334
362
 
335
363
  ## Links
336
364
 
337
- - 📘 Documentation: https://slice-js-docs.vercel.app/
365
+ - 📘 CLI Documentation: https://slice-js-docs.vercel.app/Documentation/CLI
338
366
  - 🐙 GitHub: https://github.com/VKneider/slice-cli
339
367
  - 📦 npm: https://www.npmjs.com/package/slicejs-cli
340
368
 
package/client.js CHANGED
@@ -14,12 +14,18 @@ import fs from "fs";
14
14
  import path from "path";
15
15
  import { fileURLToPath } from "url";
16
16
  import { getConfigPath, getProjectRoot } from "./commands/utils/PathHelper.js";
17
- import { exec } from "child_process";
17
+ import { exec, spawnSync } from "node:child_process";
18
18
  import { promisify } from "util";
19
19
  import validations from "./commands/Validations.js";
20
20
  import Print from "./commands/Print.js";
21
21
  import build from './commands/build/build.js';
22
22
  import { cleanBundles, bundleInfo } from './commands/bundle/bundle.js';
23
+ import {
24
+ isLocalDelegationDisabled,
25
+ findNearestLocalCliEntry,
26
+ resolveLocalCliCandidate,
27
+ shouldDelegateToLocalCli
28
+ } from './commands/utils/LocalCliDelegation.js';
23
29
 
24
30
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
31
 
@@ -84,10 +90,7 @@ async function runWithVersionCheck(commandFunction, ...args) {
84
90
  } catch {}
85
91
  })();
86
92
 
87
- const updateInfo = await updateManager.checkForUpdates();
88
- if (updateInfo && updateInfo.hasUpdates) {
89
- await updateManager.checkAndPromptUpdates({});
90
- }
93
+ updateManager.notifyAvailableUpdates().catch(() => {});
91
94
 
92
95
  const result = await commandFunction(...args);
93
96
 
@@ -102,6 +105,33 @@ async function runWithVersionCheck(commandFunction, ...args) {
102
105
  }
103
106
  }
104
107
 
108
+ function maybeDelegateToLocalCli() {
109
+ if (isLocalDelegationDisabled(process.env)) {
110
+ return;
111
+ }
112
+
113
+ const currentEntryPath = fileURLToPath(import.meta.url);
114
+ const localEntryPath = findNearestLocalCliEntry(process.cwd(), resolveLocalCliCandidate);
115
+
116
+ if (!shouldDelegateToLocalCli(currentEntryPath, localEntryPath)) {
117
+ return;
118
+ }
119
+
120
+ const child = spawnSync(
121
+ process.execPath,
122
+ [localEntryPath, ...process.argv.slice(2)],
123
+ {
124
+ stdio: 'inherit',
125
+ cwd: process.cwd(),
126
+ env: process.env
127
+ }
128
+ );
129
+
130
+ process.exit(child.status ?? 1);
131
+ }
132
+
133
+ maybeDelegateToLocalCli();
134
+
105
135
  const sliceClient = program;
106
136
 
107
137
  try {
@@ -0,0 +1,53 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ function getParentDirectory(dir) {
5
+ const parent = path.dirname(dir);
6
+ return parent === dir ? null : parent;
7
+ }
8
+
9
+ export function isLocalDelegationDisabled(env = process.env) {
10
+ return env.SLICE_NO_LOCAL_DELEGATION === '1';
11
+ }
12
+
13
+ export function findNearestLocalCliEntry(startDirectory, resolveCandidate) {
14
+ if (!startDirectory || typeof resolveCandidate !== 'function') {
15
+ return null;
16
+ }
17
+
18
+ let current = path.resolve(startDirectory);
19
+ while (current) {
20
+ const candidate = resolveCandidate(current);
21
+ if (candidate) {
22
+ return candidate;
23
+ }
24
+ current = getParentDirectory(current);
25
+ }
26
+
27
+ return null;
28
+ }
29
+
30
+ export function resolveLocalCliCandidate(directory) {
31
+ const candidate = path.join(directory, 'node_modules', 'slicejs-cli', 'client.js');
32
+
33
+ try {
34
+ const stats = fs.statSync(candidate);
35
+ return stats.isFile() ? candidate : null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ export function shouldDelegateToLocalCli(currentEntryPath, localEntryPath) {
42
+ if (!localEntryPath) {
43
+ return false;
44
+ }
45
+
46
+ try {
47
+ const currentReal = fs.realpathSync(currentEntryPath);
48
+ const localReal = fs.realpathSync(localEntryPath);
49
+ return currentReal !== localReal;
50
+ } catch {
51
+ return path.resolve(currentEntryPath) !== path.resolve(localEntryPath);
52
+ }
53
+ }
@@ -13,7 +13,7 @@ import fs from "fs-extra";
13
13
 
14
14
  const execAsync = promisify(exec);
15
15
 
16
- class UpdateManager {
16
+ export class UpdateManager {
17
17
  constructor() {
18
18
  this.packagesToUpdate = [];
19
19
  }
@@ -46,7 +46,9 @@ class UpdateManager {
46
46
  /**
47
47
  * Check for available updates and return structured info
48
48
  */
49
- async checkForUpdates() {
49
+ async checkForUpdates(options = {}) {
50
+ const { silentErrors = false } = options;
51
+
50
52
  try {
51
53
  const updateInfo = await versionChecker.checkForUpdates(true); // Silent mode
52
54
 
@@ -82,11 +84,25 @@ class UpdateManager {
82
84
  allCurrent: updateInfo.cli.status === 'current' && updateInfo.framework.status === 'current'
83
85
  };
84
86
  } catch (error) {
85
- Print.error(`Checking for updates: ${error.message}`);
87
+ if (!silentErrors) {
88
+ Print.error(`Checking for updates: ${error.message}`);
89
+ }
86
90
  return null;
87
91
  }
88
92
  }
89
93
 
94
+ async notifyAvailableUpdates() {
95
+ const updateInfo = await this.checkForUpdates({ silentErrors: true });
96
+
97
+ if (!updateInfo || !updateInfo.hasUpdates) {
98
+ return false;
99
+ }
100
+
101
+ this.displayUpdates(updateInfo);
102
+ Print.info("Run 'slice update' to install updates when convenient.");
103
+ return true;
104
+ }
105
+
90
106
  /**
91
107
  * Display available updates in a formatted way
92
108
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slicejs-cli",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "description": "Command client for developing web applications with Slice.js framework",
5
5
  "main": "client.js",
6
6
  "bin": {
@@ -0,0 +1,211 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { parse } from '@babel/parser';
7
+ import babelTraverse from '@babel/traverse';
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+ const clientPath = path.join(__dirname, '..', 'client.js');
11
+ const source = fs.readFileSync(clientPath, 'utf-8');
12
+ const ast = parse(source, {
13
+ sourceType: 'module',
14
+ plugins: []
15
+ });
16
+ const traverse = babelTraverse.default || babelTraverse;
17
+
18
+ function walk(node, visit) {
19
+ if (!node || typeof node !== 'object') {
20
+ return;
21
+ }
22
+
23
+ visit(node);
24
+
25
+ for (const value of Object.values(node)) {
26
+ if (Array.isArray(value)) {
27
+ for (const item of value) {
28
+ walk(item, visit);
29
+ }
30
+ continue;
31
+ }
32
+
33
+ walk(value, visit);
34
+ }
35
+ }
36
+
37
+ function getImportedBindings(fromSource) {
38
+ const bindings = new Map();
39
+
40
+ for (const statement of ast.program.body) {
41
+ if (statement.type !== 'ImportDeclaration') {
42
+ continue;
43
+ }
44
+
45
+ if (statement.source.value !== fromSource) {
46
+ continue;
47
+ }
48
+
49
+ for (const specifier of statement.specifiers) {
50
+ if (specifier.type === 'ImportSpecifier' && specifier.imported.type === 'Identifier') {
51
+ bindings.set(specifier.imported.name, specifier.local.name);
52
+ }
53
+ }
54
+ }
55
+
56
+ return bindings;
57
+ }
58
+
59
+ function getBoundImportedCallPositions(fromSource, importedName) {
60
+ const positions = [];
61
+ const importedBindings = getImportedBindings(fromSource);
62
+ const localName = importedBindings.get(importedName);
63
+
64
+ if (!localName) {
65
+ return positions;
66
+ }
67
+
68
+ traverse(ast, {
69
+ CallExpression(callPath) {
70
+ if (callPath.node.callee.type !== 'Identifier') {
71
+ return;
72
+ }
73
+
74
+ if (callPath.node.callee.name !== localName) {
75
+ return;
76
+ }
77
+
78
+ const binding = callPath.scope.getBinding(localName);
79
+ if (!binding) {
80
+ return;
81
+ }
82
+
83
+ if (binding.path.node.type !== 'ImportSpecifier') {
84
+ return;
85
+ }
86
+
87
+ if (binding.path.parent.type !== 'ImportDeclaration') {
88
+ return;
89
+ }
90
+
91
+ if (binding.path.parent.source.value !== fromSource) {
92
+ return;
93
+ }
94
+
95
+ if (binding.path.node.imported.type !== 'Identifier') {
96
+ return;
97
+ }
98
+
99
+ if (binding.path.node.imported.name !== importedName) {
100
+ return;
101
+ }
102
+
103
+ positions.push(callPath.node.start);
104
+ }
105
+ });
106
+
107
+ return positions;
108
+ }
109
+
110
+ function getCommandRegistrationPositions() {
111
+ const positions = [];
112
+
113
+ walk(ast.program, (node) => {
114
+ if (node.type !== 'CallExpression') {
115
+ return;
116
+ }
117
+
118
+ if (
119
+ node.callee.type === 'MemberExpression' &&
120
+ !node.callee.computed &&
121
+ node.callee.property.type === 'Identifier' &&
122
+ node.callee.property.name === 'command'
123
+ ) {
124
+ positions.push(node.start);
125
+ }
126
+ });
127
+
128
+ return positions;
129
+ }
130
+
131
+ const launcherModulePath = './commands/utils/LocalCliDelegation.js';
132
+ const commandRegistrationPositions = getCommandRegistrationPositions();
133
+ const firstCommandRegistrationPos = Math.min(...commandRegistrationPositions);
134
+
135
+ test('client imports LocalCliDelegation utility', () => {
136
+ const importedBindings = getImportedBindings(launcherModulePath);
137
+
138
+ assert.ok(
139
+ importedBindings.size > 0,
140
+ 'Contract clause failed: client.js must import launcher helpers from ./commands/utils/LocalCliDelegation.js'
141
+ );
142
+
143
+ for (const requiredImport of [
144
+ 'isLocalDelegationDisabled',
145
+ 'findNearestLocalCliEntry',
146
+ 'shouldDelegateToLocalCli'
147
+ ]) {
148
+ assert.ok(
149
+ importedBindings.has(requiredImport),
150
+ `Contract clause failed: client.js must import ${requiredImport} from ${launcherModulePath}`
151
+ );
152
+ }
153
+ });
154
+
155
+ test('client checks SLICE_NO_LOCAL_DELEGATION behavior before command runtime', () => {
156
+ const isDisabledCalls = getBoundImportedCallPositions(
157
+ launcherModulePath,
158
+ 'isLocalDelegationDisabled'
159
+ );
160
+
161
+ assert.ok(
162
+ isDisabledCalls.length > 0,
163
+ 'Contract clause failed: client.js must call imported isLocalDelegationDisabled() in launcher path'
164
+ );
165
+
166
+ assert.ok(
167
+ commandRegistrationPositions.length > 0,
168
+ 'Contract clause failed: client.js must define at least one .command(...) registration before launcher ordering checks'
169
+ );
170
+
171
+ assert.ok(
172
+ isDisabledCalls.some((pos) => pos < firstCommandRegistrationPos),
173
+ 'Contract clause failed: isLocalDelegationDisabled() must be evaluated before command registration/runtime wiring'
174
+ );
175
+ });
176
+
177
+ test('client performs local candidate resolution and delegation decision', () => {
178
+ const findNearestCalls = getBoundImportedCallPositions(
179
+ launcherModulePath,
180
+ 'findNearestLocalCliEntry'
181
+ );
182
+ const shouldDelegateCalls = getBoundImportedCallPositions(
183
+ launcherModulePath,
184
+ 'shouldDelegateToLocalCli'
185
+ );
186
+
187
+ assert.ok(
188
+ findNearestCalls.length > 0,
189
+ 'Contract clause failed: client.js must call imported findNearestLocalCliEntry() to resolve local CLI candidate in launcher path'
190
+ );
191
+
192
+ assert.ok(
193
+ shouldDelegateCalls.length > 0,
194
+ 'Contract clause failed: client.js must call imported shouldDelegateToLocalCli() to gate delegation in launcher path'
195
+ );
196
+
197
+ assert.ok(
198
+ commandRegistrationPositions.length > 0,
199
+ 'Contract clause failed: client.js must define at least one .command(...) registration before launcher ordering checks'
200
+ );
201
+
202
+ assert.ok(
203
+ findNearestCalls.some((pos) => pos < firstCommandRegistrationPos),
204
+ 'Contract clause failed: findNearestLocalCliEntry() must execute before command registration/runtime wiring'
205
+ );
206
+
207
+ assert.ok(
208
+ shouldDelegateCalls.some((pos) => pos < firstCommandRegistrationPos),
209
+ 'Contract clause failed: shouldDelegateToLocalCli() must execute before command registration/runtime wiring'
210
+ );
211
+ });
@@ -0,0 +1,272 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { parse } from '@babel/parser';
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const clientPath = path.join(__dirname, '..', 'client.js');
10
+ const clientSource = fs.readFileSync(clientPath, 'utf-8');
11
+ const ast = parse(clientSource, {
12
+ sourceType: 'module',
13
+ errorRecovery: true,
14
+ loc: true,
15
+ ranges: true
16
+ });
17
+
18
+ function lineOf(node) {
19
+ return node && node.loc && node.loc.start ? node.loc.start.line : null;
20
+ }
21
+
22
+ function walk(node, visit) {
23
+ if (!node || typeof node !== 'object') {
24
+ return;
25
+ }
26
+
27
+ visit(node);
28
+
29
+ for (const value of Object.values(node)) {
30
+ if (Array.isArray(value)) {
31
+ for (const item of value) {
32
+ walk(item, visit);
33
+ }
34
+ continue;
35
+ }
36
+
37
+ walk(value, visit);
38
+ }
39
+ }
40
+
41
+ function isMethodCall(node, methodName) {
42
+ return (
43
+ node &&
44
+ node.type === 'CallExpression' &&
45
+ node.callee &&
46
+ node.callee.type === 'MemberExpression' &&
47
+ node.callee.property &&
48
+ node.callee.property.type === 'Identifier' &&
49
+ node.callee.property.name === methodName
50
+ );
51
+ }
52
+
53
+ function isCommandUpdateExpression(node) {
54
+ if (!node || node.type !== 'CallExpression') {
55
+ return false;
56
+ }
57
+
58
+ if (
59
+ node.callee &&
60
+ node.callee.type === 'MemberExpression' &&
61
+ node.callee.property &&
62
+ node.callee.property.type === 'Identifier' &&
63
+ node.callee.property.name === 'command' &&
64
+ node.arguments[0] &&
65
+ node.arguments[0].type === 'StringLiteral' &&
66
+ node.arguments[0].value === 'update'
67
+ ) {
68
+ return true;
69
+ }
70
+
71
+ if (node.callee && node.callee.type === 'MemberExpression') {
72
+ return isCommandUpdateExpression(node.callee.object);
73
+ }
74
+
75
+ return false;
76
+ }
77
+
78
+ function getRunWithVersionCheckNode() {
79
+ let foundNode = null;
80
+
81
+ walk(ast, (node) => {
82
+ if (
83
+ node.type === 'FunctionDeclaration' &&
84
+ node.id &&
85
+ node.id.type === 'Identifier' &&
86
+ node.id.name === 'runWithVersionCheck'
87
+ ) {
88
+ foundNode = node;
89
+ return;
90
+ }
91
+
92
+ if (node.type !== 'VariableDeclarator') {
93
+ return;
94
+ }
95
+
96
+ if (!node.id || node.id.type !== 'Identifier' || node.id.name !== 'runWithVersionCheck') {
97
+ return;
98
+ }
99
+
100
+ if (
101
+ node.init &&
102
+ (node.init.type === 'ArrowFunctionExpression' || node.init.type === 'FunctionExpression')
103
+ ) {
104
+ foundNode = node.init;
105
+ }
106
+ });
107
+
108
+ return foundNode;
109
+ }
110
+
111
+ function getFunctionBodyNode(fnNode) {
112
+ if (!fnNode) {
113
+ return null;
114
+ }
115
+
116
+ if (fnNode.type === 'FunctionDeclaration' || fnNode.type === 'FunctionExpression' || fnNode.type === 'ArrowFunctionExpression') {
117
+ return fnNode.body;
118
+ }
119
+
120
+ return null;
121
+ }
122
+
123
+ function getFunctionLikeByName(name) {
124
+ let foundNode = null;
125
+
126
+ walk(ast, (node) => {
127
+ if (
128
+ node.type === 'FunctionDeclaration' &&
129
+ node.id &&
130
+ node.id.type === 'Identifier' &&
131
+ node.id.name === name
132
+ ) {
133
+ foundNode = node;
134
+ return;
135
+ }
136
+
137
+ if (node.type !== 'VariableDeclarator') {
138
+ return;
139
+ }
140
+
141
+ if (!node.id || node.id.type !== 'Identifier' || node.id.name !== name) {
142
+ return;
143
+ }
144
+
145
+ if (
146
+ node.init &&
147
+ (node.init.type === 'ArrowFunctionExpression' || node.init.type === 'FunctionExpression')
148
+ ) {
149
+ foundNode = node.init;
150
+ }
151
+ });
152
+
153
+ return foundNode;
154
+ }
155
+
156
+ test('runWithVersionCheck uses non-blocking update notifications', () => {
157
+ const runWithVersionCheckNode = getRunWithVersionCheckNode();
158
+ const runWithVersionCheckBody = getFunctionBodyNode(runWithVersionCheckNode);
159
+
160
+ assert.ok(
161
+ runWithVersionCheckNode,
162
+ 'runWithVersionCheck function should exist as a declaration or function-valued variable'
163
+ );
164
+ assert.ok(runWithVersionCheckBody, 'runWithVersionCheck should have a traversable function body');
165
+
166
+ let hasNotifyCall = false;
167
+ let hasAwaitedNotifyCall = false;
168
+ let notifyCallLine = null;
169
+ let hasPromptCall = false;
170
+ let promptCallLine = null;
171
+
172
+ walk(runWithVersionCheckBody, (node) => {
173
+ if (isMethodCall(node, 'notifyAvailableUpdates')) {
174
+ hasNotifyCall = true;
175
+ notifyCallLine = notifyCallLine ?? lineOf(node);
176
+ }
177
+ if (
178
+ node.type === 'AwaitExpression' &&
179
+ isMethodCall(node.argument, 'notifyAvailableUpdates')
180
+ ) {
181
+ hasAwaitedNotifyCall = true;
182
+ }
183
+ if (isMethodCall(node, 'checkAndPromptUpdates')) {
184
+ hasPromptCall = true;
185
+ promptCallLine = promptCallLine ?? lineOf(node);
186
+ }
187
+ });
188
+
189
+ assert.equal(
190
+ hasNotifyCall,
191
+ true,
192
+ `runWithVersionCheck must call updateManager.notifyAvailableUpdates() (found at line ${notifyCallLine ?? 'unknown'})`
193
+ );
194
+ assert.equal(
195
+ hasAwaitedNotifyCall,
196
+ false,
197
+ `runWithVersionCheck should fire-and-forget notifyAvailableUpdates() without await (notify call line ${notifyCallLine ?? 'unknown'})`
198
+ );
199
+ assert.equal(
200
+ hasPromptCall,
201
+ false,
202
+ `runWithVersionCheck must not call updateManager.checkAndPromptUpdates() (found at line ${promptCallLine ?? 'unknown'})`
203
+ );
204
+ });
205
+
206
+ test('update command remains explicitly interactive', () => {
207
+ let foundAwaitedInteractiveUpdateAction = false;
208
+ let updateActionHandlerLine = null;
209
+ let relatedPromptCallLine = null;
210
+ let handlerReferenceName = null;
211
+
212
+ walk(ast, (node) => {
213
+ if (
214
+ node.type === 'CallExpression' &&
215
+ node.callee &&
216
+ node.callee.type === 'MemberExpression' &&
217
+ node.callee.property &&
218
+ node.callee.property.type === 'Identifier' &&
219
+ node.callee.property.name === 'action' &&
220
+ isCommandUpdateExpression(node.callee.object)
221
+ ) {
222
+ const actionHandler = node.arguments[0];
223
+ if (!actionHandler) {
224
+ return;
225
+ }
226
+
227
+ let resolvedHandler = null;
228
+
229
+ if (actionHandler.type === 'Identifier') {
230
+ handlerReferenceName = actionHandler.name;
231
+ resolvedHandler = getFunctionLikeByName(actionHandler.name);
232
+ } else if (
233
+ actionHandler.type === 'ArrowFunctionExpression' ||
234
+ actionHandler.type === 'FunctionExpression'
235
+ ) {
236
+ resolvedHandler = actionHandler;
237
+ }
238
+
239
+ if (!resolvedHandler || resolvedHandler.async !== true) {
240
+ return;
241
+ }
242
+
243
+ const resolvedBody = getFunctionBodyNode(resolvedHandler);
244
+ if (!resolvedBody) {
245
+ return;
246
+ }
247
+
248
+ updateActionHandlerLine = updateActionHandlerLine ?? lineOf(resolvedHandler);
249
+
250
+ walk(resolvedBody, (actionNode) => {
251
+ if (isMethodCall(actionNode, 'checkAndPromptUpdates')) {
252
+ relatedPromptCallLine = relatedPromptCallLine ?? lineOf(actionNode);
253
+ }
254
+
255
+ if (
256
+ actionNode.type !== 'AwaitExpression' ||
257
+ !isMethodCall(actionNode.argument, 'checkAndPromptUpdates')
258
+ ) {
259
+ return;
260
+ }
261
+
262
+ foundAwaitedInteractiveUpdateAction = true;
263
+ });
264
+ }
265
+ });
266
+
267
+ assert.equal(
268
+ foundAwaitedInteractiveUpdateAction,
269
+ true,
270
+ `update command action must await checkAndPromptUpdates(...) (action line ${updateActionHandlerLine ?? 'unknown'}, related call line ${relatedPromptCallLine ?? 'unknown'}${handlerReferenceName ? `, handler reference ${handlerReferenceName}` : ''})`
271
+ );
272
+ });
@@ -0,0 +1,79 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import {
7
+ findNearestLocalCliEntry,
8
+ resolveLocalCliCandidate,
9
+ shouldDelegateToLocalCli,
10
+ isLocalDelegationDisabled
11
+ } from '../commands/utils/LocalCliDelegation.js';
12
+
13
+ test('isLocalDelegationDisabled returns true when env flag is set', () => {
14
+ const env = { SLICE_NO_LOCAL_DELEGATION: '1' };
15
+ assert.equal(isLocalDelegationDisabled(env), true);
16
+ });
17
+
18
+ test('isLocalDelegationDisabled returns false when env flag is missing', () => {
19
+ const env = {};
20
+ assert.equal(isLocalDelegationDisabled(env), false);
21
+ });
22
+
23
+ test('shouldDelegateToLocalCli is false when candidate is null', () => {
24
+ assert.equal(shouldDelegateToLocalCli('/tmp/current/client.js', null), false);
25
+ });
26
+
27
+ test('shouldDelegateToLocalCli is false when candidate realpath equals current realpath', () => {
28
+ const same = '/tmp/current/client.js';
29
+ assert.equal(shouldDelegateToLocalCli(same, same), false);
30
+ });
31
+
32
+ test('shouldDelegateToLocalCli is true when candidate differs from current', () => {
33
+ const current = '/tmp/global/client.js';
34
+ const local = '/tmp/project/node_modules/slicejs-cli/client.js';
35
+ assert.equal(shouldDelegateToLocalCli(current, local), true);
36
+ });
37
+
38
+ test('findNearestLocalCliEntry returns null when no candidate resolver hits', () => {
39
+ const cwd = path.join('/tmp', 'slice-nonexistent-project');
40
+ const resolver = () => null;
41
+ const result = findNearestLocalCliEntry(cwd, resolver);
42
+ assert.equal(result, null);
43
+ });
44
+
45
+ test('findNearestLocalCliEntry returns first match while traversing upward', () => {
46
+ const cwd = '/repo/apps/web/src';
47
+ const calls = [];
48
+ const resolver = (dir) => {
49
+ calls.push(dir);
50
+ if (dir === '/repo/apps/web') {
51
+ return '/repo/apps/web/node_modules/slicejs-cli/client.js';
52
+ }
53
+ return null;
54
+ };
55
+
56
+ const result = findNearestLocalCliEntry(cwd, resolver);
57
+
58
+ assert.equal(result, '/repo/apps/web/node_modules/slicejs-cli/client.js');
59
+ assert.deepEqual(calls, ['/repo/apps/web/src', '/repo/apps/web']);
60
+ });
61
+
62
+ test('findNearestLocalCliEntry returns null when resolver is not a function', () => {
63
+ const result = findNearestLocalCliEntry('/repo/apps/web/src', null);
64
+ assert.equal(result, null);
65
+ });
66
+
67
+ test('resolveLocalCliCandidate returns null when candidate path is a directory', () => {
68
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'slice-cli-delegation-'));
69
+ const candidateDirectory = path.join(tempRoot, 'node_modules', 'slicejs-cli', 'client.js');
70
+
71
+ fs.mkdirSync(candidateDirectory, { recursive: true });
72
+
73
+ try {
74
+ const result = resolveLocalCliCandidate(tempRoot);
75
+ assert.equal(result, null);
76
+ } finally {
77
+ fs.rmSync(tempRoot, { recursive: true, force: true });
78
+ }
79
+ });
@@ -0,0 +1,88 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import Print from '../commands/Print.js';
4
+ import { UpdateManager } from '../commands/utils/updateManager.js';
5
+
6
+ test('notifyAvailableUpdates shows advisory output and never invokes interactive update flow', async () => {
7
+ const manager = new UpdateManager();
8
+ const calls = {
9
+ display: 0,
10
+ prompted: 0,
11
+ info: [],
12
+ error: []
13
+ };
14
+
15
+ manager.checkForUpdates = async () => ({
16
+ hasUpdates: true,
17
+ updates: [
18
+ {
19
+ name: 'slicejs-web-framework',
20
+ displayName: 'Slice.js Framework',
21
+ current: '2.4.3',
22
+ latest: '2.5.0',
23
+ type: 'framework'
24
+ }
25
+ ],
26
+ allCurrent: false
27
+ });
28
+
29
+ manager.displayUpdates = () => {
30
+ calls.display += 1;
31
+ };
32
+
33
+ manager.checkAndPromptUpdates = async () => {
34
+ calls.prompted += 1;
35
+ return true;
36
+ };
37
+
38
+ const originalInfo = Print.info;
39
+ const originalError = Print.error;
40
+
41
+ Print.info = (message) => calls.info.push(message);
42
+ Print.error = (message) => calls.error.push(message);
43
+
44
+ try {
45
+ const result = await manager.notifyAvailableUpdates();
46
+
47
+ assert.equal(result, true);
48
+ assert.equal(calls.display, 1);
49
+ assert.equal(calls.prompted, 0);
50
+ assert.equal(calls.error.length, 0);
51
+ assert.equal(calls.info.length, 1);
52
+ assert.match(calls.info[0], /slice update/);
53
+ } finally {
54
+ Print.info = originalInfo;
55
+ Print.error = originalError;
56
+ }
57
+ });
58
+
59
+ test('notifyAvailableUpdates returns false and prints nothing when no updates are available', async () => {
60
+ const manager = new UpdateManager();
61
+ const calls = {
62
+ display: 0,
63
+ info: []
64
+ };
65
+
66
+ manager.checkForUpdates = async () => ({
67
+ hasUpdates: false,
68
+ updates: [],
69
+ allCurrent: true
70
+ });
71
+
72
+ manager.displayUpdates = () => {
73
+ calls.display += 1;
74
+ };
75
+
76
+ const originalInfo = Print.info;
77
+ Print.info = (message) => calls.info.push(message);
78
+
79
+ try {
80
+ const result = await manager.notifyAvailableUpdates();
81
+
82
+ assert.equal(result, false);
83
+ assert.equal(calls.display, 0);
84
+ assert.equal(calls.info.length, 0);
85
+ } finally {
86
+ Print.info = originalInfo;
87
+ }
88
+ });