slicejs-cli 3.0.0 → 3.0.3

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.
@@ -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.3",
4
4
  "description": "Command client for developing web applications with Slice.js framework",
5
5
  "main": "client.js",
6
6
  "bin": {
@@ -204,3 +204,185 @@ test('rebalance merge preserves and merges route path metadata deterministically
204
204
  assert.equal(Object.keys(bundles).length, 2);
205
205
  assert.deepEqual(bundles.beta.paths, ['/beta', '/beta-alt', '/gamma']);
206
206
  });
207
+
208
+ test('stripImports preserves absolute imports from configured publicFolders', () => {
209
+ const generator = new BundleGenerator(import.meta.url, {
210
+ components: [],
211
+ routes: [],
212
+ metrics: {},
213
+ sliceConfig: {
214
+ publicFolders: ['/public', '/assets']
215
+ }
216
+ });
217
+
218
+ const source = "import logo from '/public/logo.js';\nimport hero from '/assets/hero.js';\nclass Demo {}\n";
219
+ const cleaned = generator.stripImports(source);
220
+
221
+ assert.match(cleaned, /import\s+logo\s+from\s+'\/public\/logo\.js';/);
222
+ assert.match(cleaned, /import\s+hero\s+from\s+'\/assets\/hero\.js';/);
223
+ });
224
+
225
+ test('stripImports removes relative imports', () => {
226
+ const generator = new BundleGenerator(import.meta.url, {
227
+ components: [],
228
+ routes: [],
229
+ metrics: {},
230
+ sliceConfig: {
231
+ publicFolders: ['/public']
232
+ }
233
+ });
234
+
235
+ const source = "import localDep from './local.js';\nimport parentDep from '../parent.js';\nclass Demo {}\n";
236
+ const cleaned = generator.stripImports(source);
237
+
238
+ assert.doesNotMatch(cleaned, /\.\/local\.js/);
239
+ assert.doesNotMatch(cleaned, /\.\.\/parent\.js/);
240
+ assert.match(cleaned, /class Demo \{\}/);
241
+ });
242
+
243
+ test('stripImports warns on bare imports', () => {
244
+ const generator = new BundleGenerator(import.meta.url, {
245
+ components: [],
246
+ routes: [],
247
+ metrics: {},
248
+ sliceConfig: {
249
+ publicFolders: ['/public']
250
+ }
251
+ });
252
+
253
+ const warnings = [];
254
+ const originalWarn = console.warn;
255
+ console.warn = (...args) => warnings.push(args.join(' '));
256
+
257
+ try {
258
+ const source = "import { html } from 'lit';\nclass Demo {}\n";
259
+ const cleaned = generator.stripImports(source);
260
+
261
+ assert.doesNotMatch(cleaned, /from\s+'lit'/);
262
+ assert.ok(warnings.some((msg) => msg.includes('bare import') && msg.includes('lit')));
263
+ } finally {
264
+ console.warn = originalWarn;
265
+ }
266
+ });
267
+
268
+ test('stripImports warns on absolute imports outside publicFolders', () => {
269
+ const generator = new BundleGenerator(import.meta.url, {
270
+ components: [],
271
+ routes: [],
272
+ metrics: {},
273
+ sliceConfig: {
274
+ publicFolders: ['/public']
275
+ }
276
+ });
277
+
278
+ const warnings = [];
279
+ const originalWarn = console.warn;
280
+ console.warn = (...args) => warnings.push(args.join(' '));
281
+
282
+ try {
283
+ const source = "import secret from '/private/secret.js';\nclass Demo {}\n";
284
+ const cleaned = generator.stripImports(source);
285
+
286
+ assert.doesNotMatch(cleaned, /\/private\/secret\.js/);
287
+ assert.ok(warnings.some((msg) => msg.includes('outside publicFolders') && msg.includes('/private/secret.js')));
288
+ } finally {
289
+ console.warn = originalWarn;
290
+ }
291
+ });
292
+
293
+ test('stripImports supports side-effect and multiline imports in fallback mode', () => {
294
+ const generator = new BundleGenerator(import.meta.url, {
295
+ components: [],
296
+ routes: [],
297
+ metrics: {},
298
+ sliceConfig: {
299
+ publicFolders: ['/public']
300
+ }
301
+ });
302
+
303
+ const warnings = [];
304
+ const originalWarn = console.warn;
305
+ console.warn = (...args) => warnings.push(args.join(' '));
306
+
307
+ const originalParse = generator.parseImportsFromCode;
308
+ generator.parseImportsFromCode = () => {
309
+ throw new Error('forced parser failure');
310
+ };
311
+
312
+ try {
313
+ const source = [
314
+ "import '/public/effects.js';",
315
+ "import '/private/effects.js';",
316
+ "import {",
317
+ ' html,',
318
+ ' css',
319
+ "} from 'lit';",
320
+ 'class Demo {}'
321
+ ].join('\n');
322
+ const cleaned = generator.stripImports(source, { sourceContext: 'DemoComponent' });
323
+
324
+ assert.match(cleaned, /import '\/public\/effects\.js';/);
325
+ assert.doesNotMatch(cleaned, /\/private\/effects\.js/);
326
+ assert.doesNotMatch(cleaned, /from 'lit'/);
327
+ assert.ok(warnings.some((msg) => msg.includes('outside publicFolders') && msg.includes('/private/effects.js') && msg.includes('[DemoComponent]')));
328
+ assert.ok(warnings.some((msg) => msg.includes('bare import') && msg.includes('lit') && msg.includes('[DemoComponent]')));
329
+ } finally {
330
+ generator.parseImportsFromCode = originalParse;
331
+ console.warn = originalWarn;
332
+ }
333
+ });
334
+
335
+ test('cleanJavaScript hoists allowed absolute imports and removes them from component code', () => {
336
+ const generator = new BundleGenerator(import.meta.url, {
337
+ components: [],
338
+ routes: [],
339
+ metrics: {},
340
+ sliceConfig: {
341
+ publicFolders: ['/public']
342
+ }
343
+ });
344
+
345
+ const source = [
346
+ "import hero from '/public/hero.js';",
347
+ 'class Demo extends HTMLElement {}',
348
+ 'customElements.define("x-demo", Demo);'
349
+ ].join('\n');
350
+
351
+ const result = generator.cleanJavaScript(source, 'Demo', 'DemoPath.js');
352
+
353
+ assert.doesNotMatch(result.code, /import hero from '\/public\/hero\.js';/);
354
+ assert.ok(result.hoistedImports.includes("import hero from '/public/hero.js';"));
355
+ });
356
+
357
+ test('formatBundleFile emits hoisted imports for framework-compatible output', () => {
358
+ const generator = new BundleGenerator(import.meta.url, {
359
+ components: [],
360
+ routes: [],
361
+ metrics: {}
362
+ });
363
+
364
+ const source = generator.formatBundleFile({
365
+ 'Framework/Structural/Bootstrap': {
366
+ name: 'Bootstrap',
367
+ category: 'Framework',
368
+ categoryType: 'Structural',
369
+ componentDependencies: [],
370
+ externalDependencies: {},
371
+ hoistedImports: ["import boot from '/public/bootstrap.js';"],
372
+ js: 'class Bootstrap extends HTMLElement {}\nreturn Bootstrap;',
373
+ html: '',
374
+ css: '',
375
+ size: 10,
376
+ isFramework: true
377
+ }
378
+ }, {
379
+ type: 'framework',
380
+ generated: new Date().toISOString(),
381
+ strategy: 'hybrid',
382
+ componentCount: 1,
383
+ totalSize: 10
384
+ });
385
+
386
+ assert.match(source, /import boot from '\/public\/bootstrap\.js';/);
387
+ assert.match(source, /const SLICE_BUNDLE_DEPENDENCIES = \{\};/);
388
+ });
@@ -139,3 +139,113 @@ test('bundle output inlines dependency modules and binds imported symbols in cla
139
139
  assert.match(source, /SLICE_BUNDLE_DEPENDENCIES\["App\/documentationRoutes\.js"\] = __sliceDepExports0;/);
140
140
  assert.match(source, /const documentationRoutes = SLICE_BUNDLE_DEPENDENCIES\["App\/documentationRoutes\.js"\]\.documentationRoutes;/);
141
141
  });
