neutrinos-cli 2.0.0-beta.2 → 2.0.0-beta.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.
package/README.md CHANGED
@@ -40,6 +40,7 @@ neutrinos <command> [options]
40
40
  | `publish [name]` | Bundle and publish a package |
41
41
  | `deprecate [name]` | Deprecate a published package |
42
42
  | `auth` | Login or check auth state |
43
+ | `doctor` | Check workspace health (`--fix` to auto-repair) |
43
44
  | `completion <shell>` | Output shell completion script (`bash` or `zsh`) |
44
45
 
45
46
  ### `new <name>`
@@ -106,6 +107,28 @@ neutrinos deprecate [name] [-y] [--all]
106
107
 
107
108
  Marks a published package as deprecated. Prompts for confirmation unless `-y` is passed.
108
109
 
110
+ ### `doctor`
111
+
112
+ ```sh
113
+ neutrinos doctor # Run all health checks
114
+ neutrinos doctor --fix # Auto-fix what it can
115
+ ```
116
+
117
+ Runs 10 workspace health checks and reports pass/warn/fail for each:
118
+
119
+ - `node_modules` — Dependencies installed
120
+ - `plugin.json` — Exists with required fields (`name`, `components.selectorPrefix`, `modules.idPrefix`)
121
+ - `package.json` — Has `workspaces`, `type: "module"`, expected dependencies
122
+ - `tsconfig.json` — Exists and is valid (verified via `tsc --showConfig`)
123
+ - `plugins-server/` — Directory exists with `index.js`
124
+ - Package `alpha` block — Each package has `alpha.component` or `alpha.module`
125
+ - Component entry file — Component packages have a matching `<name>.ts` file
126
+ - Node version — Meets `>=22` requirement
127
+ - Auth state — Token file exists and is not expired
128
+ - Lock file sync — Lock file is up to date with `package.json`
129
+
130
+ `--fix` auto-repairs: missing `node_modules` (runs install), `plugin.json` defaults, `workspaces`/`type` in `package.json`, `plugins-server/` from template, missing `alpha` blocks, stale lock files.
131
+
109
132
  ### `auth`
110
133
 
