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 +33 -5
- package/client.js +35 -5
- package/commands/utils/LocalCliDelegation.js +53 -0
- package/commands/utils/updateManager.js +19 -3
- package/package.json +1 -1
- package/tests/client-launcher-contract.test.js +211 -0
- package/tests/client-update-flow-contract.test.js +272 -0
- package/tests/local-cli-delegation.test.js +79 -0
- package/tests/update-manager-notifications.test.js +88 -0
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,
|
|
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
|
|
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
|
-
#
|
|
356
|
+
# If the launcher command is unavailable, run the local CLI via npx
|
|
329
357
|
npx slicejs-cli dev
|
|
330
358
|
|
|
331
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
+
});
|