@webmate-studio/cli 0.3.61 → 0.4.0

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.
@@ -0,0 +1,1039 @@
1
+ /**
2
+ * `wm core` — SuperAdmin-only commands for authoring global Core Components.
3
+ *
4
+ * Subcommands:
5
+ * wm core ls list all Core components
6
+ * wm core clone <name> pull source.json into the local directory
7
+ * wm core clone (no arg) clone every Core component into a workspace
8
+ * wm core init [dir] register a new Core record from local component.json
9
+ * wm core init-all [dir] bulk: init + push every subfolder of dir/components
10
+ * wm core attach <name> bind an existing (UI-created) server record to the local folder
11
+ * wm core push [dir] build + push a new version (or bulk from workspace root)
12
+ * wm core status [name] workspace overview or single-component info
13
+ *
14
+ * Targets /api/admin/core-components/... — auth is the same JWT/wms_-token
15
+ * flow as the org commands. The server rejects non-SuperAdmin callers, so
16
+ * non-platform users never accidentally hit a publish path.
17
+ */
18
+
19
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs';
20
+ import { basename, dirname, join, resolve } from 'path';
21
+ import { confirm } from '@inquirer/prompts';
22
+ import ora from 'ora';
23
+ import pc from 'picocolors';
24
+ import { logger } from '@webmate-studio/core';
25
+ import { apiFetch, ApiError } from '../utils/api-client.js';
26
+ import { readMeta, writeMeta } from '../utils/webmate-meta.js';
27
+ import {
28
+ readComponentFiles,
29
+ writeComponentFiles,
30
+ computeFileHashesFromMap,
31
+ diffHashes,
32
+ getComponentId
33
+ } from '../utils/component-files.js';
34
+ import { autoSnapshot } from '../utils/git-snapshot.js';
35
+
36
+ function resolveDir(arg) {
37
+ return arg ? resolve(process.cwd(), arg) : process.cwd();
38
+ }
39
+
40
+ function slugify(value) {
41
+ return (value || '')
42
+ .toString()
43
+ .toLowerCase()
44
+ .replace(/[^a-z0-9]+/g, '-')
45
+ .replace(/^-+|-+$/g, '');
46
+ }
47
+
48
+ const DEFAULT_WM_CONFIG = `export default {
49
+ components: {
50
+ path: './components',
51
+ styles: ['./tokens/tokens.css', './styles/base.css'],
52
+ fonts: [],
53
+ islands: {
54
+ path: './islands',
55
+ framework: 'lit'
56
+ }
57
+ },
58
+ preview: {
59
+ port: 5173,
60
+ theme: 'light',
61
+ viewport: { width: 1440, height: 900 }
62
+ }
63
+ };
64
+ `;
65
+
66
+ /**
67
+ * Walk up from `start` toward the filesystem root looking for a
68
+ * `.webmate/config.json` that carries `coreWorkspace: true`. Used by
69
+ * `wm core push` / `wm core status` (no-arg) to detect that we are
70
+ * inside a bulk Core workspace and should iterate over every
71
+ * components/* subfolder instead of treating cwd as the component root.
72
+ */
73
+ function findEnclosingCoreWorkspace(start) {
74
+ let dir = start;
75
+ while (true) {
76
+ const cfg = join(dir, '.webmate', 'config.json');
77
+ if (existsSync(cfg)) {
78
+ try {
79
+ const data = JSON.parse(readFileSync(cfg, 'utf-8'));
80
+ if (data && data.coreWorkspace === true) {
81
+ return { root: dir, config: data };
82
+ }
83
+ } catch {
84
+ /* malformed config — keep walking */
85
+ }
86
+ }
87
+ const parent = dirname(dir);
88
+ if (parent === dir) return null;
89
+ dir = parent;
90
+ }
91
+ }
92
+
93
+ function listComponentSubfolders(workspaceRoot) {
94
+ const componentsDir = join(workspaceRoot, 'components');
95
+ if (!existsSync(componentsDir)) return [];
96
+ return readdirSync(componentsDir, { withFileTypes: true })
97
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
98
+ .map((d) => ({
99
+ name: d.name,
100
+ absPath: join(componentsDir, d.name)
101
+ }))
102
+ .filter((entry) => existsSync(join(entry.absPath, 'component.json')));
103
+ }
104
+
105
+ async function findCoreByName(name) {
106
+ const list = await apiFetch('/api/admin/core-components');
107
+ const components = list?.components ?? [];
108
+ return components.find((c) => c.name === name) ?? null;
109
+ }
110
+
111
+ /**
112
+ * Rewrite the local component.json's `id` (and optionally `name`) so it
113
+ * matches the server-assigned Core record. Without this the local source
114
+ * keeps its old UUID, which then disagrees with what the server stores
115
+ * and what `wm core clone` patches back on download. Called by both
116
+ * `init` and `attach`.
117
+ */
118
+ function syncComponentJsonId(rootDir, { id, name }) {
119
+ const componentJsonPath = join(rootDir, 'component.json');
120
+ if (!existsSync(componentJsonPath)) return false;
121
+ let data;
122
+ try {
123
+ data = JSON.parse(readFileSync(componentJsonPath, 'utf-8'));
124
+ } catch {
125
+ return false;
126
+ }
127
+ let changed = false;
128
+ if (id && data.id !== id) {
129
+ data.id = id;
130
+ changed = true;
131
+ }
132
+ if (name && data.name !== name) {
133
+ data.name = name;
134
+ changed = true;
135
+ }
136
+ if (changed) {
137
+ writeFileSync(componentJsonPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
138
+ }
139
+ return changed;
140
+ }
141
+
142
+ // ────────────────────────────────────────────────────────────────────────────
143
+ // wm core ls
144
+ // ────────────────────────────────────────────────────────────────────────────
145
+
146
+ export async function coreListCommand() {
147
+ let list;
148
+ try {
149
+ list = await apiFetch('/api/admin/core-components');
150
+ } catch (err) {
151
+ if (err instanceof ApiError && err.status === 403) {
152
+ logger.error('SuperAdmin permissions required. Are you logged in as a platform owner?');
153
+ process.exit(1);
154
+ }
155
+ logger.error(err.message);
156
+ process.exit(1);
157
+ }
158
+
159
+ const components = list?.components ?? [];
160
+ if (components.length === 0) {
161
+ logger.info('No Core components registered yet.');
162
+ return;
163
+ }
164
+
165
+ console.log();
166
+ console.log(pc.bold(`Core components (${components.length}):`));
167
+ console.log();
168
+ for (const c of components) {
169
+ const featured = c.featured ? pc.yellow(' ★') : '';
170
+ const inactive = c.active ? '' : pc.dim(' [inactive]');
171
+ const usage = c.usageCount > 0 ? pc.dim(` · used by ${c.usageCount}`) : '';
172
+ console.log(
173
+ ` ${pc.cyan(c.name.padEnd(28))} ${pc.dim('v' + (c.currentVersion || '—').padEnd(8))} ` +
174
+ `${(c.category || '—').padEnd(14)} ${c.displayName}${featured}${inactive}${usage}`
175
+ );
176
+ }
177
+ console.log();
178
+ }
179
+
180
+ // ────────────────────────────────────────────────────────────────────────────
181
+ // wm core clone <name>
182
+ // ────────────────────────────────────────────────────────────────────────────
183
+
184
+ export async function coreCloneCommand(name, options = {}) {
185
+ if (!name) {
186
+ // No name → workspace mode: clone every Core component into a workspace
187
+ // skeleton, parallel to `wm clone <project>` for org repos.
188
+ return coreCloneWorkspaceCommand(options);
189
+ }
190
+
191
+ const targetDir = resolveDir(options.to);
192
+
193
+ if (existsSync(targetDir) && readdirSync(targetDir).length > 0 && !options.force) {
194
+ const proceed = await confirm({
195
+ message: `Target ${pc.cyan(targetDir)} is not empty. Continue and overwrite?`,
196
+ default: false
197
+ });
198
+ if (!proceed) {
199
+ logger.info('Clone cancelled.');
200
+ return;
201
+ }
202
+ }
203
+
204
+ const lookup = ora(`Resolving "${name}"…`).start();
205
+ let core;
206
+ try {
207
+ core = await findCoreByName(name);
208
+ } catch (err) {
209
+ lookup.fail(err.message);
210
+ process.exit(1);
211
+ }
212
+ if (!core) {
213
+ lookup.fail(`No Core component named "${name}" found.`);
214
+ process.exit(1);
215
+ }
216
+ lookup.succeed(`Found ${pc.cyan(core.name)} ${pc.dim('(' + core.id + ')')}`);
217
+
218
+ const downloadSpinner = ora('Downloading source…').start();
219
+ let payload;
220
+ try {
221
+ payload = await apiFetch(`/api/admin/core-components/${core.id}/files`);
222
+ } catch (err) {
223
+ if (err instanceof ApiError && err.status === 409 && err.body?.legacy) {
224
+ downloadSpinner.fail('Legacy ZIP-source — clone from webmate-premium-components and re-push.');
225
+ } else if (err instanceof ApiError && err.status === 404) {
226
+ downloadSpinner.fail('No published version yet. Use `wm core init` from a local copy and push first.');
227
+ } else {
228
+ downloadSpinner.fail(err.message);
229
+ }
230
+ process.exit(1);
231
+ }
232
+
233
+ const files = payload?.files ?? {};
234
+ if (Object.keys(files).length === 0) {
235
+ downloadSpinner.fail('Server returned no files.');
236
+ process.exit(1);
237
+ }
238
+ downloadSpinner.succeed(`Downloaded ${Object.keys(files).length} file(s) (v${payload.version})`);
239
+
240
+ writeComponentFiles(targetDir, files, { clean: !options.merge });
241
+ const fileHashes = computeFileHashesFromMap(files);
242
+
243
+ writeMeta(targetDir, {
244
+ componentId: core.id,
245
+ // Pin to the version we actually pulled so the gallery shows the
246
+ // component as in-sync, not "Neu"/never-pushed.
247
+ baseVersion: payload.versionId ?? null,
248
+ version: payload.version,
249
+ pulledAt: new Date().toISOString(),
250
+ fileHashes
251
+ });
252
+
253
+ logger.success(`Cloned ${pc.cyan(core.name)} into ${pc.dim(targetDir)}`);
254
+ console.log(` Files: ${pc.dim(Object.keys(files).length + ' file(s)')}`);
255
+ console.log(` Edit, then run ${pc.bold('wm core push')} from this directory.`);
256
+ }
257
+
258
+ /**
259
+ * wm core clone (no arg) — clones every Core component into a fresh
260
+ * workspace folder. Mirrors `wm clone <project>` for org repos.
261
+ */
262
+ async function coreCloneWorkspaceCommand(options = {}) {
263
+ const dirName = options.to || 'webmate-core';
264
+ const targetDir = resolve(process.cwd(), dirName);
265
+
266
+ if (existsSync(targetDir) && readdirSync(targetDir).length > 0 && !options.force) {
267
+ const proceed = await confirm({
268
+ message: `Target ${pc.cyan(targetDir)} is not empty. Continue?`,
269
+ default: false
270
+ });
271
+ if (!proceed) {
272
+ logger.info('Clone cancelled.');
273
+ return;
274
+ }
275
+ }
276
+
277
+ const listSpinner = ora('Listing Core components…').start();
278
+ let components;
279
+ try {
280
+ const res = await apiFetch('/api/admin/core-components');
281
+ components = Array.isArray(res?.components) ? res.components : [];
282
+ listSpinner.succeed(`Found ${pc.cyan(components.length)} Core component(s)`);
283
+ } catch (err) {
284
+ if (err instanceof ApiError && err.status === 403) {
285
+ listSpinner.fail('SuperAdmin permissions required.');
286
+ } else {
287
+ listSpinner.fail(err.message);
288
+ }
289
+ process.exit(1);
290
+ }
291
+
292
+ // Workspace skeleton — mirrors cloneProjectCommand layout so `wm dev`
293
+ // works identically. The `coreWorkspace: true` flag in
294
+ // .webmate/config.json is the marker used by findEnclosingCoreWorkspace.
295
+ mkdirSync(targetDir, { recursive: true });
296
+ mkdirSync(join(targetDir, 'components'), { recursive: true });
297
+
298
+ const configPath = join(targetDir, 'wm.config.js');
299
+ if (!existsSync(configPath)) {
300
+ writeFileSync(configPath, DEFAULT_WM_CONFIG, 'utf-8');
301
+ }
302
+
303
+ const dotWebmateDir = join(targetDir, '.webmate');
304
+ mkdirSync(dotWebmateDir, { recursive: true });
305
+ writeFileSync(
306
+ join(dotWebmateDir, 'config.json'),
307
+ JSON.stringify(
308
+ {
309
+ coreWorkspace: true,
310
+ componentsPath: './components',
311
+ clonedAt: new Date().toISOString()
312
+ },
313
+ null,
314
+ 2
315
+ ) + '\n',
316
+ 'utf-8'
317
+ );
318
+
319
+ logger.success(`Workspace scaffolded at ${pc.dim(targetDir)}`);
320
+
321
+ let pulled = 0;
322
+ let skipped = 0;
323
+
324
+ for (const comp of components) {
325
+ const compSlug = slugify(comp.name) || comp.id.slice(0, 8);
326
+ const compDir = join(targetDir, 'components', compSlug);
327
+
328
+ const pullSpinner = ora(`Pulling ${comp.name}…`).start();
329
+ try {
330
+ const payload = await apiFetch(`/api/admin/core-components/${comp.id}/files`);
331
+ const files = payload?.files ?? {};
332
+ if (!Object.keys(files).length) {
333
+ pullSpinner.warn(`${comp.name}: empty source bundle — skipped`);
334
+ skipped++;
335
+ continue;
336
+ }
337
+ mkdirSync(compDir, { recursive: true });
338
+ writeComponentFiles(compDir, files, { clean: true });
339
+ const fileHashes = computeFileHashesFromMap(files);
340
+ writeMeta(compDir, {
341
+ componentId: comp.id,
342
+ // Pin the version we pulled so the gallery sees this as in-sync.
343
+ baseVersion: payload.versionId ?? null,
344
+ version: payload.version,
345
+ pulledAt: new Date().toISOString(),
346
+ fileHashes
347
+ });
348
+ pullSpinner.succeed(`${comp.name} ${pc.dim('v' + payload.version)}`);
349
+ pulled++;
350
+ } catch (err) {
351
+ if (err instanceof ApiError && err.status === 409 && err.body?.legacy) {
352
+ pullSpinner.warn(`${comp.name}: legacy ZIP-source — skipped, clone manually`);
353
+ } else if (err instanceof ApiError && err.status === 404) {
354
+ pullSpinner.warn(`${comp.name}: no published version — skipped`);
355
+ } else if (err instanceof ApiError) {
356
+ pullSpinner.fail(`${comp.name}: HTTP ${err.status} — ${err.message}`);
357
+ } else {
358
+ pullSpinner.fail(`${comp.name}: ${err.message}`);
359
+ }
360
+ skipped++;
361
+ }
362
+ }
363
+
364
+ console.log();
365
+ logger.success(`Cloned Core library — ${pulled} pulled, ${skipped} skipped`);
366
+ console.log();
367
+ console.log(pc.bold('Next steps:'));
368
+ console.log(` ${pc.cyan('cd ' + dirName)}`);
369
+ console.log(` ${pc.cyan('wm dev')} ${pc.dim('# see all Core components in the preview gallery')}`);
370
+ console.log(` ${pc.cyan('wm core push')} ${pc.dim('# push every component that has local changes')}`);
371
+ }
372
+
373
+ // ────────────────────────────────────────────────────────────────────────────
374
+ // wm core init [dir]
375
+ // ────────────────────────────────────────────────────────────────────────────
376
+
377
+ export async function coreInitCommand(dirArg, options = {}) {
378
+ const rootDir = resolveDir(dirArg);
379
+ if (!existsSync(rootDir)) {
380
+ logger.error(`Directory not found: ${rootDir}`);
381
+ process.exit(1);
382
+ }
383
+
384
+ let componentInfo;
385
+ try {
386
+ componentInfo = getComponentId(rootDir);
387
+ } catch (err) {
388
+ logger.error(err.message);
389
+ process.exit(1);
390
+ }
391
+
392
+ // Use the folder name as the technical Core name unless component.json names one.
393
+ const techName = (componentInfo.raw?.name || basename(rootDir))
394
+ .toString()
395
+ .toLowerCase()
396
+ .replace(/[^a-z0-9]+/g, '-')
397
+ .replace(/^-+|-+$/g, '');
398
+
399
+ const displayName = options.displayName || componentInfo.raw?.displayName || techName;
400
+ const category = options.category || componentInfo.raw?.category || null;
401
+ const description = options.description || componentInfo.raw?.description || null;
402
+
403
+ const spinner = ora(`Registering ${pc.cyan(techName)} on the server…`).start();
404
+ let created;
405
+ try {
406
+ created = await apiFetch('/api/admin/core-components', {
407
+ method: 'POST',
408
+ body: {
409
+ name: techName,
410
+ displayName,
411
+ category,
412
+ description
413
+ }
414
+ });
415
+ } catch (err) {
416
+ if (err instanceof ApiError && err.status === 409) {
417
+ spinner.fail(
418
+ `A Core component named "${techName}" already exists. ` +
419
+ `Use \`wm core clone ${techName}\` to start editing it.`
420
+ );
421
+ } else if (err instanceof ApiError && err.status === 403) {
422
+ spinner.fail('SuperAdmin permissions required.');
423
+ } else {
424
+ spinner.fail(err.message);
425
+ }
426
+ process.exit(1);
427
+ }
428
+
429
+ spinner.succeed(`Registered ${pc.cyan(created.name)} ${pc.dim('(' + created.coreComponentId + ')')}`);
430
+
431
+ writeMeta(rootDir, {
432
+ componentId: created.coreComponentId,
433
+ baseVersion: null,
434
+ version: '0.0.0',
435
+ pulledAt: new Date().toISOString(),
436
+ fileHashes: {}
437
+ });
438
+
439
+ // Rewrite local component.json so its id matches the server. Without this
440
+ // the next push uploads source.json with a stale UUID inside component.json,
441
+ // which then conflicts with the server-side override on download.
442
+ const synced = syncComponentJsonId(rootDir, { id: created.coreComponentId, name: created.name });
443
+ if (synced) {
444
+ console.log(` ${pc.dim('Updated component.json with the server-assigned id.')}`);
445
+ }
446
+
447
+ console.log(` Next: ${pc.bold('wm core push')} to upload the first version.`);
448
+ }
449
+
450
+ // ────────────────────────────────────────────────────────────────────────────
451
+ // wm core attach <name>
452
+ // ────────────────────────────────────────────────────────────────────────────
453
+
454
+ /**
455
+ * Bind an already-existing Core record (typically just created in the
456
+ * admin UI) to the current local folder. Writes .webmate.json and
457
+ * patches component.json with the server-assigned id, but does NOT
458
+ * upload anything. The next `wm core push` becomes the first version.
459
+ */
460
+ export async function coreAttachCommand(name, options = {}) {
461
+ if (!name) {
462
+ logger.error('Component name is required. Usage: wm core attach <name>');
463
+ process.exit(1);
464
+ }
465
+
466
+ const rootDir = resolveDir(options.dir);
467
+ if (!existsSync(rootDir)) {
468
+ logger.error(`Directory not found: ${rootDir}`);
469
+ process.exit(1);
470
+ }
471
+
472
+ const existing = readMeta(rootDir);
473
+ if (existing?.componentId && !options.force) {
474
+ logger.error(
475
+ `This folder is already attached to component ${existing.componentId}. ` +
476
+ `Use --force to overwrite.`
477
+ );
478
+ process.exit(1);
479
+ }
480
+
481
+ const lookup = ora(`Resolving "${name}"…`).start();
482
+ let core;
483
+ try {
484
+ core = await findCoreByName(name);
485
+ } catch (err) {
486
+ if (err instanceof ApiError && err.status === 403) {
487
+ lookup.fail('SuperAdmin permissions required.');
488
+ } else {
489
+ lookup.fail(err.message);
490
+ }
491
+ process.exit(1);
492
+ }
493
+ if (!core) {
494
+ lookup.fail(`No Core component named "${name}" found.`);
495
+ process.exit(1);
496
+ }
497
+ lookup.succeed(`Found ${pc.cyan(core.name)} ${pc.dim('(' + core.id + ')')}`);
498
+
499
+ writeMeta(rootDir, {
500
+ componentId: core.id,
501
+ baseVersion: null,
502
+ version: core.currentVersion || '0.0.0',
503
+ pulledAt: new Date().toISOString(),
504
+ fileHashes: {}
505
+ });
506
+
507
+ const synced = syncComponentJsonId(rootDir, { id: core.id, name: core.name });
508
+
509
+ logger.success(`Attached ${pc.cyan(core.name)} to ${pc.dim(rootDir)}`);
510
+ if (synced) {
511
+ console.log(` ${pc.dim('Updated component.json with the server id.')}`);
512
+ }
513
+ console.log(` Next: ${pc.bold('wm core push')} to upload the first version.`);
514
+ }
515
+
516
+ // ────────────────────────────────────────────────────────────────────────────
517
+ // wm core push [dir]
518
+ // ────────────────────────────────────────────────────────────────────────────
519
+
520
+ export async function corePushCommand(dirArg, options = {}) {
521
+ const rootDir = resolveDir(dirArg);
522
+ if (!existsSync(rootDir)) {
523
+ logger.error(`Directory not found: ${rootDir}`);
524
+ process.exit(1);
525
+ }
526
+
527
+ // Workspace-mode: if the user runs `wm core push` (no dir, no .webmate.json
528
+ // here) from inside a Core workspace, iterate over every component subfolder
529
+ // and push each one that has local changes. Parallel to the org-workspace
530
+ // bulk push flow.
531
+ const meta = readMeta(rootDir);
532
+ if (!meta?.componentId) {
533
+ const ws = findEnclosingCoreWorkspace(rootDir);
534
+ if (ws) {
535
+ return corePushWorkspaceCommand(ws.root, options);
536
+ }
537
+ logger.error(
538
+ `No .webmate.json found in ${rootDir}. ` +
539
+ `Run \`wm core clone <name>\` or \`wm core init\` first.`
540
+ );
541
+ process.exit(1);
542
+ }
543
+
544
+ const { files, fileHashes } = readComponentFiles(rootDir);
545
+
546
+ const body = { files };
547
+ if (meta.baseVersion) body.baseVersion = meta.baseVersion;
548
+ if (options.message) body.commitMessage = options.message;
549
+
550
+ const spinner = ora('Uploading and building…').start();
551
+ let result;
552
+ try {
553
+ result = await apiFetch(`/api/admin/core-components/${meta.componentId}/versions`, {
554
+ method: 'POST',
555
+ body
556
+ });
557
+ } catch (err) {
558
+ if (err instanceof ApiError) {
559
+ if (err.status === 404) {
560
+ spinner.fail(
561
+ `Core component ${meta.componentId} not found on the server. ` +
562
+ `Run \`wm core init\` to register it first.`
563
+ );
564
+ } else if (err.status === 403) {
565
+ spinner.fail('SuperAdmin permissions required.');
566
+ } else if (err.status === 409) {
567
+ spinner.fail(
568
+ `Conflict: remote moved to v${err.body?.currentVersion ?? '?'}. ` +
569
+ `Run \`wm core clone ${meta.componentId}\` to refresh local source.`
570
+ );
571
+ } else if (err.status === 422) {
572
+ spinner.fail(`Build failed: ${err.body?.buildErrors?.join('; ') || err.message}`);
573
+ } else {
574
+ spinner.fail(`Push failed (HTTP ${err.status}): ${err.message}`);
575
+ }
576
+ } else {
577
+ spinner.fail(err.message);
578
+ }
579
+ process.exit(1);
580
+ }
581
+
582
+ spinner.succeed(`Built and published ${pc.cyan(result.version)} ${pc.dim('(' + result.versionId.slice(0, 8) + ')')}`);
583
+
584
+ writeMeta(rootDir, {
585
+ componentId: meta.componentId,
586
+ baseVersion: result.versionId,
587
+ version: result.version,
588
+ pulledAt: new Date().toISOString(),
589
+ fileHashes
590
+ });
591
+
592
+ logger.success(`Pushed Core component ${pc.cyan(meta.componentId)} as ${pc.cyan(result.version)}`);
593
+ if (result.previousVersion) {
594
+ console.log(` Previous: ${pc.dim(result.previousVersion)}`);
595
+ }
596
+ console.log(` Files: ${pc.dim(Object.keys(files).length + ' file(s)')}`);
597
+
598
+ const snap = await autoSnapshot(
599
+ rootDir,
600
+ options.message
601
+ ? `wm core push ${result.version}: ${options.message}`
602
+ : `wm core push ${result.version}`,
603
+ { skip: options.noGit }
604
+ );
605
+ if (snap.committed) {
606
+ console.log(` Git: ${pc.dim('committed ' + snap.sha)}`);
607
+ } else if (snap.skipped && snap.reason === 'no-git') {
608
+ console.log(` Git: ${pc.dim('skipped (no .git)')}`);
609
+ } else if (snap.skipped && snap.reason === 'no-changes') {
610
+ console.log(` Git: ${pc.dim('nothing to snapshot')}`);
611
+ }
612
+ }
613
+
614
+ // ────────────────────────────────────────────────────────────────────────────
615
+ // wm core push (workspace bulk mode)
616
+ // ────────────────────────────────────────────────────────────────────────────
617
+
618
+ /**
619
+ * Iterate over every components/<name>/ subfolder in a Core workspace,
620
+ * push the ones that have local changes. Per-component output is concise
621
+ * so a 64-component bulk push doesn't drown the terminal.
622
+ *
623
+ * Skips folders that:
624
+ * - have no .webmate.json (not yet attached — caller should `wm core init` or `attach`)
625
+ * - have no local diff against the last recorded fileHashes
626
+ * - have a baseVersion=null but no files (never-pushed but also nothing to push)
627
+ *
628
+ * Build errors on one component don't abort the run — the next component
629
+ * still gets a chance. A summary at the end lists failures explicitly.
630
+ */
631
+ async function corePushWorkspaceCommand(workspaceRoot, options = {}) {
632
+ const entries = listComponentSubfolders(workspaceRoot);
633
+ if (entries.length === 0) {
634
+ logger.info('No components found in this workspace.');
635
+ return;
636
+ }
637
+
638
+ console.log(pc.bold(`Core workspace: ${pc.dim(workspaceRoot)}`));
639
+ console.log(pc.dim(` Scanning ${entries.length} component(s)…`));
640
+ console.log();
641
+
642
+ let pushed = 0;
643
+ let skipped = 0;
644
+ const failures = [];
645
+
646
+ for (const entry of entries) {
647
+ const meta = readMeta(entry.absPath);
648
+ if (!meta?.componentId) {
649
+ console.log(` ${pc.dim('·')} ${entry.name}: ${pc.dim('not attached, skipped')}`);
650
+ skipped++;
651
+ continue;
652
+ }
653
+
654
+ const { files, fileHashes } = readComponentFiles(entry.absPath);
655
+
656
+ // Skip components that match the last recorded hashes AND have a
657
+ // baseVersion (= have been pushed at least once). First-time pushes
658
+ // (baseVersion=null) always go through, even if hashes look identical
659
+ // to whatever was set during clone.
660
+ if (meta.baseVersion && meta.fileHashes) {
661
+ const diff = diffHashes(fileHashes, meta.fileHashes);
662
+ if (diff.added.length + diff.modified.length + diff.removed.length === 0) {
663
+ if (!options.force) {
664
+ console.log(` ${pc.dim('·')} ${entry.name}: ${pc.dim('in sync, skipped')}`);
665
+ skipped++;
666
+ continue;
667
+ }
668
+ }
669
+ }
670
+
671
+ const body = { files };
672
+ if (meta.baseVersion) body.baseVersion = meta.baseVersion;
673
+ if (options.message) body.commitMessage = options.message;
674
+
675
+ const spinner = ora(`${entry.name}: building…`).start();
676
+ try {
677
+ const result = await apiFetch(`/api/admin/core-components/${meta.componentId}/versions`, {
678
+ method: 'POST',
679
+ body
680
+ });
681
+ spinner.succeed(
682
+ `${entry.name}: v${pc.cyan(result.version)} ` +
683
+ `${pc.dim('(' + Object.keys(files).length + ' files)')}`
684
+ );
685
+ writeMeta(entry.absPath, {
686
+ componentId: meta.componentId,
687
+ baseVersion: result.versionId,
688
+ version: result.version,
689
+ pulledAt: new Date().toISOString(),
690
+ fileHashes
691
+ });
692
+ pushed++;
693
+ } catch (err) {
694
+ const detail =
695
+ err instanceof ApiError
696
+ ? err.status === 422
697
+ ? `build failed: ${err.body?.buildErrors?.join('; ') || err.message}`
698
+ : err.status === 409
699
+ ? `conflict (remote at v${err.body?.currentVersion ?? '?'})`
700
+ : `HTTP ${err.status}: ${err.message}`
701
+ : err.message;
702
+ spinner.fail(`${entry.name}: ${detail}`);
703
+ failures.push({ name: entry.name, detail });
704
+ }
705
+ }
706
+
707
+ console.log();
708
+ logger.success(
709
+ `Bulk push done — ${pushed} pushed, ${skipped} skipped, ${failures.length} failed.`
710
+ );
711
+ if (failures.length > 0) {
712
+ console.log();
713
+ console.log(pc.bold('Failures:'));
714
+ for (const f of failures) {
715
+ console.log(` ${pc.red('×')} ${f.name}: ${pc.dim(f.detail)}`);
716
+ }
717
+ }
718
+ }
719
+
720
+ // ────────────────────────────────────────────────────────────────────────────
721
+ // wm core status [name]
722
+ // ────────────────────────────────────────────────────────────────────────────
723
+
724
+ /**
725
+ * Show local vs. remote sync state. Two modes:
726
+ * - no arg, inside a Core workspace → table for every components/* subfolder
727
+ * - <name> arg → single component status (resolves name → server lookup)
728
+ */
729
+ export async function coreStatusCommand(arg, options = {}) {
730
+ if (arg) {
731
+ return coreStatusSingleCommand(arg, options);
732
+ }
733
+
734
+ const ws = findEnclosingCoreWorkspace(process.cwd());
735
+ if (!ws) {
736
+ logger.error('Not inside a Core workspace. Pass a component name, or `cd` into a workspace.');
737
+ process.exit(1);
738
+ }
739
+
740
+ const entries = listComponentSubfolders(ws.root);
741
+ if (entries.length === 0) {
742
+ logger.info('No components in this workspace.');
743
+ return;
744
+ }
745
+
746
+ const lookup = ora('Fetching remote state…').start();
747
+ let remoteByName;
748
+ try {
749
+ const res = await apiFetch('/api/admin/core-components');
750
+ const components = res?.components ?? [];
751
+ remoteByName = new Map(components.map((c) => [c.name, c]));
752
+ lookup.succeed(`Remote: ${remoteByName.size} Core component(s)`);
753
+ } catch (err) {
754
+ if (err instanceof ApiError && err.status === 403) {
755
+ lookup.fail('SuperAdmin permissions required.');
756
+ } else {
757
+ lookup.fail(err.message);
758
+ }
759
+ process.exit(1);
760
+ }
761
+
762
+ console.log();
763
+ console.log(pc.bold('Core workspace status'));
764
+ console.log(pc.dim(` ${ws.root}`));
765
+ console.log();
766
+
767
+ const localNames = new Set();
768
+
769
+ for (const entry of entries) {
770
+ localNames.add(entry.name);
771
+ const meta = readMeta(entry.absPath);
772
+ if (!meta?.componentId) {
773
+ console.log(` ${pc.yellow('?')} ${pc.cyan(entry.name.padEnd(32))} ${pc.dim('not attached')}`);
774
+ continue;
775
+ }
776
+
777
+ const remote = remoteByName.get(entry.name);
778
+ const { fileHashes } = readComponentFiles(entry.absPath);
779
+ const hashDiff = meta.fileHashes
780
+ ? diffHashes(fileHashes, meta.fileHashes)
781
+ : { added: [], modified: [], removed: [] };
782
+ const hasLocalChanges = hashDiff.added.length + hashDiff.modified.length + hashDiff.removed.length > 0;
783
+ const behindRemote = remote && meta.version && remote.currentVersion && remote.currentVersion !== meta.version;
784
+
785
+ let label;
786
+ if (!remote) {
787
+ label = pc.red('remote-missing');
788
+ } else if (hasLocalChanges && behindRemote) {
789
+ label = pc.magenta('diverged');
790
+ } else if (hasLocalChanges) {
791
+ label = pc.yellow('changes-local');
792
+ } else if (behindRemote) {
793
+ label = pc.cyan('behind-remote');
794
+ } else if (!meta.baseVersion) {
795
+ label = pc.yellow('never-pushed');
796
+ } else {
797
+ label = pc.green('in-sync');
798
+ }
799
+
800
+ const v = meta.version ? `v${meta.version}` : 'v?';
801
+ const rv = remote?.currentVersion ? `v${remote.currentVersion}` : 'v—';
802
+ console.log(` ${label.padEnd(24)} ${pc.cyan(entry.name.padEnd(32))} ${pc.dim('local ' + v + ' / remote ' + rv)}`);
803
+ }
804
+
805
+ const remoteOnly = [...remoteByName.keys()].filter((n) => !localNames.has(n));
806
+ if (remoteOnly.length > 0) {
807
+ console.log();
808
+ console.log(pc.dim(` ${remoteOnly.length} remote component(s) not in this workspace:`));
809
+ for (const n of remoteOnly) {
810
+ console.log(` ${pc.dim('· ' + n)}`);
811
+ }
812
+ console.log(pc.dim(` Run \`wm core clone\` again to pull them.`));
813
+ }
814
+ }
815
+
816
+ async function coreStatusSingleCommand(name, _options = {}) {
817
+ let core;
818
+ try {
819
+ core = await findCoreByName(name);
820
+ } catch (err) {
821
+ if (err instanceof ApiError && err.status === 403) {
822
+ logger.error('SuperAdmin permissions required.');
823
+ } else {
824
+ logger.error(err.message);
825
+ }
826
+ process.exit(1);
827
+ }
828
+ if (!core) {
829
+ logger.error(`No Core component named "${name}" found.`);
830
+ process.exit(1);
831
+ }
832
+
833
+ console.log(pc.bold(core.name));
834
+ console.log(` Display: ${core.displayName}`);
835
+ console.log(` Category: ${core.category || pc.dim('—')}`);
836
+ console.log(` Version: v${core.currentVersion || '—'}`);
837
+ console.log(` Active: ${core.active ? pc.green('yes') : pc.red('no')}`);
838
+ console.log(` Featured: ${core.featured ? pc.yellow('yes') : 'no'}`);
839
+ console.log(` Used by: ${core.usageCount ?? 0} site(s)`);
840
+ }
841
+
842
+ // ────────────────────────────────────────────────────────────────────────────
843
+ // wm core init-all [dir]
844
+ // ────────────────────────────────────────────────────────────────────────────
845
+
846
+ /**
847
+ * Bulk-onboarding for the 64 legacy components: iterates over every
848
+ * subfolder of <dir>/components/ (or <dir> directly if it looks like a
849
+ * single components/ folder) and runs init + first push for each.
850
+ *
851
+ * Skips folders that:
852
+ * - don't have a component.json
853
+ * - already carry a .webmate.json (already initialised)
854
+ *
855
+ * Stops nothing on individual failures — collects them and reports at the end.
856
+ */
857
+ export async function coreInitAllCommand(dirArg, options = {}) {
858
+ const rootDir = resolveDir(dirArg);
859
+ if (!existsSync(rootDir)) {
860
+ logger.error(`Directory not found: ${rootDir}`);
861
+ process.exit(1);
862
+ }
863
+
864
+ // If user passes the workspace root (./webmate-premium-components),
865
+ // pick up its components/ subfolder. If they pass the components/ folder
866
+ // directly, use that. Both work.
867
+ let componentsDir = rootDir;
868
+ if (!existsSync(join(rootDir, 'component.json')) && existsSync(join(rootDir, 'components'))) {
869
+ componentsDir = join(rootDir, 'components');
870
+ }
871
+
872
+ const entries = readdirSync(componentsDir, { withFileTypes: true })
873
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
874
+ .map((d) => ({ name: d.name, absPath: join(componentsDir, d.name) }))
875
+ .filter((e) => existsSync(join(e.absPath, 'component.json')));
876
+
877
+ if (entries.length === 0) {
878
+ logger.error(`No components with component.json found under ${componentsDir}`);
879
+ process.exit(1);
880
+ }
881
+
882
+ console.log(pc.bold(`Bulk Core onboarding`));
883
+ console.log(pc.dim(` Components dir: ${componentsDir}`));
884
+ console.log(pc.dim(` Found ${entries.length} component(s)`));
885
+ console.log();
886
+
887
+ if (!options.yes) {
888
+ const proceed = await confirm({
889
+ message: `Init + push ${pc.cyan(entries.length)} component(s) as Core records?`,
890
+ default: false
891
+ });
892
+ if (!proceed) {
893
+ logger.info('Cancelled.');
894
+ return;
895
+ }
896
+ }
897
+
898
+ // Pre-fetch every CoreComponent id the server currently knows about — used
899
+ // to validate that a .webmate.json on disk still points at a live record.
900
+ // After a server-side wipe (or manual delete) the local meta lingers and
901
+ // would otherwise short-circuit init, causing every subsequent push to 404.
902
+ const serverLookup = ora('Checking server inventory…').start();
903
+ let serverIds;
904
+ try {
905
+ const list = await apiFetch('/api/admin/core-components');
906
+ serverIds = new Set((list?.components ?? []).map((c) => c.id));
907
+ serverLookup.succeed(`Server inventory: ${serverIds.size} Core component(s) on record`);
908
+ } catch (err) {
909
+ serverLookup.fail(err.message);
910
+ process.exit(1);
911
+ }
912
+
913
+ let inited = 0;
914
+ let pushed = 0;
915
+ let skipped = 0;
916
+ const failures = [];
917
+
918
+ for (const entry of entries) {
919
+ const existingMeta = readMeta(entry.absPath);
920
+ const metaIsValid = existingMeta?.componentId && serverIds.has(existingMeta.componentId);
921
+ if (existingMeta?.componentId && !metaIsValid) {
922
+ console.log(
923
+ ` ${pc.yellow('!')} ${entry.name}: ${pc.dim('stale .webmate.json (id ' + existingMeta.componentId.slice(0, 8) + ' not on server) — re-initialising')}`
924
+ );
925
+ }
926
+ if (metaIsValid) {
927
+ console.log(` ${pc.dim('·')} ${entry.name}: ${pc.dim('already attached, skipping init')}`);
928
+ } else {
929
+ // Inline init: parses component.json, posts create, writes .webmate.json,
930
+ // syncs component.json id. Same logic as coreInitCommand but quieter.
931
+ let componentInfo;
932
+ try {
933
+ componentInfo = getComponentId(entry.absPath);
934
+ } catch (err) {
935
+ failures.push({ name: entry.name, stage: 'read', detail: err.message });
936
+ console.log(` ${pc.red('×')} ${entry.name}: ${pc.dim('component.json read failed')}`);
937
+ continue;
938
+ }
939
+
940
+ const techName = (componentInfo.raw?.name || entry.name)
941
+ .toString()
942
+ .toLowerCase()
943
+ .replace(/[^a-z0-9]+/g, '-')
944
+ .replace(/^-+|-+$/g, '');
945
+
946
+ const spinner = ora(`${entry.name}: registering…`).start();
947
+ let created;
948
+ try {
949
+ created = await apiFetch('/api/admin/core-components', {
950
+ method: 'POST',
951
+ body: {
952
+ name: techName,
953
+ displayName: componentInfo.raw?.displayName || techName,
954
+ category: componentInfo.raw?.category || null,
955
+ description: componentInfo.raw?.description || null
956
+ }
957
+ });
958
+ spinner.succeed(`${entry.name}: registered as ${pc.cyan(created.name)}`);
959
+ inited++;
960
+ } catch (err) {
961
+ if (err instanceof ApiError && err.status === 409) {
962
+ spinner.warn(`${entry.name}: name "${techName}" already exists on server — attaching`);
963
+ // Re-fetch by name and attach instead.
964
+ const existing = await findCoreByName(techName).catch(() => null);
965
+ if (!existing) {
966
+ failures.push({ name: entry.name, stage: 'init', detail: '409 but findByName failed' });
967
+ continue;
968
+ }
969
+ created = { coreComponentId: existing.id, name: existing.name };
970
+ } else {
971
+ failures.push({
972
+ name: entry.name,
973
+ stage: 'init',
974
+ detail: err instanceof ApiError ? `HTTP ${err.status}: ${err.message}` : err.message
975
+ });
976
+ spinner.fail(`${entry.name}: init failed`);
977
+ continue;
978
+ }
979
+ }
980
+
981
+ writeMeta(entry.absPath, {
982
+ componentId: created.coreComponentId,
983
+ baseVersion: null,
984
+ version: '0.0.0',
985
+ pulledAt: new Date().toISOString(),
986
+ fileHashes: {}
987
+ });
988
+ syncComponentJsonId(entry.absPath, { id: created.coreComponentId, name: created.name });
989
+ }
990
+
991
+ // Push first version (or next version if attaching).
992
+ const meta = readMeta(entry.absPath);
993
+ const { files, fileHashes } = readComponentFiles(entry.absPath);
994
+ const body = { files };
995
+ if (meta.baseVersion) body.baseVersion = meta.baseVersion;
996
+
997
+ const spinner = ora(`${entry.name}: building…`).start();
998
+ try {
999
+ const result = await apiFetch(
1000
+ `/api/admin/core-components/${meta.componentId}/versions`,
1001
+ { method: 'POST', body }
1002
+ );
1003
+ spinner.succeed(
1004
+ `${entry.name}: v${pc.cyan(result.version)} ` +
1005
+ `${pc.dim('(' + Object.keys(files).length + ' files)')}`
1006
+ );
1007
+ writeMeta(entry.absPath, {
1008
+ componentId: meta.componentId,
1009
+ baseVersion: result.versionId,
1010
+ version: result.version,
1011
+ pulledAt: new Date().toISOString(),
1012
+ fileHashes
1013
+ });
1014
+ pushed++;
1015
+ } catch (err) {
1016
+ const detail =
1017
+ err instanceof ApiError
1018
+ ? err.status === 422
1019
+ ? `build failed: ${err.body?.buildErrors?.join('; ') || err.message}`
1020
+ : `HTTP ${err.status}: ${err.message}`
1021
+ : err.message;
1022
+ spinner.fail(`${entry.name}: ${detail}`);
1023
+ failures.push({ name: entry.name, stage: 'push', detail });
1024
+ skipped++;
1025
+ }
1026
+ }
1027
+
1028
+ console.log();
1029
+ logger.success(
1030
+ `Bulk onboarding done — ${inited} initialised, ${pushed} pushed, ${failures.length} failed.`
1031
+ );
1032
+ if (failures.length > 0) {
1033
+ console.log();
1034
+ console.log(pc.bold('Failures:'));
1035
+ for (const f of failures) {
1036
+ console.log(` ${pc.red('×')} ${f.name.padEnd(28)} ${pc.dim('[' + f.stage + ']')} ${f.detail}`);
1037
+ }
1038
+ }
1039
+ }