142
+
143
+ test('bundle output hoists allowed absolute imports to module top-level', () => {
144
+ const generator = new BundleGenerator(import.meta.url, {
145
+ components: [],
146
+ routes: [],
147
+ metrics: {
148
+ totalComponents: 0,
149
+ totalRoutes: 0,
150
+ sharedPercentage: 0,
151
+ totalSize: 0
152
+ }
153
+ }, { output: 'src' });
154
+
155
+ const source = generator.generateBundleFileContent(
156
+ 'slice-bundle.test.js',
157
+ 'route',
158
+ [{
159
+ name: 'HeroCard',
160
+ category: 'Visual',
161
+ categoryType: 'Visual',
162
+ dependencies: new Set(),
163
+ size: 100,
164
+ js: 'class HeroCard extends HTMLElement {}\nwindow.HeroCard = HeroCard;\nreturn HeroCard;',
165
+ html: '',
166
+ css: '',
167
+ hoistedImports: ["import hero from '/public/hero.js';"]
168
+ }],
169
+ '/test'
170
+ );
171
+
172
+ assert.match(source, /import hero from '\/public\/hero\.js';/);
173
+ assert.doesNotMatch(source, /SLICE_CLASS_FACTORY_SliceComponent_HeroCard = \(\) => \{[\s\S]*import hero from '\/public\/hero\.js';/);
174
+ });
175
+
176
+ test('generateBundleFileContent throws on hoisted import local binding collisions', () => {
177
+ const generator = new BundleGenerator(import.meta.url, {
178
+ components: [],
179
+ routes: [],
180
+ metrics: {
181
+ totalComponents: 0,
182
+ totalRoutes: 0,
183
+ sharedPercentage: 0,
184
+ totalSize: 0
185
+ }
186
+ }, { output: 'src' });
187
+
188
+ assert.throws(() => {
189
+ generator.generateBundleFileContent(
190
+ 'slice-bundle.test.js',
191
+ 'route',
192
+ [
193
+ {
194
+ name: 'CompA',
195
+ category: 'Visual',
196
+ categoryType: 'Visual',
197
+ dependencies: new Set(),
198
+ size: 100,
199
+ js: 'class CompA extends HTMLElement {}\nwindow.CompA = CompA;\nreturn CompA;',
200
+ html: '',
201
+ css: '',
202
+ hoistedImports: ["import foo from '/public/a.js';"]
203
+ },
204
+ {
205
+ name: 'CompB',
206
+ category: 'Visual',
207
+ categoryType: 'Visual',
208
+ dependencies: new Set(),
209
+ size: 100,
210
+ js: 'class CompB extends HTMLElement {}\nwindow.CompB = CompB;\nreturn CompB;',
211
+ html: '',
212
+ css: '',
213
+ hoistedImports: ["import foo from '/public/b.js';"]
214
+ }
215
+ ],
216
+ '/test'
217
+ );
218
+ }, /Hoisted import binding collision: foo/);
219
+ });
220
+
221
+ test('generateBundleFileContent throws on reserved identifier collision', () => {
222
+ const generator = new BundleGenerator(import.meta.url, {
223
+ components: [],
224
+ routes: [],
225
+ metrics: {
226
+ totalComponents: 0,
227
+ totalRoutes: 0,
228
+ sharedPercentage: 0,
229
+ totalSize: 0
230
+ }
231
+ }, { output: 'src' });
232
+
233
+ assert.throws(() => {
234
+ generator.generateBundleFileContent(
235
+ 'slice-bundle.test.js',
236
+ 'route',
237
+ [{
238
+ name: 'CompMeta',
239
+ category: 'Visual',
240
+ categoryType: 'Visual',
241
+ dependencies: new Set(),
242
+ size: 100,
243
+ js: 'class CompMeta extends HTMLElement {}\nwindow.CompMeta = CompMeta;\nreturn CompMeta;',
244
+ html: '',
245
+ css: '',
246
+ hoistedImports: ["import SLICE_BUNDLE_META from '/public/meta.js';"]
247
+ }],
248
+ '/test'
249
+ );
250
+ }, /reserved identifier collision: SLICE_BUNDLE_META/);
251
+ });
@@ -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
+ });