@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.
@@ -1,22 +1,10 @@
1
1
  import { startPreviewServer } from '@webmate-studio/preview';
2
2
  import { logger } from '@webmate-studio/core';
3
- import { isLoggedIn, loadAuth } from '../utils/auth.js';
4
- import pc from 'picocolors';
5
3
 
6
4
  /**
7
5
  * Dev command - start preview server
8
- * Login is optional - if logged in, design tokens will be loaded
9
6
  */
10
7
  export async function devCommand(options) {
11
- // Check if logged in (optional)
12
- if (isLoggedIn()) {
13
- const auth = loadAuth();
14
- logger.info(`Projekt: ${pc.cyan(auth.tenant.name)}`);
15
- } else {
16
- logger.info('Nicht eingeloggt - verwende Standard Design Tokens');
17
- logger.info(`Tipp: ${pc.cyan('wm login')} um Design Tokens vom CMS zu laden`);
18
- }
19
-
20
8
  try {
21
9
  await startPreviewServer(options);
22
10
  } catch (error) {
@@ -0,0 +1,192 @@
1
+ /**
2
+ * `wm doctor` — scan the workspace for common setup issues and print a
3
+ * checklist with one-line fixes. Offline by design: every check works
4
+ * against local files only, so the command runs the same whether or
5
+ * not the user is logged in or has network access. Intended as the
6
+ * first thing to try when something feels off.
7
+ */
8
+
9
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
10
+ import { resolve, join, basename } from 'path';
11
+ import pc from 'picocolors';
12
+ import { logger } from '@webmate-studio/core';
13
+ import { readMeta } from '../utils/webmate-meta.js';
14
+ import { readAuth } from '../utils/auth-storage.js';
15
+
16
+ function readWorkspaceConfig(rootDir) {
17
+ const configPath = join(rootDir, '.webmate', 'config.json');
18
+ if (!existsSync(configPath)) return null;
19
+ try {
20
+ return JSON.parse(readFileSync(configPath, 'utf8'));
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ function listComponentDirs(rootDir) {
27
+ const componentsDir = join(rootDir, 'components');
28
+ if (!existsSync(componentsDir)) return [];
29
+ if (!statSync(componentsDir).isDirectory()) return [];
30
+ return readdirSync(componentsDir)
31
+ .map((name) => join(componentsDir, name))
32
+ .filter((p) => {
33
+ let stat;
34
+ try { stat = statSync(p); } catch { return false; }
35
+ return stat.isDirectory() && existsSync(join(p, 'component.json'));
36
+ });
37
+ }
38
+
39
+ function check(label, status, hint = null) {
40
+ return { label, status, hint };
41
+ }
42
+
43
+ function runChecks(rootDir) {
44
+ const checks = [];
45
+ const wsConfig = readWorkspaceConfig(rootDir);
46
+ const workspaceRepoId = wsConfig?.repositoryId ?? null;
47
+
48
+ // 1. Authentication
49
+ const auth = readAuth();
50
+ if (!auth) {
51
+ checks.push(check('Authentifizierung', 'warn', 'Kein Login gefunden — führe `wm login` aus für Sync-Funktionen.'));
52
+ } else {
53
+ checks.push(check(`Authentifizierung (${auth.email ?? 'unknown'})`, 'ok'));
54
+ }
55
+
56
+ // 2. Workspace binding
57
+ if (!wsConfig) {
58
+ checks.push(check('Workspace-Bindung', 'warn', 'Kein .webmate/config.json — dieser Ordner ist kein gecloneter Workspace.'));
59
+ } else if (!workspaceRepoId) {
60
+ checks.push(check('Workspace-Bindung', 'warn', '.webmate/config.json ohne repositoryId — das Workspace ist an kein Projekt gebunden.'));
61
+ } else {
62
+ checks.push(check(`Workspace gebunden an ${wsConfig.repositoryName ?? workspaceRepoId}`, 'ok'));
63
+ }
64
+
65
+ // 3. Component dirs sanity
66
+ const componentDirs = listComponentDirs(rootDir);
67
+ if (componentDirs.length === 0) {
68
+ checks.push(check('Komponenten', 'warn', 'Kein components/-Unterordner mit component.json gefunden.'));
69
+ return checks;
70
+ }
71
+ checks.push(check(`${componentDirs.length} Komponente(n) gefunden`, 'ok'));
72
+
73
+ // 4. Foreign-origin metas (the classic copy-from-other-workspace case)
74
+ let foreignOrigin = 0;
75
+ let staleNoOrigin = 0;
76
+ let unlinked = 0;
77
+ for (const dir of componentDirs) {
78
+ const meta = readMeta(dir);
79
+ if (!meta) {
80
+ unlinked++;
81
+ continue;
82
+ }
83
+ if (!meta.baseVersion) continue;
84
+ if (meta.originRepositoryId && workspaceRepoId && meta.originRepositoryId !== workspaceRepoId) {
85
+ foreignOrigin++;
86
+ } else if (!meta.originRepositoryId) {
87
+ // Legacy meta — we can't be sure if it's stale; only flag as
88
+ // suspicious, not as an error.
89
+ staleNoOrigin++;
90
+ }
91
+ }
92
+ if (foreignOrigin > 0) {
93
+ checks.push(
94
+ check(
95
+ `${foreignOrigin} Komponente(n) zeigen auf ein anderes Projekt`,
96
+ 'error',
97
+ `Führe \`wm reset\` aus, um die Sync-Pointer für dieses Workspace zurückzusetzen.`
98
+ )
99
+ );
100
+ }
101
+ if (unlinked > 0) {
102
+ checks.push(
103
+ check(
104
+ `${unlinked} Komponente(n) ohne .webmate.json`,
105
+ 'warn',
106
+ `Führe \`wm reset\` aus, um sie als „neu" zu adoptieren.`
107
+ )
108
+ );
109
+ }
110
+ if (foreignOrigin === 0 && unlinked === 0 && staleNoOrigin > 0) {
111
+ checks.push(
112
+ check(
113
+ `${staleNoOrigin} Komponente(n) ohne Origin-Stempel`,
114
+ 'info',
115
+ `Alter Meta-Stand. Beim nächsten Push wird das Feld nachgetragen.`
116
+ )
117
+ );
118
+ }
119
+
120
+ // 5. component.json integrity
121
+ const dupIds = new Map();
122
+ const missingIds = [];
123
+ for (const dir of componentDirs) {
124
+ const componentJsonPath = join(dir, 'component.json');
125
+ try {
126
+ const data = JSON.parse(readFileSync(componentJsonPath, 'utf8'));
127
+ if (!data?.id) {
128
+ missingIds.push(basename(dir));
129
+ } else {
130
+ const list = dupIds.get(data.id) ?? [];
131
+ list.push(basename(dir));
132
+ dupIds.set(data.id, list);
133
+ }
134
+ } catch {
135
+ missingIds.push(basename(dir));
136
+ }
137
+ }
138
+ if (missingIds.length > 0) {
139
+ checks.push(
140
+ check(
141
+ `${missingIds.length} component.json ohne id`,
142
+ 'error',
143
+ `Betroffen: ${missingIds.join(', ')}. Jede Komponente braucht eine UUID/CUID im id-Feld.`
144
+ )
145
+ );
146
+ }
147
+ const dups = [...dupIds.entries()].filter(([, list]) => list.length > 1);
148
+ if (dups.length > 0) {
149
+ const lines = dups.map(([id, list]) => ` ${pc.dim(id.slice(0, 8) + '…')}: ${list.join(' / ')}`).join('\n');
150
+ checks.push(
151
+ check(
152
+ `${dups.length} component.json-id mehrfach vergeben`,
153
+ 'error',
154
+ `Doppelte IDs lassen Pushes silent überschreiben. Vergib neue UUIDs:\n${lines}`
155
+ )
156
+ );
157
+ }
158
+
159
+ return checks;
160
+ }
161
+
162
+ const DOT = {
163
+ ok: pc.green('●'),
164
+ warn: pc.yellow('●'),
165
+ error: pc.red('●'),
166
+ info: pc.cyan('●')
167
+ };
168
+
169
+ export async function doctorCommand(dirArg) {
170
+ const rootDir = resolve(process.cwd(), dirArg ?? '.');
171
+ if (!existsSync(rootDir)) {
172
+ logger.error(`Directory not found: ${rootDir}`);
173
+ process.exit(1);
174
+ }
175
+
176
+ const checks = runChecks(rootDir);
177
+ console.log();
178
+ console.log(` ${pc.bold('wm doctor')} — ${pc.dim(rootDir)}`);
179
+ console.log();
180
+ for (const c of checks) {
181
+ console.log(` ${DOT[c.status]} ${c.label}`);
182
+ if (c.hint) {
183
+ for (const line of c.hint.split('\n')) {
184
+ console.log(` ${pc.gray(line)}`);
185
+ }
186
+ }
187
+ }
188
+ console.log();
189
+
190
+ const hasError = checks.some((c) => c.status === 'error');
191
+ if (hasError) process.exit(1);
192
+ }
@@ -0,0 +1,312 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync, rmSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { spawn } from 'child_process';
4
+ import { loadConfig, logger } from '@webmate-studio/core';
5
+ import { confirm } from '@inquirer/prompts';
6
+ import pc from 'picocolors';
7
+
8
+ /**
9
+ * `wm install` — installs npm dependencies for the project root and every
10
+ * component that ships its own package.json. Skips paths that already have
11
+ * a node_modules folder unless --force is passed. After all targets have
12
+ * been processed, prints a summary table showing what was added vs. left
13
+ * untouched, so component-heavy projects don't drown in npm chatter.
14
+ *
15
+ * Why this exists: the build service runs `npm install` per component as
16
+ * part of the build, but the preview server can't bundle islands whose
17
+ * imports come from those un-installed dependencies. This brings the
18
+ * preview into parity with the build service.
19
+ */
20
+ export async function installCommand(options = {}) {
21
+ const cwd = process.cwd();
22
+ const force = !!options.force;
23
+ const prune = !!options.prune;
24
+
25
+ const config = await loadConfig().catch(() => null);
26
+ const componentsPath = config?.components?.path || 'components';
27
+ const componentsDir = join(cwd, componentsPath);
28
+
29
+ if (prune) {
30
+ const result = await runPrune(cwd, componentsDir);
31
+ if (result === 'aborted') return;
32
+ process.stdout.write('\n');
33
+ }
34
+
35
+ const targets = [];
36
+ let emptyPackageCount = 0;
37
+ if (existsSync(join(cwd, 'package.json'))) {
38
+ if (readDepCount(cwd) > 0) targets.push({ label: 'project root', dir: cwd });
39
+ else emptyPackageCount++;
40
+ }
41
+ if (existsSync(componentsDir)) {
42
+ for (const entry of readdirSync(componentsDir)) {
43
+ const componentDir = join(componentsDir, entry);
44
+ let stat;
45
+ try { stat = statSync(componentDir); } catch { continue; }
46
+ if (!stat.isDirectory()) continue;
47
+ const pkg = join(componentDir, 'package.json');
48
+ if (!existsSync(pkg)) continue;
49
+ // Skip empty package.json files (no dependencies/devDependencies).
50
+ // `wm generate` and `wm init` emit a stub package.json by default —
51
+ // running npm install in them just creates noisy empty lockfiles.
52
+ if (readDepCount(componentDir) === 0) {
53
+ emptyPackageCount++;
54
+ continue;
55
+ }
56
+ targets.push({ label: `components/${entry}`, dir: componentDir });
57
+ }
58
+ }
59
+
60
+ if (targets.length === 0) {
61
+ if (emptyPackageCount > 0) {
62
+ logger.info(`Found ${emptyPackageCount} package.json file${emptyPackageCount === 1 ? '' : 's'} but none with dependencies — nothing to install.`);
63
+ } else {
64
+ logger.info('No package.json found in project root or any component directory.');
65
+ }
66
+ return;
67
+ }
68
+
69
+ const targetLine = `Found ${pc.cyan(targets.length)} package.json file${targets.length === 1 ? '' : 's'} with dependencies`;
70
+ const skipLine = emptyPackageCount > 0 ? pc.gray(` (skipped ${emptyPackageCount} empty)`) : '';
71
+ logger.info(targetLine + skipLine + '.');
72
+
73
+ const results = [];
74
+
75
+ for (const target of targets) {
76
+ const nodeModules = join(target.dir, 'node_modules');
77
+ const depCount = readDepCount(target.dir);
78
+
79
+ if (!force && existsSync(nodeModules)) {
80
+ results.push({ label: target.label, status: 'skipped', depCount, added: 0, removed: 0, durationMs: 0, error: null });
81
+ printRow(target.label, 'skipped', '—');
82
+ continue;
83
+ }
84
+
85
+ const start = Date.now();
86
+ try {
87
+ const output = await runNpmInstall(target.dir);
88
+ const durationMs = Date.now() - start;
89
+ const { added, removed, upToDate } = parseNpmOutput(output);
90
+ const status = upToDate ? 'up-to-date' : (added > 0 ? 'added' : 'installed');
91
+ results.push({ label: target.label, status, depCount, added, removed, durationMs, error: null });
92
+ printRow(target.label, status, formatChange(added, removed, durationMs));
93
+ } catch (err) {
94
+ const durationMs = Date.now() - start;
95
+ results.push({ label: target.label, status: 'failed', depCount, added: 0, removed: 0, durationMs, error: err.message });
96
+ printRow(target.label, 'failed', err.message);
97
+ }
98
+ }
99
+
100
+ printSummary(results);
101
+
102
+ maybePrintCopiedComponentsHint(componentsDir);
103
+
104
+ if (results.some(r => r.status === 'failed')) process.exit(1);
105
+ }
106
+
107
+ /**
108
+ * After a fresh install, look for the telltale sign of a workspace seeded
109
+ * by copying components from somewhere else: `.webmate.json` files that
110
+ * already carry a baseVersion pointing at a different project's CMS. The
111
+ * preview-server status scanner would then mark them as "Im CMS gelöscht",
112
+ * which is misleading. Suggest `wm reset` so the user knows the fix
113
+ * exists without having to discover it by accident.
114
+ *
115
+ * The hint is intentionally a soft suggestion, not a hard claim — we
116
+ * can't tell offline whether the baseVersion is legitimately pointing
117
+ * at this workspace's CMS or at a foreign one.
118
+ */
119
+ function maybePrintCopiedComponentsHint(componentsDir) {
120
+ if (!existsSync(componentsDir)) return;
121
+ let pinned = 0;
122
+ for (const entry of readdirSync(componentsDir)) {
123
+ const metaPath = join(componentsDir, entry, '.webmate.json');
124
+ if (!existsSync(metaPath)) continue;
125
+ try {
126
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
127
+ if (meta?.baseVersion) pinned++;
128
+ } catch {
129
+ // ignore malformed metas — they'll surface elsewhere
130
+ }
131
+ }
132
+ if (pinned === 0) return;
133
+
134
+ process.stdout.write(
135
+ ' ' + pc.cyan('ℹ') + ` ${pc.gray('Falls du Komponenten aus einem anderen Workspace kopiert hast,')}` + '\n' +
136
+ ' ' + ' ' + pc.gray(`führe ${pc.bold('wm reset')} aus, um veraltete Sync-Pointer zurückzusetzen.`) + '\n\n'
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Walk project root + components looking for stub package.json files
142
+ * (no dependencies / devDependencies) that nonetheless have a
143
+ * package-lock.json or node_modules/ sitting next to them — left
144
+ * behind by an earlier `wm install` before the stub-filter landed.
145
+ *
146
+ * The package.json itself is left in place so the user's later
147
+ * `npm install <pkg>` works against it. Only the lockfile and
148
+ * node_modules/ are removed, with explicit confirmation.
149
+ */
150
+ async function runPrune(cwd, componentsDir) {
151
+ const candidates = [];
152
+ const scan = (dir, label) => {
153
+ if (!existsSync(join(dir, 'package.json'))) return;
154
+ if (readDepCount(dir) !== 0) return;
155
+ const hasLock = existsSync(join(dir, 'package-lock.json'));
156
+ const hasNm = existsSync(join(dir, 'node_modules'));
157
+ if (!hasLock && !hasNm) return;
158
+ candidates.push({ label, dir, hasLock, hasNm });
159
+ };
160
+
161
+ scan(cwd, 'project root');
162
+ if (existsSync(componentsDir)) {
163
+ for (const entry of readdirSync(componentsDir)) {
164
+ const componentDir = join(componentsDir, entry);
165
+ let stat;
166
+ try { stat = statSync(componentDir); } catch { continue; }
167
+ if (!stat.isDirectory()) continue;
168
+ scan(componentDir, `components/${entry}`);
169
+ }
170
+ }
171
+
172
+ if (candidates.length === 0) {
173
+ logger.info('Nothing to prune — no leftover lockfiles or node_modules under stub package.json files.');
174
+ return 'nothing';
175
+ }
176
+
177
+ logger.info(`Found ${pc.cyan(candidates.length)} stub package.json file${candidates.length === 1 ? '' : 's'} with leftover artefacts:`);
178
+ for (const c of candidates) {
179
+ const parts = [];
180
+ if (c.hasLock) parts.push('package-lock.json');
181
+ if (c.hasNm) parts.push('node_modules/');
182
+ const truncated = c.label.length > LABEL_COL ? c.label.slice(0, LABEL_COL - 1) + '…' : c.label.padEnd(LABEL_COL);
183
+ process.stdout.write(` ${pc.gray('•')} ${truncated} ${pc.gray(parts.join(' + '))}\n`);
184
+ }
185
+ process.stdout.write(` ${pc.gray('package.json itself is kept so future `npm install <pkg>` works.')}\n\n`);
186
+
187
+ const ok = await confirm({
188
+ message: `Delete these from ${candidates.length} ${candidates.length === 1 ? 'directory' : 'directories'}?`,
189
+ default: false
190
+ });
191
+
192
+ if (!ok) {
193
+ logger.info('Prune aborted.');
194
+ return 'aborted';
195
+ }
196
+
197
+ for (const c of candidates) {
198
+ try {
199
+ if (c.hasLock) rmSync(join(c.dir, 'package-lock.json'), { force: true });
200
+ if (c.hasNm) rmSync(join(c.dir, 'node_modules'), { recursive: true, force: true });
201
+ process.stdout.write(` ${pc.green('✓')} ${c.label}\n`);
202
+ } catch (err) {
203
+ process.stdout.write(` ${pc.red('✗')} ${c.label} ${pc.gray(err.message)}\n`);
204
+ }
205
+ }
206
+ return 'done';
207
+ }
208
+
209
+ function readDepCount(dir) {
210
+ try {
211
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
212
+ return Object.keys(pkg.dependencies || {}).length + Object.keys(pkg.devDependencies || {}).length;
213
+ } catch {
214
+ return 0;
215
+ }
216
+ }
217
+
218
+ function runNpmInstall(cwd) {
219
+ return new Promise((resolve, reject) => {
220
+ const child = spawn('npm', ['install', '--no-audit', '--no-fund', '--loglevel=error'], {
221
+ cwd,
222
+ stdio: ['ignore', 'pipe', 'pipe'],
223
+ shell: process.platform === 'win32'
224
+ });
225
+ let stdout = '';
226
+ let stderr = '';
227
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
228
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
229
+ child.on('error', reject);
230
+ child.on('close', (code) => {
231
+ if (code === 0) resolve(stdout);
232
+ else reject(new Error(stderr.trim().split('\n')[0] || `npm install exited with code ${code}`));
233
+ });
234
+ });
235
+ }
236
+
237
+ function parseNpmOutput(stdout) {
238
+ const added = (stdout.match(/added (\d+) packages?/) || [, 0])[1];
239
+ const removed = (stdout.match(/removed (\d+) packages?/) || [, 0])[1];
240
+ const upToDate = /up to date/.test(stdout) && Number(added) === 0;
241
+ return { added: Number(added), removed: Number(removed), upToDate };
242
+ }
243
+
244
+ function formatChange(added, removed, durationMs) {
245
+ const parts = [];
246
+ if (added) parts.push(`+${added}`);
247
+ if (removed) parts.push(`-${removed}`);
248
+ if (parts.length === 0) parts.push('no change');
249
+ parts.push(pc.gray(`${durationMs}ms`));
250
+ return parts.join(' ');
251
+ }
252
+
253
+ const LABEL_COL = 38;
254
+
255
+ function printRow(label, status, detail) {
256
+ const dot = STATUS_DOT[status] || pc.gray('•');
257
+ const truncated = label.length > LABEL_COL ? label.slice(0, LABEL_COL - 1) + '…' : label.padEnd(LABEL_COL);
258
+ const coloredStatus = colorStatus(status).padEnd(12);
259
+ process.stdout.write(` ${dot} ${truncated} ${coloredStatus} ${detail}\n`);
260
+ }
261
+
262
+ const STATUS_DOT = {
263
+ 'added': pc.green('●'),
264
+ 'installed': pc.green('●'),
265
+ 'up-to-date': pc.gray('●'),
266
+ 'skipped': pc.gray('○'),
267
+ 'failed': pc.red('●')
268
+ };
269
+
270
+ function colorStatus(status) {
271
+ switch (status) {
272
+ case 'added': return pc.green('added');
273
+ case 'installed': return pc.green('installed');
274
+ case 'up-to-date': return pc.gray('up-to-date');
275
+ case 'skipped': return pc.gray('skipped');
276
+ case 'failed': return pc.red('failed');
277
+ default: return status;
278
+ }
279
+ }
280
+
281
+ function printSummary(results) {
282
+ const added = results.filter(r => r.status === 'added').length;
283
+ const installed = results.filter(r => r.status === 'installed').length;
284
+ const upToDate = results.filter(r => r.status === 'up-to-date').length;
285
+ const skipped = results.filter(r => r.status === 'skipped').length;
286
+ const failed = results.filter(r => r.status === 'failed').length;
287
+ const totalAdded = results.reduce((s, r) => s + r.added, 0);
288
+ const totalRemoved = results.reduce((s, r) => s + r.removed, 0);
289
+
290
+ process.stdout.write('\n');
291
+ const parts = [];
292
+ if (added + installed) parts.push(pc.green(`${added + installed} installed`));
293
+ if (upToDate) parts.push(pc.gray(`${upToDate} up-to-date`));
294
+ if (skipped) parts.push(pc.gray(`${skipped} skipped`));
295
+ if (failed) parts.push(pc.red(`${failed} failed`));
296
+ process.stdout.write(' ' + parts.join(' · ') + '\n');
297
+
298
+ if (totalAdded || totalRemoved) {
299
+ const change = [];
300
+ if (totalAdded) change.push(pc.green(`+${totalAdded} packages`));
301
+ if (totalRemoved) change.push(pc.yellow(`-${totalRemoved} packages`));
302
+ process.stdout.write(' ' + change.join(' · ') + '\n');
303
+ }
304
+
305
+ if (failed > 0) {
306
+ process.stdout.write('\n ' + pc.red('Failed targets:') + '\n');
307
+ for (const r of results.filter(x => x.status === 'failed')) {
308
+ process.stdout.write(` ${pc.red(r.label)} ${pc.gray(r.error)}\n`);
309
+ }
310
+ }
311
+ process.stdout.write('\n');
312
+ }
@@ -0,0 +1,158 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { exec } from 'child_process';
3
+ import ora from 'ora';
4
+ import { logger } from '@webmate-studio/core';
5
+ import pc from 'picocolors';
6
+ import { writeAuth, getAuthFilePath, readAuth } from '../utils/auth-storage.js';
7
+ import { apiFetch, ApiError } from '../utils/api-client.js';
8
+ import { getDefaultBaseUrl } from '../utils/auth-resolver.js';
9
+ import { startDeviceAuthorization, pollForDeviceToken, DeviceFlowError } from '../utils/device-flow.js';
10
+
11
+ function openInBrowser(url) {
12
+ const platform = process.platform;
13
+ const cmd =
14
+ platform === 'darwin' ? `open "${url}"`
15
+ : platform === 'win32' ? `start "" "${url}"`
16
+ : `xdg-open "${url}"`;
17
+ exec(cmd, () => {});
18
+ }
19
+
20
+ async function validateAndFetchIdentity({ token, baseUrl }) {
21
+ const me = await apiFetch('/api/auth/me', { token, baseUrl });
22
+ const user = me?.data ?? me?.user ?? me;
23
+ return {
24
+ userId: user?.id ?? null,
25
+ email: user?.email ?? null,
26
+ organizationId: user?.organizationId ?? null,
27
+ organizationSlug: user?.organizationSlug ?? null,
28
+ firstName: user?.firstName ?? null,
29
+ lastName: user?.lastName ?? null
30
+ };
31
+ }
32
+
33
+ async function loginViaDeviceFlow(baseUrl) {
34
+ const init = ora('Requesting device code from Webmate…').start();
35
+ let authz;
36
+ try {
37
+ authz = await startDeviceAuthorization(baseUrl);
38
+ init.succeed('Device code received.');
39
+ } catch (err) {
40
+ init.fail(`Could not reach ${baseUrl}: ${err.message}`);
41
+ process.exit(1);
42
+ }
43
+
44
+ const { userCode, verificationUrl, expiresIn, pollInterval } = authz;
45
+
46
+ console.log();
47
+ console.log(pc.bold(' Open this URL in your browser to authorize:'));
48
+ console.log(` ${pc.cyan(verificationUrl)}`);
49
+ console.log();
50
+ console.log(` Verification code: ${pc.bold(pc.yellow(userCode))}`);
51
+ console.log(pc.dim(` (Expires in ${Math.round(expiresIn / 60)} minutes)`));
52
+ console.log();
53
+
54
+ openInBrowser(verificationUrl);
55
+
56
+ const spinner = ora('Waiting for browser authorization…').start();
57
+
58
+ let result;
59
+ try {
60
+ result = await pollForDeviceToken(baseUrl, userCode, {
61
+ intervalSeconds: pollInterval,
62
+ expiresInSeconds: expiresIn,
63
+ onTick: (tick) => {
64
+ if (tick.status === 'network-error') {
65
+ spinner.text = `Waiting for browser authorization… (transient network error, retrying)`;
66
+ }
67
+ }
68
+ });
69
+ spinner.succeed('Authorization approved.');
70
+ } catch (err) {
71
+ if (err instanceof DeviceFlowError) {
72
+ spinner.fail(err.message);
73
+ } else {
74
+ spinner.fail(`Unexpected error: ${err.message}`);
75
+ }
76
+ process.exit(1);
77
+ }
78
+
79
+ return {
80
+ token: result.token,
81
+ email: result.user?.email ?? null,
82
+ userId: result.user?.id ?? null,
83
+ firstName: result.user?.firstName ?? null,
84
+ lastName: result.user?.lastName ?? null,
85
+ organizationId: result.organization?.id ?? null,
86
+ organizationSlug: result.organization?.slug ?? null
87
+ };
88
+ }
89
+
90
+ async function loginViaToken({ token, baseUrl }) {
91
+ const spinner = ora('Validating token…').start();
92
+ let identity;
93
+ try {
94
+ identity = await validateAndFetchIdentity({ token, baseUrl });
95
+ spinner.succeed('Token accepted.');
96
+ } catch (err) {
97
+ if (err instanceof ApiError) {
98
+ spinner.fail(`Token rejected by ${baseUrl} (HTTP ${err.status}): ${err.message}`);
99
+ } else {
100
+ spinner.fail(`Could not reach ${baseUrl}: ${err.message}`);
101
+ }
102
+ process.exit(1);
103
+ }
104
+ return { token, ...identity };
105
+ }
106
+
107
+ export async function loginCommand(options = {}) {
108
+ const existing = readAuth();
109
+ if (existing && !options.force) {
110
+ const overwrite = await confirm({
111
+ message: `Already logged in as ${pc.cyan(existing.email ?? existing.userId ?? 'unknown')} at ${pc.dim(existing.baseUrl)}. Overwrite?`,
112
+ default: false
113
+ });
114
+ if (!overwrite) {
115
+ logger.info('Login cancelled.');
116
+ return;
117
+ }
118
+ }
119
+
120
+ let baseUrl;
121
+ let baseUrlNote = null;
122
+ if (options.baseUrl) {
123
+ baseUrl = options.baseUrl;
124
+ } else {
125
+ const resolved = getDefaultBaseUrl();
126
+ baseUrl = resolved.baseUrl;
127
+ if (resolved.source === 'workspace') {
128
+ baseUrlNote = `using baseUrl from ${resolved.path}`;
129
+ } else if (resolved.source === 'env') {
130
+ baseUrlNote = 'using WEBMATE_BASE_URL environment variable';
131
+ }
132
+ }
133
+ baseUrl = baseUrl.replace(/\/+$/, '');
134
+ if (baseUrlNote) {
135
+ console.log(pc.dim(` ${baseUrlNote}`));
136
+ }
137
+
138
+ const session = options.token
139
+ ? await loginViaToken({ token: options.token, baseUrl })
140
+ : await loginViaDeviceFlow(baseUrl);
141
+
142
+ const saved = writeAuth({
143
+ baseUrl,
144
+ token: session.token,
145
+ userId: session.userId,
146
+ email: session.email,
147
+ organizationId: session.organizationId,
148
+ organizationSlug: session.organizationSlug
149
+ });
150
+
151
+ const who = session.email ?? session.userId ?? 'unknown user';
152
+ logger.success(`Logged in as ${pc.cyan(who)}`);
153
+ console.log(` Base URL: ${pc.dim(saved.baseUrl)}`);
154
+ if (session.organizationId) {
155
+ console.log(` Organization: ${pc.dim(session.organizationSlug ?? session.organizationId)}`);
156
+ }
157
+ console.log(` Stored in: ${pc.dim(getAuthFilePath())}`);
158
+ }