@webmate-studio/cli 0.3.62 → 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,414 @@
1
+ import { existsSync, readdirSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
2
+ import { basename, dirname, join, resolve } from 'path';
3
+ import { confirm } from '@inquirer/prompts';
4
+ import ora from 'ora';
5
+ import pc from 'picocolors';
6
+ import { logger } from '@webmate-studio/core';
7
+ import { apiFetch, ApiError } from '../utils/api-client.js';
8
+ import { resolveAuth } from '../utils/auth-resolver.js';
9
+ import { writeMeta } from '../utils/webmate-meta.js';
10
+ import {
11
+ writeComponentFiles,
12
+ computeFileHashesFromMap
13
+ } from '../utils/component-files.js';
14
+ import { autoSnapshot } from '../utils/git-snapshot.js';
15
+
16
+ function slugify(value) {
17
+ return (value || '')
18
+ .toString()
19
+ .toLowerCase()
20
+ .replace(/[^a-z0-9]+/g, '-')
21
+ .replace(/^-+|-+$/g, '');
22
+ }
23
+
24
+ const DEFAULT_WM_CONFIG = `export default {
25
+ components: {
26
+ path: './components',
27
+ styles: ['./tokens/tokens.css', './styles/base.css'],
28
+ fonts: [],
29
+ islands: {
30
+ path: './islands',
31
+ framework: 'lit'
32
+ }
33
+ },
34
+ preview: {
35
+ port: 5173,
36
+ theme: 'light',
37
+ viewport: { width: 1440, height: 900 }
38
+ }
39
+ };
40
+ `;
41
+
42
+ function parseSource(spec) {
43
+ if (!spec) return null;
44
+ const at = spec.lastIndexOf('@');
45
+ if (at < 0) return { sourceComponentId: spec, sourceVersion: null };
46
+ return {
47
+ sourceComponentId: spec.slice(0, at),
48
+ sourceVersion: spec.slice(at + 1) || null
49
+ };
50
+ }
51
+
52
+ function resolveTargetDir(arg) {
53
+ const cwd = process.cwd();
54
+ return arg ? resolve(cwd, arg) : cwd;
55
+ }
56
+
57
+ async function resolveSourceVersionId({ sourceComponentId, sourceVersion }) {
58
+ if (!sourceVersion || sourceVersion === 'latest') return null;
59
+ if (/^cm[0-9a-z]{20,}$/i.test(sourceVersion)) return sourceVersion;
60
+ const list = await apiFetch(
61
+ `/api/organization/components/${sourceComponentId}/versions`
62
+ );
63
+ const versions = list?.versions ?? [];
64
+ const match = versions.find((v) => v.version === sourceVersion);
65
+ if (!match) {
66
+ throw new Error(
67
+ `Version "${sourceVersion}" not found among ${versions.length} versions of ${sourceComponentId}`
68
+ );
69
+ }
70
+ return match.id;
71
+ }
72
+
73
+ export async function cloneCommand(projectArgs, options = {}) {
74
+ // `wm clone foo bar baz` arrives as ['foo', 'bar', 'baz'] thanks to the
75
+ // variadic positional. Join with spaces so project names with whitespace
76
+ // work without forcing the user to quote them.
77
+ const projectArg = Array.isArray(projectArgs) ? projectArgs.join(' ').trim() : (projectArgs ?? '').trim();
78
+
79
+ // Two modes share the verb:
80
+ // * `wm clone <project>` — project bootstrap (this file, below)
81
+ // * `wm clone --from <uuid>` — single-component clone (legacy, server-side)
82
+ // We can't use both at once.
83
+ if (projectArg && options.from) {
84
+ logger.error('Cannot combine positional <project> with --from. Use one mode at a time.');
85
+ process.exit(1);
86
+ }
87
+ if (!projectArg && !options.from) {
88
+ logger.error('Either a project name/id or --from <component-uuid>[@<version>] is required.');
89
+ process.exit(1);
90
+ }
91
+
92
+ if (projectArg) {
93
+ return cloneProjectCommand(projectArg, options);
94
+ }
95
+
96
+ return cloneComponentCommand(options);
97
+ }
98
+
99
+ async function cloneComponentCommand(options = {}) {
100
+ const parsed = parseSource(options.from);
101
+ if (!parsed?.sourceComponentId) {
102
+ logger.error(`Invalid --from value: ${options.from}`);
103
+ process.exit(1);
104
+ }
105
+
106
+ const targetDir = resolveTargetDir(options.to);
107
+
108
+ if (existsSync(targetDir) && readdirSync(targetDir).length > 0 && !options.force) {
109
+ const proceed = await confirm({
110
+ message: `Target ${pc.cyan(targetDir)} is not empty. Continue and overwrite?`,
111
+ default: false
112
+ });
113
+ if (!proceed) {
114
+ logger.info('Clone cancelled.');
115
+ return;
116
+ }
117
+ }
118
+
119
+ let sourceVersionId;
120
+ try {
121
+ sourceVersionId = await resolveSourceVersionId({
122
+ sourceComponentId: parsed.sourceComponentId,
123
+ sourceVersion: parsed.sourceVersion
124
+ });
125
+ } catch (err) {
126
+ if (err instanceof ApiError) {
127
+ logger.error(`Could not list source versions (HTTP ${err.status}): ${err.message}`);
128
+ } else {
129
+ logger.error(err.message);
130
+ }
131
+ process.exit(1);
132
+ }
133
+
134
+ const spinner = ora('Cloning component on server…').start();
135
+ let result;
136
+ try {
137
+ const body = {
138
+ sourceComponentId: parsed.sourceComponentId
139
+ };
140
+ if (sourceVersionId) body.sourceVersionId = sourceVersionId;
141
+ if (options.targetOrg) body.targetOrganizationSlug = options.targetOrg;
142
+ if (options.targetOrgId) body.targetOrganizationId = options.targetOrgId;
143
+ if (options.name) body.targetName = options.name;
144
+ if (options.category) body.targetCategory = options.category;
145
+
146
+ result = await apiFetch('/api/organization/components/clone', {
147
+ method: 'POST',
148
+ body
149
+ });
150
+ spinner.succeed(
151
+ `Cloned to new component ${pc.cyan(result.newComponentId)} ` +
152
+ `${pc.dim('(org ' + result.newOrganizationId + ')')}`
153
+ );
154
+ } catch (err) {
155
+ if (err instanceof ApiError) {
156
+ spinner.fail(`Clone failed (HTTP ${err.status}): ${err.message}`);
157
+ } else {
158
+ spinner.fail(err.message);
159
+ }
160
+ process.exit(1);
161
+ }
162
+
163
+ const files = result?.files ?? {};
164
+ if (!Object.keys(files).length) {
165
+ logger.warn('Server returned no files. Aborting before touching disk.');
166
+ process.exit(1);
167
+ }
168
+
169
+ mkdirSync(targetDir, { recursive: true });
170
+ writeComponentFiles(targetDir, files, { clean: !options.merge });
171
+
172
+ const fileHashes = computeFileHashesFromMap(files);
173
+ const cloneAuth = resolveAuth();
174
+ const meta = writeMeta(targetDir, {
175
+ componentId: result.newComponentId,
176
+ baseVersion: null,
177
+ version: null,
178
+ pulledAt: new Date().toISOString(),
179
+ fileHashes,
180
+ originRepositoryId: cloneAuth?.repositoryId ?? null
181
+ });
182
+
183
+ logger.success(`Wrote ${Object.keys(files).length} file(s) to ${pc.dim(targetDir)}`);
184
+ console.log(` New component ID: ${pc.cyan(result.newComponentId)}`);
185
+ console.log(` Target org: ${pc.dim(result.newOrganizationId)}`);
186
+ console.log(` Source: ${pc.dim(result.source.componentId + '@' + result.source.version)}`);
187
+ console.log(` Manifest: ${pc.dim(meta && targetDir + '/.webmate.json')}`);
188
+
189
+ const snap = await autoSnapshot(
190
+ targetDir,
191
+ `wm clone from ${result.source.componentId}@${result.source.version}`,
192
+ { skip: options.noGit }
193
+ );
194
+ if (snap.committed) {
195
+ console.log(` Git snapshot: ${pc.dim(snap.sha)}`);
196
+ } else if (snap.skipped && snap.reason === 'no-git') {
197
+ console.log(` Git snapshot: ${pc.dim('skipped (no .git)')}`);
198
+ }
199
+
200
+ console.log();
201
+ console.log(pc.bold('Next step:') + ' run ' + pc.cyan(`wm push ${basename(targetDir)} --force`));
202
+ console.log(pc.dim(' (--force is needed for the very first push because there is no baseVersion yet)'));
203
+ }
204
+
205
+ // Walk up from `start` (inclusive) toward the filesystem root. Returns the
206
+ // first ancestor that looks like an existing webmate workspace (has a
207
+ // wm.config.js or a workspace-shaped .webmate/config.json), or null if
208
+ // there is none. Used to stop `wm clone <project>` from creating a
209
+ // sub-workspace inside an existing one — which is the classic footgun
210
+ // that pollutes the parent's components/ tree with another full workspace.
211
+ //
212
+ // Workspace vs. global config: `~/.webmate/config.json` is also created by
213
+ // the CLI for global state (just { cmsUrl: ... }) and must not be treated
214
+ // as a workspace marker — otherwise running `wm clone` from $HOME refuses
215
+ // with a misleading "you're inside a workspace" error. We disambiguate by
216
+ // peeking at the file: only configs that carry a `repositoryId` (or, for
217
+ // freshly initialised but not-yet-bound workspaces, a `componentsPath`)
218
+ // are real workspaces.
219
+ function isWorkspaceConfigFile(path) {
220
+ try {
221
+ const raw = readFileSync(path, 'utf-8');
222
+ const data = JSON.parse(raw);
223
+ if (!data || typeof data !== 'object') return false;
224
+ return Boolean(data.repositoryId || data.componentsPath);
225
+ } catch {
226
+ return false;
227
+ }
228
+ }
229
+ function findEnclosingWorkspace(start) {
230
+ let dir = start;
231
+ while (true) {
232
+ if (existsSync(join(dir, 'wm.config.js'))) return dir;
233
+ const cfg = join(dir, '.webmate', 'config.json');
234
+ if (existsSync(cfg) && isWorkspaceConfigFile(cfg)) return dir;
235
+ const parent = dirname(dir);
236
+ if (parent === dir) return null;
237
+ dir = parent;
238
+ }
239
+ }
240
+
241
+ async function cloneProjectCommand(projectArg, options = {}) {
242
+ // Refuse to nest workspaces. If the user invokes `wm clone <project>`
243
+ // from inside an existing workspace (or anywhere under its tree —
244
+ // most commonly inside its components/ folder), the resulting clone
245
+ // would land as a sub-workspace and every parent-walk component
246
+ // scanner would happily fold its contents into the outer workspace.
247
+ // We prefer a hard stop with a clear pointer to a safer location
248
+ // over the silent disaster.
249
+ const enclosing = findEnclosingWorkspace(process.cwd());
250
+ if (enclosing && !options.force) {
251
+ logger.error(
252
+ `Refusing to clone into a nested workspace.\n` +
253
+ ` Current directory is inside the workspace at ${pc.cyan(enclosing)}.\n` +
254
+ ` Run \`wm clone\` from a folder that is not itself a workspace — typically the parent:\n` +
255
+ ` ${pc.dim('cd ' + dirname(enclosing))}\n` +
256
+ ` ${pc.cyan('wm clone ' + projectArg)}\n` +
257
+ ` Or pass ${pc.bold('--force')} if you really mean to nest.`
258
+ );
259
+ process.exit(1);
260
+ }
261
+
262
+ // 1. Resolve the project (= ComponentRepository) — accept id or name.
263
+ const lookup = ora(`Looking up project ${pc.cyan(projectArg)}…`).start();
264
+ let repo;
265
+ try {
266
+ const res = await apiFetch('/api/organization/repositories');
267
+ const repos = Array.isArray(res?.repositories) ? res.repositories : [];
268
+ repo = repos.find(r => r.id === projectArg || r.name === projectArg);
269
+ if (!repo) {
270
+ lookup.fail(`Project "${projectArg}" not found in your organization.`);
271
+ console.log(pc.dim(' Available projects:'));
272
+ for (const r of repos) {
273
+ console.log(` ${pc.cyan(r.name)} ${pc.dim('(' + r.id + ')')}`);
274
+ }
275
+ process.exit(1);
276
+ }
277
+ lookup.succeed(`Project ${pc.cyan(repo.name)} ${pc.dim('(' + repo.id + ')')}`);
278
+ } catch (err) {
279
+ if (err instanceof ApiError) {
280
+ lookup.fail(`Could not list projects (HTTP ${err.status}): ${err.message}`);
281
+ } else {
282
+ lookup.fail(err.message);
283
+ }
284
+ process.exit(1);
285
+ }
286
+
287
+ // 2. Decide target directory.
288
+ const dirName = options.to ? options.to : slugify(repo.name);
289
+ const targetDir = resolve(process.cwd(), dirName);
290
+
291
+ if (existsSync(targetDir) && readdirSync(targetDir).length > 0 && !options.force) {
292
+ const proceed = await confirm({
293
+ message: `Target ${pc.cyan(targetDir)} is not empty. Continue and overwrite?`,
294
+ default: false
295
+ });
296
+ if (!proceed) {
297
+ logger.info('Clone cancelled.');
298
+ return;
299
+ }
300
+ }
301
+
302
+ // 3. List components in this repo (filter client-side from the org list).
303
+ const compsSpinner = ora('Listing components…').start();
304
+ let components;
305
+ try {
306
+ const res = await apiFetch('/api/organization/components');
307
+ const all = Array.isArray(res?.components) ? res.components : [];
308
+ components = all.filter(c => c.repository?.id === repo.id || c.repositoryId === repo.id);
309
+ compsSpinner.succeed(
310
+ `Found ${pc.cyan(components.length)} component(s) in ${pc.cyan(repo.name)}`
311
+ );
312
+ } catch (err) {
313
+ if (err instanceof ApiError) {
314
+ compsSpinner.fail(`Could not list components (HTTP ${err.status}): ${err.message}`);
315
+ } else {
316
+ compsSpinner.fail(err.message);
317
+ }
318
+ process.exit(1);
319
+ }
320
+
321
+ // 4. Lay out the workspace skeleton.
322
+ mkdirSync(targetDir, { recursive: true });
323
+ mkdirSync(join(targetDir, 'components'), { recursive: true });
324
+
325
+ const configPath = join(targetDir, 'wm.config.js');
326
+ if (!existsSync(configPath)) {
327
+ writeFileSync(configPath, DEFAULT_WM_CONFIG, 'utf-8');
328
+ }
329
+
330
+ const auth = resolveAuth();
331
+ const dotWebmateDir = join(targetDir, '.webmate');
332
+ mkdirSync(dotWebmateDir, { recursive: true });
333
+ const workspaceConfig = {
334
+ organizationId: auth?.organizationId ?? null,
335
+ organizationSlug: auth?.organizationSlug ?? null,
336
+ organizationName: auth?.organizationName ?? null,
337
+ repositoryId: repo.id,
338
+ repositoryName: repo.name,
339
+ clonedAt: new Date().toISOString()
340
+ };
341
+ writeFileSync(
342
+ join(dotWebmateDir, 'config.json'),
343
+ JSON.stringify(workspaceConfig, null, 2) + '\n',
344
+ 'utf-8'
345
+ );
346
+ logger.success(`Workspace scaffolded at ${pc.dim(targetDir)}`);
347
+
348
+ // 5. Pull source for every published component.
349
+ let pulled = 0;
350
+ let skipped = 0;
351
+ for (const comp of components) {
352
+ const versionId =
353
+ comp.latestVersion && typeof comp.latestVersion === 'object'
354
+ ? comp.latestVersion.id
355
+ : null;
356
+ const versionLabel =
357
+ comp.latestVersion && typeof comp.latestVersion === 'object'
358
+ ? comp.latestVersion.version
359
+ : typeof comp.latestVersion === 'string'
360
+ ? comp.latestVersion
361
+ : null;
362
+ const compSlug = slugify(comp.name) || slugify(comp.displayName) || comp.id.slice(0, 8);
363
+ const compDir = join(targetDir, 'components', compSlug);
364
+
365
+ if (!versionId) {
366
+ logger.warn(` · ${comp.name}: no published version — skipped`);
367
+ skipped++;
368
+ continue;
369
+ }
370
+
371
+ const pullSpinner = ora(`Pulling ${comp.name} ${pc.dim('v' + versionLabel)}…`).start();
372
+ try {
373
+ const source = await apiFetch(
374
+ `/api/organization/components/${comp.id}/versions/${versionId}/source`
375
+ );
376
+ const files = source?.files ?? {};
377
+ if (!Object.keys(files).length) {
378
+ pullSpinner.warn(`${comp.name}: empty source bundle — skipped`);
379
+ skipped++;
380
+ continue;
381
+ }
382
+ mkdirSync(compDir, { recursive: true });
383
+ writeComponentFiles(compDir, files, { clean: true });
384
+ const fileHashes = computeFileHashesFromMap(files);
385
+ writeMeta(compDir, {
386
+ componentId: comp.id,
387
+ baseVersion: versionId,
388
+ version: versionLabel,
389
+ pulledAt: new Date().toISOString(),
390
+ fileHashes,
391
+ originRepositoryId: repo.id
392
+ });
393
+ pullSpinner.succeed(`${comp.name} ${pc.dim('v' + versionLabel)}`);
394
+ pulled++;
395
+ } catch (err) {
396
+ if (err instanceof ApiError) {
397
+ pullSpinner.fail(`${comp.name}: HTTP ${err.status} — ${err.message}`);
398
+ } else {
399
+ pullSpinner.fail(`${comp.name}: ${err.message}`);
400
+ }
401
+ skipped++;
402
+ }
403
+ }
404
+
405
+ // 6. Recap + next steps.
406
+ console.log();
407
+ logger.success(
408
+ `Cloned project ${pc.cyan(repo.name)} — ${pulled} component(s) pulled, ${skipped} skipped`
409
+ );
410
+ console.log();
411
+ console.log(pc.bold('Next steps:'));
412
+ console.log(` ${pc.cyan('cd ' + dirName)}`);
413
+ console.log(` ${pc.cyan('wm dev')}`);
414
+ }
@@ -0,0 +1,101 @@
1
+ import ora from 'ora';
2
+ import pc from 'picocolors';
3
+ import { logger } from '@webmate-studio/core';
4
+ import { apiFetch, ApiError } from '../utils/api-client.js';
5
+ import { tenantApiFetch, resolveTenantSubdomain } from '../utils/tenant-api.js';
6
+
7
+ function buildQuery(opts) {
8
+ const params = new URLSearchParams();
9
+ if (opts.org) params.set('orgSlug', opts.org);
10
+ if (opts.orgId) params.set('orgId', opts.orgId);
11
+ if (opts.category) params.set('category', opts.category);
12
+ if (opts.search) params.set('search', opts.search);
13
+ const qs = params.toString();
14
+ return qs ? `?${qs}` : '';
15
+ }
16
+
17
+ function truncate(value, n) {
18
+ const s = typeof value === 'string' ? value : value == null ? '' : String(value);
19
+ if (!s) return '';
20
+ if (s.length <= n) return s;
21
+ return s.slice(0, n - 1) + '…';
22
+ }
23
+
24
+ function latestVersionLabel(c) {
25
+ const lv = c.latestVersion;
26
+ if (lv && typeof lv === 'object') return lv.version ?? '—';
27
+ if (typeof lv === 'string') return lv;
28
+ return '—';
29
+ }
30
+
31
+ export async function componentsListCommand(options = {}) {
32
+ const query = buildQuery(options);
33
+ const spinner = ora('Fetching components…').start();
34
+ let response;
35
+ try {
36
+ // Tenant-Modus: --org wird ignoriert (Components leben nun pro Website),
37
+ // dafür liest der Endpoint die Website aus dem Auth-Tenant-Kontext.
38
+ // Legacy: org-weite Liste mit ?orgSlug=… wie bisher.
39
+ const sub = resolveTenantSubdomain(null);
40
+ if (sub) {
41
+ response = await tenantApiFetch(`/api/tenant-components`, {}, { tenantSubdomain: sub });
42
+ } else {
43
+ response = await apiFetch(`/api/organization/components${query}`);
44
+ }
45
+ spinner.stop();
46
+ } catch (err) {
47
+ if (err instanceof ApiError) {
48
+ spinner.fail(`Fetch failed (HTTP ${err.status}): ${err.message}`);
49
+ } else {
50
+ spinner.fail(err.message);
51
+ }
52
+ process.exit(1);
53
+ }
54
+
55
+ const components =
56
+ response?.components ??
57
+ response?.data ??
58
+ (Array.isArray(response) ? response : []);
59
+
60
+ if (!Array.isArray(components) || components.length === 0) {
61
+ logger.info(
62
+ options.org
63
+ ? `No components in organization "${options.org}".`
64
+ : 'No components in current organization.'
65
+ );
66
+ return;
67
+ }
68
+
69
+ const orgLabel = options.org ?? pc.dim('(current org)');
70
+ console.log();
71
+ console.log(pc.bold(`${components.length} component(s) in ${orgLabel}`));
72
+ console.log();
73
+
74
+ const ID_W = 36;
75
+ const NAME_W = 30;
76
+ const CAT_W = 18;
77
+ const VER_W = 12;
78
+
79
+ console.log(
80
+ pc.dim(
81
+ `${'UUID'.padEnd(ID_W)} ${'Name'.padEnd(NAME_W)} ${'Category'.padEnd(CAT_W)} ${'Latest'.padEnd(VER_W)}`
82
+ )
83
+ );
84
+ console.log(
85
+ pc.dim('-'.repeat(ID_W) + ' ' + '-'.repeat(NAME_W) + ' ' + '-'.repeat(CAT_W) + ' ' + '-'.repeat(VER_W))
86
+ );
87
+
88
+ for (const c of components) {
89
+ const id = c.id ?? '';
90
+ const name = truncate(c.displayName ?? c.name ?? '', NAME_W);
91
+ const cat = truncate(c.category ?? '', CAT_W);
92
+ const ver = truncate(latestVersionLabel(c), VER_W);
93
+ const dep = c.deprecated ? pc.yellow(' (deprecated)') : '';
94
+ console.log(
95
+ `${pc.dim(String(id).padEnd(ID_W))} ${name.padEnd(NAME_W)} ${pc.dim(cat.padEnd(CAT_W))} ${pc.cyan(ver.padEnd(VER_W))}${dep}`
96
+ );
97
+ }
98
+
99
+ console.log();
100
+ console.log(pc.dim(` wm clone --from <uuid>[@<ver>] --to <dir>`));
101
+ }