@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.
- package/README.md +222 -0
- package/bin/wm.mjs +208 -0
- package/package.json +5 -1
- package/src/commands/build.js +335 -0
- package/src/commands/clone.js +414 -0
- package/src/commands/components.js +101 -0
- package/src/commands/core.js +1039 -0
- package/src/commands/doctor.js +192 -0
- package/src/commands/install.js +312 -0
- package/src/commands/login.js +158 -0
- package/src/commands/logout.js +22 -0
- package/src/commands/projects.js +91 -0
- package/src/commands/pull.js +192 -0
- package/src/commands/push.js +231 -0
- package/src/commands/reset.js +118 -0
- package/src/commands/status.js +118 -0
- package/src/commands/versions.js +130 -0
- package/src/commands/whoami.js +64 -0
- package/src/utils/api-client.js +131 -0
- package/src/utils/auth-resolver.js +145 -0
- package/src/utils/auth-storage.js +104 -0
- package/src/utils/component-files.js +195 -0
- package/src/utils/device-flow.js +111 -0
- package/src/utils/git-snapshot.js +63 -0
- package/src/utils/tenant-api.js +103 -0
- package/src/utils/webmate-meta.js +75 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { logger } from '@webmate-studio/core';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { clearAuth, readAuth, getAuthFilePath } from '../utils/auth-storage.js';
|
|
4
|
+
|
|
5
|
+
export async function logoutCommand() {
|
|
6
|
+
if (process.env.WEBMATE_TOKEN) {
|
|
7
|
+
logger.warn(
|
|
8
|
+
'WEBMATE_TOKEN environment variable is set — `wm logout` does not unset env vars. ' +
|
|
9
|
+
'Run `unset WEBMATE_TOKEN` in your shell if needed.'
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const existing = readAuth();
|
|
14
|
+
if (!existing) {
|
|
15
|
+
logger.info(`No stored credentials at ${pc.dim(getAuthFilePath())}`);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
clearAuth();
|
|
20
|
+
const who = existing.email ?? existing.userId ?? 'unknown user';
|
|
21
|
+
logger.success(`Logged out (${pc.cyan(who)} — ${pc.dim(existing.baseUrl)})`);
|
|
22
|
+
}
|