111
134
  ```sh
@@ -27,6 +27,7 @@ import { createWorkspace } from '../commands/new-workspace.js';
27
27
  import { publish } from '../commands/publish.js';
28
28
  import { startPluginsServer } from '../commands/serve.js';
29
29
  import { completion } from '../commands/completion.js';
30
+ import { doctor } from '../commands/doctor.js';
30
31
  import { getPackages } from '../utils/get-packages.js';
31
32
  import { validateWorkspace } from '../utils/check-valid-ws.js';
32
33
  import { done, failed, inprogress, log } from '../utils/logger.js';
@@ -192,6 +193,13 @@ export const createProgram = () => {
192
193
  .action((shell) => {
193
194
  completion(program, shell);
194
195
  });
196
+ program
197
+ .command('doctor')
198
+ .description('Check workspace health')
199
+ .option('--fix', 'Auto-fix issues where possible')
200
+ .action((options) => {
201
+ doctor(cwd(), options);
202
+ });
195
203
  program
196
204
  .command('__list-packages', { hidden: true })
197
205
  .description('List workspace package names (used by shell completion)')
@@ -209,7 +217,7 @@ export const createProgram = () => {
209
217
  });
210
218
  program.hook('preAction', async (_thisCmd, actionCmd) => {
211
219
  const cmd = actionCmd.name();
212
- if (cmd === 'new' || cmd === 'login' || cmd === 'completion' || cmd === '__list-packages') {
220
+ if (cmd === 'new' || cmd === 'login' || cmd === 'completion' || cmd === '__list-packages' || cmd === 'doctor') {
213
221
  return;
214
222
  }
215
223
  if (!validateWorkspace(cwd())) {
@@ -0,0 +1,69 @@
1
+ import { bold, greenBright, red, yellowBright } from 'colorette';
2
+ import { log as _log } from 'node:console';
3
+ import { checks } from '../utils/doctor-checks.js';
4
+ const SYMBOLS = {
5
+ pass: greenBright('✔'),
6
+ warn: yellowBright('⚠'),
7
+ fail: red('✖'),
8
+ };
9
+ const colorize = {
10
+ pass: greenBright,
11
+ warn: yellowBright,
12
+ fail: red,
13
+ };
14
+ function printResult(result) {
15
+ const sym = SYMBOLS[result.status];
16
+ const color = colorize[result.status];
17
+ _log(` ${sym} ${color(`${result.name} — ${result.message}`)}`);
18
+ }
19
+ function printFixedResult(before, after) {
20
+ _log(` ${red('✖')} → ${greenBright('✔')} ${greenBright(`${after.name} — Fixed: ${after.message}`)}`);
21
+ }
22
+ export function doctor(wsPath, opts) {
23
+ _log('\nWorkspace Health Check');
24
+ _log('──────────────────────\n');
25
+ let results = [];
26
+ for (const check of checks) {
27
+ const checkResults = check.run(wsPath);
28
+ results.push(...checkResults);
29
+ }
30
+ for (const r of results) {
31
+ printResult(r);
32
+ }
33
+ if (opts.fix) {
34
+ const fixableFailures = results.filter((r) => r.status === 'fail' && r.fixable);
35
+ if (fixableFailures.length > 0) {
36
+ _log('');
37
+ for (const check of checks) {
38
+ if (!check.fix)
39
+ continue;
40
+ const checkResults = check.run(wsPath);
41
+ const hasFixableFailure = checkResults.some((r) => r.status === 'fail' && r.fixable);
42
+ if (!hasFixableFailure)
43
+ continue;
44
+ check.fix(wsPath);
45
+ const afterResults = check.run(wsPath);
46
+ for (let i = 0; i < checkResults.length; i++) {
47
+ const before = checkResults[i];
48
+ const after = afterResults[i];
49
+ if (before.status === 'fail' && after && after.status === 'pass') {
50
+ printFixedResult(before, after);
51
+ const idx = results.findIndex((r) => r.name === before.name && r.status === 'fail');
52
+ if (idx !== -1) {
53
+ results[idx] = after;
54
+ }
55
+ }
56
+ }
57
+ }
58
+ }
59
+ }
60
+ const passed = results.filter((r) => r.status === 'pass').length;
61
+ const warned = results.filter((r) => r.status === 'warn').length;
62
+ const failed = results.filter((r) => r.status === 'fail').length;
63
+ _log('\n──────────────────────');
64
+ _log(bold(`Summary: ${greenBright(`${passed} passed`)}, ${yellowBright(`${warned} warnings`)}, ${red(`${failed} failed`)}`));
65
+ _log('');
66
+ if (failed > 0) {
67
+ process.exit(1);
68
+ }
69
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,357 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { cpSync, existsSync, globSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
+ import { basename, join } from 'node:path';
4
+ import { authConfigJson, pluginJsonPath, pluginServerRoot, pluginServerTemplatesPath } from './path-utils.js';
5
+ import { getGeneratedComponentName } from './path-utils.js';
6
+ // ── Check 0: node_modules ──────────────────────────────────────────────
7
+ const nodeModulesCheck = {
8
+ name: 'node_modules',
9
+ run(wsPath) {
10
+ if (!existsSync(join(wsPath, 'node_modules'))) {
11
+ return [{ name: 'node_modules', status: 'fail', message: 'Not installed. Run "npm install" or "pnpm install"', fixable: true }];
12
+ }
13
+ return [{ name: 'node_modules', status: 'pass', message: 'Dependencies installed', fixable: false }];
14
+ },
15
+ fix(wsPath) {
16
+ const pnpmLock = join(wsPath, 'pnpm-lock.yaml');
17
+ const manager = existsSync(pnpmLock) ? 'pnpm' : 'npm';
18
+ execSync(`${manager} install`, { cwd: wsPath, stdio: 'inherit' });
19
+ },
20
+ };
21
+ // ── Check 1: plugin.json ───────────────────────────────────────────────
22
+ const pluginJsonCheck = {
23
+ name: 'plugin.json',
24
+ run(wsPath) {
25
+ const path = pluginJsonPath(wsPath);
26
+ if (!existsSync(path)) {
27
+ return [{ name: 'plugin.json', status: 'fail', message: 'File is missing', fixable: true }];
28
+ }
29
+ let json;
30
+ try {
31
+ json = JSON.parse(readFileSync(path, 'utf-8'));
32
+ }
33
+ catch {
34
+ return [{ name: 'plugin.json', status: 'fail', message: 'File is not valid JSON', fixable: false }];
35
+ }
36
+ const missing = [];
37
+ if (!json.name)
38
+ missing.push('name');
39
+ if (!json.components?.selectorPrefix)
40
+ missing.push('components.selectorPrefix');
41
+ if (!json.modules?.idPrefix)
42
+ missing.push('modules.idPrefix');
43
+ if (missing.length > 0) {
44
+ return [{ name: 'plugin.json', status: 'fail', message: `Missing required fields: ${missing.join(', ')}`, fixable: true }];
45
+ }
46
+ return [{ name: 'plugin.json', status: 'pass', message: 'All required fields present', fixable: false }];
47
+ },
48
+ fix(wsPath) {
49
+ const path = pluginJsonPath(wsPath);
50
+ const defaults = {
51
+ name: basename(wsPath),
52
+ components: { selectorPrefix: 'comp' },
53
+ modules: { idPrefix: 'mod' },
54
+ };
55
+ if (!existsSync(path)) {
56
+ writeFileSync(path, JSON.stringify(defaults, null, 4) + '\n');
57
+ return;
58
+ }
59
+ let existing = {};
60
+ try {
61
+ existing = JSON.parse(readFileSync(path, 'utf-8'));
62
+ }
63
+ catch {
64
+ writeFileSync(path, JSON.stringify(defaults, null, 4) + '\n');
65
+ return;
66
+ }
67
+ existing.name ??= defaults.name;
68
+ existing.components ??= {};
69
+ existing.components.selectorPrefix ??= defaults.components.selectorPrefix;
70
+ existing.modules ??= {};
71
+ existing.modules.idPrefix ??= defaults.modules.idPrefix;
72
+ writeFileSync(path, JSON.stringify(existing, null, 4) + '\n');
73
+ },
74
+ };
75
+ // ── Check 2: Root package.json ─────────────────────────────────────────
76
+ const rootPackageJsonCheck = {
77
+ name: 'Root package.json',
78
+ run(wsPath) {
79
+ const path = join(wsPath, 'package.json');
80
+ if (!existsSync(path)) {
81
+ return [{ name: 'Root package.json', status: 'fail', message: 'File is missing', fixable: false }];
82
+ }
83
+ let json;
84
+ try {
85
+ json = JSON.parse(readFileSync(path, 'utf-8'));
86
+ }
87
+ catch {
88
+ return [{ name: 'Root package.json', status: 'fail', message: 'File is not valid JSON', fixable: false }];
89
+ }
90
+ const issues = [];
91
+ if (!json['workspaces'])
92
+ issues.push('workspaces');
93
+ if (json['type'] !== 'module')
94
+ issues.push('type: "module"');
95
+ const deps = (json['dependencies'] ?? {});
96
+ if (!deps['@jatahworx/alpha-annotations-lib'])
97
+ issues.push('dependency @jatahworx/alpha-annotations-lib');
98
+ const devDeps = (json['devDependencies'] ?? {});
99
+ if (!devDeps['lit'])
100
+ issues.push('devDependency lit');
101
+ if (!devDeps['typescript'])
102
+ issues.push('devDependency typescript');
103
+ if (issues.length > 0) {
104
+ const fixable = issues.includes('workspaces') || issues.includes('type: "module"');
105
+ return [{ name: 'Root package.json', status: 'fail', message: `Missing: ${issues.join(', ')}`, fixable }];
106
+ }
107
+ return [{ name: 'Root package.json', status: 'pass', message: 'workspaces, dependencies, and type configured', fixable: false }];
108
+ },
109
+ fix(wsPath) {
110
+ const path = join(wsPath, 'package.json');
111
+ if (!existsSync(path))
112
+ return;
113
+ let json;
114
+ try {
115
+ json = JSON.parse(readFileSync(path, 'utf-8'));
116
+ }
117
+ catch {
118
+ return;
119
+ }
120
+ json['workspaces'] ??= ['packages/*'];
121
+ json['type'] ??= 'module';
122
+ writeFileSync(path, JSON.stringify(json, null, 4) + '\n');
123
+ },
124
+ };
125
+ // ── Check 3: tsconfig.json ─────────────────────────────────────────────
126
+ const tsconfigCheck = {
127
+ name: 'tsconfig.json',
128
+ run(wsPath) {
129
+ const path = join(wsPath, 'tsconfig.json');
130
+ if (!existsSync(path)) {
131
+ return [{ name: 'tsconfig.json', status: 'fail', message: 'File is missing. Create a tsconfig.json', fixable: false }];
132
+ }
133
+ try {
134
+ execSync('npx tsc --showConfig', { cwd: wsPath, stdio: 'pipe', timeout: 15_000 });
135
+ }
136
+ catch (err) {
137
+ const stderr = err instanceof Error && 'stderr' in err
138
+ ? String(err.stderr).trim()
139
+ : '';
140
+ if (stderr.includes('ENOENT') || stderr.includes('not found') || !stderr) {
141
+ return [{ name: 'tsconfig.json', status: 'warn', message: 'File exists but could not run tsc to validate. Ensure typescript is installed', fixable: false }];
142
+ }
143
+ return [{ name: 'tsconfig.json', status: 'fail', message: `Invalid config: ${stderr}`, fixable: false }];
144
+ }
145
+ return [{ name: 'tsconfig.json', status: 'pass', message: 'Valid configuration', fixable: false }];
146
+ },
147
+ };
148
+ // ── Check 4: plugins-server/ ───────────────────────────────────────────
149
+ const pluginsServerCheck = {
150
+ name: 'plugins-server/',
151
+ run(wsPath) {
152
+ const serverDir = pluginServerRoot(wsPath);
153
+ if (!existsSync(serverDir)) {
154
+ return [{ name: 'plugins-server/', status: 'fail', message: 'Directory is missing', fixable: true }];
155
+ }
156
+ if (!existsSync(join(serverDir, 'index.js'))) {
157
+ return [{ name: 'plugins-server/', status: 'fail', message: 'Directory exists but index.js is missing', fixable: true }];
158
+ }
159
+ return [{ name: 'plugins-server/', status: 'pass', message: 'Directory exists with index.js', fixable: false }];
160
+ },
161
+ fix(wsPath) {
162
+ const serverDir = pluginServerRoot(wsPath);
163
+ const templateDir = pluginServerTemplatesPath();
164
+ cpSync(templateDir, serverDir, { recursive: true });
165
+ },
166
+ };
167
+ // ── Check 5: Package alpha block ───────────────────────────────────────
168
+ function getPackagesSafe(wsPath) {
169
+ try {
170
+ const packageJsonContent = readFileSync(join(wsPath, 'package.json'), 'utf-8');
171
+ const parsed = JSON.parse(packageJsonContent);
172
+ const packagesDir = parsed.workspaces ?? [];
173
+ const packageNames = globSync(packagesDir, { cwd: wsPath });
174
+ return packageNames
175
+ .map((name) => join(wsPath, name))
176
+ .filter((p) => existsSync(join(p, 'package.json')));
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ }
182
+ const packageAlphaBlockCheck = {
183
+ name: 'Package alpha block',
184
+ run(wsPath) {
185
+ const packages = getPackagesSafe(wsPath);
186
+ if (packages === null) {
187
+ return [{ name: 'Package alpha block', status: 'fail', message: 'Could not discover packages (missing package.json or workspaces)', fixable: false }];
188
+ }
189
+ if (packages.length === 0) {
190
+ return [{ name: 'Package alpha block', status: 'pass', message: 'No packages found (nothing to check)', fixable: false }];
191
+ }
192
+ const results = [];
193
+ for (const pkgPath of packages) {
194
+ const pkgJsonPath = join(pkgPath, 'package.json');
195
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
196
+ const pkgName = pkgJson.name || basename(pkgPath);
197
+ if (!pkgJson.alpha) {
198
+ results.push({
199
+ name: `Package "${pkgName}" alpha block`,
200
+ status: 'fail',
201
+ message: 'No alpha block found. Add alpha.component or alpha.module',
202
+ fixable: true,
203
+ });
204
+ }
205
+ else if (!pkgJson.alpha.component && !pkgJson.alpha.module) {
206
+ results.push({
207
+ name: `Package "${pkgName}" alpha block`,
208
+ status: 'warn',
209
+ message: 'alpha block exists but neither component nor module is true',
210
+ fixable: true,
211
+ });
212
+ }
213
+ else {
214
+ const type = pkgJson.alpha.component ? 'alpha.component' : 'alpha.module';
215
+ results.push({
216
+ name: `Package "${pkgName}" alpha block`,
217
+ status: 'pass',
218
+ message: `${type} is set`,
219
+ fixable: false,
220
+ });
221
+ }
222
+ }
223
+ return results;
224
+ },
225
+ fix(wsPath) {
226
+ const packages = getPackagesSafe(wsPath);
227
+ if (!packages)
228
+ return;
229
+ for (const pkgPath of packages) {
230
+ const pkgJsonPath = join(pkgPath, 'package.json');
231
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
232
+ const alpha = (pkgJson['alpha'] ?? {});
233
+ if (!alpha['component'] && !alpha['module']) {
234
+ alpha['component'] = true;
235
+ }
236
+ pkgJson['alpha'] = alpha;
237
+ writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 4) + '\n');
238
+ }
239
+ },
240
+ };
241
+ // ── Check 6: Component entry file ──────────────────────────────────────
242
+ const componentEntryFileCheck = {
243
+ name: 'Component entry file',
244
+ run(wsPath) {
245
+ const packages = getPackagesSafe(wsPath);
246
+ if (packages === null) {
247
+ return [{ name: 'Component entry file', status: 'fail', message: 'Could not discover packages', fixable: false }];
248
+ }
249
+ const results = [];
250
+ for (const pkgPath of packages) {
251
+ const pkgJsonPath = join(pkgPath, 'package.json');
252
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
253
+ if (!pkgJson.alpha?.component)
254
+ continue;
255
+ const pkgName = pkgJson.name || basename(pkgPath);
256
+ const entryName = getGeneratedComponentName(basename(pkgPath));
257
+ const entryFile = join(pkgPath, `${entryName}.ts`);
258
+ if (!existsSync(entryFile)) {
259
+ results.push({
260
+ name: `Package "${pkgName}" entry file`,
261
+ status: 'fail',
262
+ message: `${entryName}.ts is missing`,
263
+ fixable: false,
264
+ });
265
+ }
266
+ else {
267
+ results.push({
268
+ name: `Package "${pkgName}" entry file`,
269
+ status: 'pass',
270
+ message: `${entryName}.ts exists`,
271
+ fixable: false,
272
+ });
273
+ }
274
+ }
275
+ return results;
276
+ },
277
+ };
278
+ // ── Check 7: Node version ──────────────────────────────────────────────
279
+ const nodeVersionCheck = {
280
+ name: 'Node version',
281
+ run() {
282
+ const [major] = process.versions.node.split('.').map(Number);
283
+ if (major < 22) {
284
+ return [{ name: 'Node version', status: 'fail', message: `v${process.versions.node} does not meet requirement (>=22). Install Node.js 22+`, fixable: false }];
285
+ }
286
+ return [{ name: 'Node version', status: 'pass', message: `v${process.versions.node} meets requirement (>=22)`, fixable: false }];
287
+ },
288
+ };
289
+ // ── Check 8: Auth state ────────────────────────────────────────────────
290
+ const authStateCheck = {
291
+ name: 'Auth state',
292
+ run() {
293
+ const authPath = authConfigJson();
294
+ if (!existsSync(authPath)) {
295
+ return [{ name: 'Auth state', status: 'fail', message: `${authPath} not found. Run "neutrinos auth login"`, fixable: false }];
296
+ }
297
+ try {
298
+ const tokenSet = JSON.parse(readFileSync(authPath, 'utf-8'));
299
+ if (tokenSet.expires_at && tokenSet.expires_at < Date.now() / 1000) {
300
+ return [{ name: 'Auth state', status: 'warn', message: 'Token expired. Run "neutrinos auth login"', fixable: false }];
301
+ }
302
+ }
303
+ catch {
304
+ return [{ name: 'Auth state', status: 'warn', message: 'Could not parse auth.json', fixable: false }];
305
+ }
306
+ return [{ name: 'Auth state', status: 'pass', message: 'Token present and valid', fixable: false }];
307
+ },
308
+ };
309
+ // ── Check 9: Lock file sync ───────────────────────────────────────────
310
+ const lockFileSyncCheck = {
311
+ name: 'Lock file sync',
312
+ run(wsPath) {
313
+ const packageJsonPath = join(wsPath, 'package.json');
314
+ if (!existsSync(packageJsonPath)) {
315
+ return [{ name: 'Lock file sync', status: 'fail', message: 'No package.json to compare against', fixable: false }];
316
+ }
317
+ const pnpmLock = join(wsPath, 'pnpm-lock.yaml');
318
+ const npmLock = join(wsPath, 'package-lock.json');
319
+ let lockPath = null;
320
+ let manager = 'npm';
321
+ if (existsSync(pnpmLock)) {
322
+ lockPath = pnpmLock;
323
+ manager = 'pnpm';
324
+ }
325
+ else if (existsSync(npmLock)) {
326
+ lockPath = npmLock;
327
+ manager = 'npm';
328
+ }
329
+ if (!lockPath) {
330
+ return [{ name: 'Lock file sync', status: 'fail', message: 'No lock file found. Run "npm install" or "pnpm install"', fixable: true }];
331
+ }
332
+ const lockMtime = statSync(lockPath).mtimeMs;
333
+ const pkgMtime = statSync(packageJsonPath).mtimeMs;
334
+ if (pkgMtime > lockMtime) {
335
+ return [{ name: 'Lock file sync', status: 'warn', message: `package.json is newer than lock file. Run "${manager} install"`, fixable: true }];
336
+ }
337
+ return [{ name: 'Lock file sync', status: 'pass', message: 'Lock file is up to date', fixable: false }];
338
+ },
339
+ fix(wsPath) {
340
+ const pnpmLock = join(wsPath, 'pnpm-lock.yaml');
341
+ const manager = existsSync(pnpmLock) ? 'pnpm' : 'npm';
342
+ execSync(`${manager} install`, { cwd: wsPath, stdio: 'inherit' });
343
+ },
344
+ };
345
+ // ── Export all checks ──────────────────────────────────────────────────
346
+ export const checks = [
347
+ nodeModulesCheck,
348
+ pluginJsonCheck,
349
+ rootPackageJsonCheck,
350
+ tsconfigCheck,
351
+ pluginsServerCheck,
352
+ packageAlphaBlockCheck,
353
+ componentEntryFileCheck,
354
+ nodeVersionCheck,
355
+ authStateCheck,
356
+ lockFileSyncCheck,
357
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neutrinos-cli",
3
- "version": "2.0.0-beta.2",
3
+ "version": "2.0.0-beta.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "neutrinos": "./dist/src/bin/cli.js"