@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,91 @@
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
+
6
+ function buildQuery(opts) {
7
+ const params = new URLSearchParams();
8
+ if (opts.org) params.set('orgSlug', opts.org);
9
+ if (opts.orgId) params.set('orgId', opts.orgId);
10
+ const qs = params.toString();
11
+ return qs ? `?${qs}` : '';
12
+ }
13
+
14
+ function truncate(value, n) {
15
+ const s = typeof value === 'string' ? value : value == null ? '' : String(value);
16
+ if (!s) return '';
17
+ if (s.length <= n) return s;
18
+ return s.slice(0, n - 1) + '…';
19
+ }
20
+
21
+ export async function projectsListCommand(options = {}) {
22
+ const query = buildQuery(options);
23
+ const spinner = ora('Fetching projects…').start();
24
+ let response;
25
+ try {
26
+ response = await apiFetch(`/api/organization/repositories${query}`);
27
+ spinner.stop();
28
+ } catch (err) {
29
+ if (err instanceof ApiError) {
30
+ spinner.fail(`Fetch failed (HTTP ${err.status}): ${err.message}`);
31
+ } else {
32
+ spinner.fail(err.message);
33
+ }
34
+ process.exit(1);
35
+ }
36
+
37
+ const repositories = Array.isArray(response?.repositories) ? response.repositories : [];
38
+
39
+ if (repositories.length === 0) {
40
+ logger.info(
41
+ options.org
42
+ ? `No projects in organization "${options.org}".`
43
+ : 'No projects in current organization.'
44
+ );
45
+ return;
46
+ }
47
+
48
+ const orgLabel = options.org ?? pc.dim('(current org)');
49
+ console.log();
50
+ console.log(pc.bold(`${repositories.length} project(s) in ${orgLabel}`));
51
+ console.log();
52
+
53
+ const NAME_W = 30;
54
+ const ID_W = 36;
55
+ const TYPE_W = 6;
56
+ const BRANCH_W = 16;
57
+ const COUNT_W = 5;
58
+
59
+ console.log(
60
+ pc.dim(
61
+ `${'Name'.padEnd(NAME_W)} ${'ID'.padEnd(ID_W)} ${'Type'.padEnd(TYPE_W)} ${'Branch'.padEnd(BRANCH_W)} ${'#Cmp'.padStart(COUNT_W)}`
62
+ )
63
+ );
64
+ console.log(
65
+ pc.dim(
66
+ '-'.repeat(NAME_W) +
67
+ ' ' +
68
+ '-'.repeat(ID_W) +
69
+ ' ' +
70
+ '-'.repeat(TYPE_W) +
71
+ ' ' +
72
+ '-'.repeat(BRANCH_W) +
73
+ ' ' +
74
+ '-'.repeat(COUNT_W)
75
+ )
76
+ );
77
+
78
+ for (const r of repositories) {
79
+ const name = truncate(r.name ?? '', NAME_W).padEnd(NAME_W);
80
+ const id = String(r.id ?? '').padEnd(ID_W);
81
+ const typeStr = (r.isInternal ? 'intern' : 'github').padEnd(TYPE_W);
82
+ const typeColored = r.isInternal ? pc.magenta(typeStr) : pc.cyan(typeStr);
83
+ const branchStr = r.isInternal ? '—' : r.branch ?? '—';
84
+ const branch = truncate(branchStr, BRANCH_W).padEnd(BRANCH_W);
85
+ const count = String(r.componentCount ?? 0).padStart(COUNT_W);
86
+ console.log(`${name} ${pc.dim(id)} ${typeColored} ${pc.dim(branch)} ${pc.cyan(count)}`);
87
+ }
88
+
89
+ console.log();
90
+ console.log(pc.dim(' wm clone <name> bootstrap a workspace for a project'));
91
+ }
@@ -0,0 +1,192 @@
1
+ import { existsSync, mkdirSync, readdirSync } from 'fs';
2
+ import { join, resolve, basename } 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 { readMeta, writeMeta, getMetaPath } from '../utils/webmate-meta.js';
10
+ import { tenantApiFetch, resolveTenantSubdomain } from '../utils/tenant-api.js';
11
+ import {
12
+ readComponentFiles,
13
+ writeComponentFiles,
14
+ computeFileHashesFromMap,
15
+ diffHashes,
16
+ getComponentId
17
+ } from '../utils/component-files.js';
18
+ import { autoSnapshot } from '../utils/git-snapshot.js';
19
+
20
+ function resolveComponentDir(arg) {
21
+ const cwd = process.cwd();
22
+ if (!arg) return cwd;
23
+ return resolve(cwd, arg);
24
+ }
25
+
26
+ async function pickComponentId({ rootDir, meta, optionId }) {
27
+ if (optionId) return optionId;
28
+ if (meta?.componentId) return meta.componentId;
29
+ if (existsSync(join(rootDir, 'component.json'))) {
30
+ try {
31
+ return getComponentId(rootDir).id;
32
+ } catch {
33
+ // fall through
34
+ }
35
+ }
36
+ const err = new Error(
37
+ 'No component id found. Pass --id <uuid>, or run from a directory containing component.json or .webmate.json.'
38
+ );
39
+ err.code = 'WM_NO_COMPONENT_ID';
40
+ throw err;
41
+ }
42
+
43
+ async function fetchVersionsList(componentId, meta) {
44
+ const sub = resolveTenantSubdomain(meta);
45
+ const path = sub
46
+ ? `/api/tenant-components/${componentId}/versions`
47
+ : `/api/organization/components/${componentId}/versions`;
48
+ return tenantApiFetch(path, {}, meta);
49
+ }
50
+
51
+ async function fetchSource(componentId, versionId, meta) {
52
+ const sub = resolveTenantSubdomain(meta);
53
+ const path = sub
54
+ ? `/api/tenant-components/${componentId}/versions/${versionId}/source`
55
+ : `/api/organization/components/${componentId}/versions/${versionId}/source`;
56
+ return tenantApiFetch(path, {}, meta);
57
+ }
58
+
59
+ async function resolveVersionId({ componentId, requested, meta }) {
60
+ const list = await fetchVersionsList(componentId, meta);
61
+ const versions = list?.versions ?? [];
62
+ if (!versions.length) {
63
+ throw new Error(`No versions exist yet for component ${componentId}`);
64
+ }
65
+ if (!requested || requested === 'latest') {
66
+ return versions[0];
67
+ }
68
+ const match = versions.find((v) => v.id === requested || v.version === requested);
69
+ if (!match) {
70
+ throw new Error(`Version "${requested}" not found among ${versions.length} versions`);
71
+ }
72
+ return match;
73
+ }
74
+
75
+ function summariseChanges(diff) {
76
+ const parts = [];
77
+ if (diff.added.length) parts.push(`${diff.added.length} added`);
78
+ if (diff.modified.length) parts.push(`${diff.modified.length} modified`);
79
+ if (diff.removed.length) parts.push(`${diff.removed.length} removed`);
80
+ return parts.length ? parts.join(', ') : 'no changes';
81
+ }
82
+
83
+ export async function pullCommand(componentArg, options = {}) {
84
+ const rootDir = resolveComponentDir(componentArg);
85
+
86
+ if (!existsSync(rootDir)) {
87
+ mkdirSync(rootDir, { recursive: true });
88
+ logger.info(`Created directory ${pc.dim(rootDir)}`);
89
+ }
90
+
91
+ const meta = readMeta(rootDir);
92
+
93
+ let componentId;
94
+ try {
95
+ componentId = await pickComponentId({ rootDir, meta, optionId: options.id });
96
+ } catch (err) {
97
+ logger.error(err.message);
98
+ process.exit(1);
99
+ }
100
+
101
+ // Detect local changes before overwriting
102
+ const dirHasContent = readdirSync(rootDir).some((n) => n !== '.webmate.json');
103
+ if (dirHasContent && meta?.fileHashes) {
104
+ const { fileHashes: localHashes } = readComponentFiles(rootDir);
105
+ const localDiff = diffHashes(localHashes, meta.fileHashes);
106
+ const hasLocal =
107
+ localDiff.added.length || localDiff.modified.length || localDiff.removed.length;
108
+ if (hasLocal && !options.force) {
109
+ console.log(
110
+ pc.yellow(`Local changes detected since last pull: ${summariseChanges(localDiff)}`)
111
+ );
112
+ for (const p of [...localDiff.modified, ...localDiff.added].slice(0, 10)) {
113
+ console.log(` ${pc.yellow('M')} ${p}`);
114
+ }
115
+ for (const p of localDiff.removed.slice(0, 10)) {
116
+ console.log(` ${pc.red('D')} ${p}`);
117
+ }
118
+ const proceed = await confirm({
119
+ message: 'Overwrite local changes?',
120
+ default: false
121
+ });
122
+ if (!proceed) {
123
+ logger.info('Pull cancelled.');
124
+ return;
125
+ }
126
+ }
127
+ }
128
+
129
+ let version;
130
+ try {
131
+ version = await resolveVersionId({ componentId, requested: options.version, meta });
132
+ } catch (err) {
133
+ if (err instanceof ApiError) {
134
+ logger.error(`Could not list versions (HTTP ${err.status}): ${err.message}`);
135
+ } else {
136
+ logger.error(err.message);
137
+ }
138
+ process.exit(1);
139
+ }
140
+
141
+ const spinner = ora(`Pulling ${pc.cyan(version.version)} (${pc.dim(version.id.slice(0, 8))})…`).start();
142
+ let source;
143
+ try {
144
+ source = await fetchSource(componentId, version.id, meta);
145
+ spinner.succeed(`Fetched source for ${pc.cyan(version.version)}.`);
146
+ } catch (err) {
147
+ if (err instanceof ApiError) {
148
+ spinner.fail(`Pull failed (HTTP ${err.status}): ${err.message}`);
149
+ } else {
150
+ spinner.fail(err.message);
151
+ }
152
+ process.exit(1);
153
+ }
154
+
155
+ const remoteFiles = source?.files ?? {};
156
+ if (!remoteFiles || !Object.keys(remoteFiles).length) {
157
+ logger.warn('Remote returned no files. Nothing to write.');
158
+ return;
159
+ }
160
+
161
+ writeComponentFiles(rootDir, remoteFiles, { clean: !options.merge });
162
+
163
+ const fileHashes = computeFileHashesFromMap(remoteFiles);
164
+ const pullAuth = resolveAuth();
165
+ const writePayload = {
166
+ componentId,
167
+ baseVersion: version.id,
168
+ version: version.version,
169
+ pulledAt: new Date().toISOString(),
170
+ fileHashes,
171
+ originRepositoryId: pullAuth?.repositoryId ?? null
172
+ };
173
+ const sub = resolveTenantSubdomain(meta);
174
+ if (sub) writePayload.tenantSubdomain = sub;
175
+ const written = writeMeta(rootDir, writePayload);
176
+
177
+ logger.success(`Pulled into ${pc.dim(rootDir)}`);
178
+ console.log(` Version: ${pc.cyan(written.version)} ${pc.dim('(' + written.baseVersion + ')')}`);
179
+ console.log(` Files: ${pc.dim(Object.keys(remoteFiles).length + ' file(s)')}`);
180
+ console.log(` Manifest: ${pc.dim(getMetaPath(rootDir))}`);
181
+
182
+ const snap = await autoSnapshot(
183
+ rootDir,
184
+ `wm pull ${written.version} (${written.baseVersion})`,
185
+ { skip: options.noGit }
186
+ );
187
+ if (snap.committed) {
188
+ console.log(` Git snapshot: ${pc.dim(snap.sha)}`);
189
+ } else if (snap.skipped && snap.reason === 'no-git') {
190
+ console.log(` Git snapshot: ${pc.dim('skipped (no .git)')}`);
191
+ }
192
+ }
@@ -0,0 +1,231 @@
1
+ import { existsSync } from 'fs';
2
+ import { basename, 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 { readMeta, writeMeta } from '../utils/webmate-meta.js';
10
+ import { tenantApiFetch, resolveTenantSubdomain } from '../utils/tenant-api.js';
11
+ import {
12
+ readComponentFiles,
13
+ diffHashes,
14
+ getComponentId
15
+ } from '../utils/component-files.js';
16
+ import { autoSnapshot } from '../utils/git-snapshot.js';
17
+
18
+ function resolveComponentDir(arg) {
19
+ const cwd = process.cwd();
20
+ if (!arg) return cwd;
21
+ return resolve(cwd, arg);
22
+ }
23
+
24
+ async function fetchLatestRemote(componentId, meta) {
25
+ const sub = resolveTenantSubdomain(meta);
26
+ const path = sub
27
+ ? `/api/tenant-components/${componentId}/versions`
28
+ : `/api/organization/components/${componentId}/versions`;
29
+ const list = await tenantApiFetch(path, {}, meta);
30
+ const versions = list?.versions ?? [];
31
+ return versions[0] ?? null;
32
+ }
33
+
34
+ function summariseLocalDiff(diff) {
35
+ const parts = [];
36
+ if (diff.added.length) parts.push(`${diff.added.length} added`);
37
+ if (diff.modified.length) parts.push(`${diff.modified.length} modified`);
38
+ if (diff.removed.length) parts.push(`${diff.removed.length} removed`);
39
+ return parts.length ? parts.join(', ') : null;
40
+ }
41
+
42
+ export async function pushCommand(componentArg, options = {}) {
43
+ const rootDir = resolveComponentDir(componentArg);
44
+
45
+ if (!existsSync(rootDir)) {
46
+ logger.error(`Directory not found: ${rootDir}`);
47
+ process.exit(1);
48
+ }
49
+
50
+ let componentInfo;
51
+ try {
52
+ componentInfo = getComponentId(rootDir);
53
+ } catch (err) {
54
+ logger.error(err.message);
55
+ process.exit(1);
56
+ }
57
+
58
+ const componentId = componentInfo.id;
59
+ const meta = readMeta(rootDir);
60
+
61
+ if (!meta && !options.force) {
62
+ const proceed = await confirm({
63
+ message: `No .webmate.json found. Push ${pc.cyan(componentInfo.displayName ?? componentId)} without a baseVersion (skips conflict check)?`,
64
+ default: false
65
+ });
66
+ if (!proceed) {
67
+ logger.info('Push cancelled.');
68
+ return;
69
+ }
70
+ }
71
+
72
+ const { files, fileHashes } = readComponentFiles(rootDir);
73
+
74
+ // Only short-circuit on identical hashes when there IS a baseVersion to
75
+ // compare against. Locally-generated or freshly-cloned components have
76
+ // baseVersion=null and must always go through the upload path — their
77
+ // fileHashes match the on-disk files (we just wrote them) but the server
78
+ // has never seen them yet.
79
+ if (meta?.fileHashes && meta?.baseVersion) {
80
+ const localDiff = diffHashes(fileHashes, meta.fileHashes);
81
+ const summary = summariseLocalDiff(localDiff);
82
+ if (!summary) {
83
+ if (options.force) {
84
+ console.log(pc.dim('No local changes since last pull/push — pushing anyway (--force).'));
85
+ } else {
86
+ logger.info('No local changes since last pull/push. Nothing to upload.');
87
+ return;
88
+ }
89
+ } else {
90
+ console.log(`Local changes: ${pc.cyan(summary)}`);
91
+ }
92
+ } else if (!meta?.baseVersion) {
93
+ console.log(pc.dim('First push — uploading new component to the server.'));
94
+ }
95
+
96
+ const preflight = ora('Checking remote head…').start();
97
+ let remoteLatest;
98
+ try {
99
+ remoteLatest = await fetchLatestRemote(componentId, meta);
100
+ preflight.succeed(
101
+ remoteLatest
102
+ ? `Remote head: ${pc.cyan(remoteLatest.version)} ${pc.dim('(' + remoteLatest.id.slice(0, 8) + ')')}`
103
+ : 'Remote has no versions yet — first push.'
104
+ );
105
+ } catch (err) {
106
+ // A first-push (baseVersion=null) is expected to 404 — the component
107
+ // doesn't exist on the server yet. The POST below will create it.
108
+ if (err instanceof ApiError && err.status === 404 && !meta?.baseVersion) {
109
+ preflight.succeed('No remote yet — first push will create the component.');
110
+ remoteLatest = null;
111
+ } else if (err instanceof ApiError) {
112
+ preflight.fail(`Could not reach remote (HTTP ${err.status}): ${err.message}`);
113
+ process.exit(1);
114
+ } else {
115
+ preflight.fail(err.message);
116
+ process.exit(1);
117
+ }
118
+ }
119
+
120
+ if (meta?.baseVersion && remoteLatest && remoteLatest.id !== meta.baseVersion) {
121
+ console.log();
122
+ logger.error(
123
+ `Remote moved: your baseVersion is ${meta.baseVersion} ` +
124
+ `but remote latest is ${remoteLatest.id} (${remoteLatest.version}).`
125
+ );
126
+ console.log(` Run ${pc.bold('wm pull')} first, then push again.`);
127
+ if (!options.force) process.exit(1);
128
+ console.log(pc.yellow(' --force given: pushing anyway (server will refuse on baseVersion mismatch).'));
129
+ }
130
+
131
+ const body = { files };
132
+ if (meta?.baseVersion) body.baseVersion = meta.baseVersion;
133
+ if (options.message) body.commitMessage = options.message;
134
+
135
+ // First-push hints — these are ignored by the server when the component
136
+ // already exists. They let the server create the component on the fly
137
+ // when this is the first push for a locally-generated/cloned component.
138
+ if (!meta?.baseVersion) {
139
+ body.name = basename(rootDir);
140
+ const auth = resolveAuth();
141
+ // targetRepositoryId nur im Legacy-Modus — der tenant-Endpoint
142
+ // braucht es nicht (Component lebt direkt im Tenant).
143
+ if (!resolveTenantSubdomain(meta) && auth?.repositoryId) {
144
+ body.targetRepositoryId = auth.repositoryId;
145
+ }
146
+ if (meta?.clonedFrom) {
147
+ body.clonedFrom = meta.clonedFrom;
148
+ }
149
+ }
150
+
151
+ const spinner = ora('Uploading and building…').start();
152
+ let result;
153
+ try {
154
+ const sub = resolveTenantSubdomain(meta);
155
+ const pushPath = sub
156
+ ? `/api/tenant-components/${componentId}/versions`
157
+ : `/api/organization/components/${componentId}/versions`;
158
+ result = await tenantApiFetch(pushPath, { method: 'POST', body }, meta);
159
+ spinner.succeed(`Built and published ${pc.cyan(result.version)} ${pc.dim('(' + result.versionId.slice(0, 8) + ')')}`);
160
+ } catch (err) {
161
+ if (err instanceof ApiError) {
162
+ if (err.status === 409) {
163
+ spinner.fail(
164
+ `Conflict: remote moved to ${err.body?.currentVersion ?? '?'} ` +
165
+ `(${err.body?.currentVersionId ?? '?'}). Run \`wm pull\` first.`
166
+ );
167
+ } else if (err.status === 422) {
168
+ spinner.fail(`Build failed (HTTP 422): ${err.body?.message ?? err.message}`);
169
+ } else {
170
+ spinner.fail(`Push failed (HTTP ${err.status}): ${err.message}`);
171
+ }
172
+ } else {
173
+ spinner.fail(err.message);
174
+ }
175
+ process.exit(1);
176
+ }
177
+
178
+ // Stamp the workspace's bound repository into meta so the scanner can
179
+ // later distinguish "really deleted upstream" from "this meta was
180
+ // authored against a different project and copied here" — see
181
+ // preview-server bulk-status state classifier.
182
+ const pushAuth = resolveAuth();
183
+ const writePayload = {
184
+ componentId,
185
+ baseVersion: result.versionId,
186
+ version: result.version,
187
+ pulledAt: new Date().toISOString(),
188
+ fileHashes,
189
+ originRepositoryId: pushAuth?.repositoryId ?? null
190
+ };
191
+ // Tenant-Subdomain persistieren — der nächste push/pull weiß damit
192
+ // ohne env-Variable, dass die Component im neuen Routing lebt.
193
+ const sub = resolveTenantSubdomain(meta);
194
+ if (sub) writePayload.tenantSubdomain = sub;
195
+
196
+ const written = writeMeta(rootDir, writePayload);
197
+
198
+ logger.success(`Pushed ${pc.cyan(componentInfo.displayName ?? componentId)} as ${pc.cyan(written.version)}`);
199
+ if (result.previousVersion) {
200
+ console.log(` Previous: ${pc.dim(result.previousVersion)}`);
201
+ }
202
+ console.log(` Files: ${pc.dim(Object.keys(files).length + ' file(s)')}`);
203
+
204
+ // Impact awareness: any tenant the project is linked to via
205
+ // TenantRepositoryLink('auto') now serves this version. Surfacing
206
+ // this is the explicit mitigation against "I pushed something half-
207
+ // finished and it went live somewhere I didn't expect".
208
+ if (Array.isArray(result.impactedWebsites) && result.impactedWebsites.length > 0) {
209
+ const labels = result.impactedWebsites
210
+ .map((w) => w.name || w.subdomain || w.id)
211
+ .filter(Boolean);
212
+ console.log(
213
+ ` ${pc.yellow('Live on:')} ${labels.join(', ')} ${pc.dim('(' + labels.length + ')')}`
214
+ );
215
+ }
216
+
217
+ const snap = await autoSnapshot(
218
+ rootDir,
219
+ options.message
220
+ ? `wm push ${written.version}: ${options.message}`
221
+ : `wm push ${written.version} (${written.baseVersion})`,
222
+ { skip: options.noGit }
223
+ );
224
+ if (snap.committed) {
225
+ console.log(` Git: ${pc.dim('committed ' + snap.sha)}`);
226
+ } else if (snap.skipped && snap.reason === 'no-git') {
227
+ console.log(` Git: ${pc.dim('skipped (no .git)')}`);
228
+ } else if (snap.skipped && snap.reason === 'no-changes') {
229
+ console.log(` Git: ${pc.dim('nothing to snapshot')}`);
230
+ }
231
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `wm reset [dir]` — drop the sync pointer in `.webmate.json` so the next
3
+ * `wm push` re-registers the component as a fresh one.
4
+ *
5
+ * Why this exists: when a developer copies an existing component folder
6
+ * (incl. its `.webmate.json`) into a new workspace bound to a different
7
+ * project, the `baseVersion` it carries refers to a version-CUID that
8
+ * only exists in the original project's CMS. The status scanner sees
9
+ * "baseVersion set + CMS doesn't know this id" and flags the component
10
+ * as `deleted-upstream` ("once lived here, now gone"), which is
11
+ * misleading — the component never lived in the new project at all.
12
+ *
13
+ * Reset clears `baseVersion` and `fileHashes` but keeps `componentId`,
14
+ * so the component is back in the `never-pushed` state and `wm push`
15
+ * will create it on the target server.
16
+ *
17
+ * Workspace mode: if `dir` contains a `components/` subfolder (the
18
+ * convention used by `wm clone <project>` and `wm core clone`), reset
19
+ * every component under it. Otherwise treat `dir` as a single component
20
+ * folder.
21
+ */
22
+
23
+ import { existsSync, statSync, readdirSync } from 'fs';
24
+ import { resolve, join, basename } from 'path';
25
+ import pc from 'picocolors';
26
+ import { logger } from '@webmate-studio/core';
27
+ import { readMeta, writeMeta } from '../utils/webmate-meta.js';
28
+ import { getComponentId } from '../utils/component-files.js';
29
+
30
+ function isComponentDir(dir) {
31
+ return existsSync(join(dir, 'component.json'));
32
+ }
33
+
34
+ function listComponentDirs(rootDir) {
35
+ const componentsRoot = join(rootDir, 'components');
36
+ if (existsSync(componentsRoot) && statSync(componentsRoot).isDirectory()) {
37
+ // Workspace layout
38
+ return readdirSync(componentsRoot)
39
+ .map((name) => join(componentsRoot, name))
40
+ .filter((p) => statSync(p).isDirectory() && isComponentDir(p));
41
+ }
42
+ if (isComponentDir(rootDir)) return [rootDir];
43
+ return [];
44
+ }
45
+
46
+ function resetOne(componentDir) {
47
+ const meta = readMeta(componentDir);
48
+ const slug = basename(componentDir);
49
+
50
+ if (!meta) {
51
+ // No .webmate.json — adopt the component from its component.json so
52
+ // the preview/sidebar treats it as never-pushed (visible "Neu" badge)
53
+ // instead of unlinked (no badge, no action). Same end state as a
54
+ // reset of a stale meta, just bootstrapped from scratch.
55
+ let info;
56
+ try {
57
+ info = getComponentId(componentDir);
58
+ } catch (err) {
59
+ return { slug, skipped: true, reason: err.message };
60
+ }
61
+ writeMeta(componentDir, {
62
+ componentId: info.id,
63
+ baseVersion: null,
64
+ fileHashes: {}
65
+ });
66
+ return { slug, reset: true, adopted: true };
67
+ }
68
+
69
+ if (meta.baseVersion == null && (!meta.fileHashes || Object.keys(meta.fileHashes).length === 0)) {
70
+ return { slug, skipped: true, reason: 'already reset' };
71
+ }
72
+ writeMeta(componentDir, {
73
+ componentId: meta.componentId,
74
+ baseVersion: null,
75
+ fileHashes: {}
76
+ });
77
+ return { slug, reset: true, hadBaseVersion: !!meta.baseVersion };
78
+ }
79
+
80
+ export async function resetCommand(dirArg) {
81
+ const rootDir = resolve(process.cwd(), dirArg ?? '.');
82
+ if (!existsSync(rootDir)) {
83
+ logger.error(`Directory not found: ${rootDir}`);
84
+ process.exit(1);
85
+ }
86
+
87
+ const dirs = listComponentDirs(rootDir);
88
+ if (dirs.length === 0) {
89
+ logger.error(
90
+ `No components found in ${rootDir}. Pass a component folder or a workspace root with a components/ subfolder.`
91
+ );
92
+ process.exit(1);
93
+ }
94
+
95
+ let reset = 0;
96
+ let adopted = 0;
97
+ let skipped = 0;
98
+ for (const dir of dirs) {
99
+ const result = resetOne(dir);
100
+ if (result.adopted) {
101
+ adopted++;
102
+ console.log(` ${pc.cyan('●')} ${result.slug.padEnd(40)} ${pc.dim('adopted (new .webmate.json)')}`);
103
+ } else if (result.reset) {
104
+ reset++;
105
+ console.log(` ${pc.green('●')} ${result.slug.padEnd(40)} ${pc.dim('reset')}`);
106
+ } else {
107
+ skipped++;
108
+ console.log(` ${pc.dim('○')} ${result.slug.padEnd(40)} ${pc.dim('skipped — ' + result.reason)}`);
109
+ }
110
+ }
111
+
112
+ console.log();
113
+ console.log(` ${pc.bold(reset)} reset · ${pc.bold(adopted)} adopted · ${pc.bold(skipped)} skipped`);
114
+ if (reset + adopted > 0) {
115
+ console.log();
116
+ console.log(` Run ${pc.bold('wm push')} to register the component(s) on the server.`);
117
+ }
118
+ }