@webmate-studio/cli 0.3.61 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,104 @@
1
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, chmodSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { dirname, join } from 'path';
4
+
5
+ const AUTH_DIR = join(homedir(), '.webmate');
6
+ const AUTH_FILE = join(AUTH_DIR, 'auth.json');
7
+ const FILE_MODE = 0o600;
8
+ const DIR_MODE = 0o700;
9
+
10
+ export function getAuthFilePath() {
11
+ return AUTH_FILE;
12
+ }
13
+
14
+ export function readAuth() {
15
+ if (!existsSync(AUTH_FILE)) return null;
16
+ try {
17
+ const raw = readFileSync(AUTH_FILE, 'utf-8');
18
+ const data = JSON.parse(raw);
19
+ if (!data?.token || !data?.baseUrl) return null;
20
+ return data;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ export function writeAuth(auth) {
27
+ if (!auth?.token || !auth?.baseUrl) {
28
+ throw new Error('writeAuth requires token and baseUrl');
29
+ }
30
+ if (!existsSync(AUTH_DIR)) {
31
+ mkdirSync(AUTH_DIR, { recursive: true, mode: DIR_MODE });
32
+ }
33
+ const payload = {
34
+ version: 1,
35
+ baseUrl: auth.baseUrl,
36
+ token: auth.token,
37
+ userId: auth.userId ?? null,
38
+ email: auth.email ?? null,
39
+ organizationId: auth.organizationId ?? null,
40
+ organizationSlug: auth.organizationSlug ?? null,
41
+ createdAt: auth.createdAt ?? new Date().toISOString()
42
+ };
43
+ writeFileSync(AUTH_FILE, JSON.stringify(payload, null, 2), { mode: FILE_MODE });
44
+ try {
45
+ chmodSync(AUTH_FILE, FILE_MODE);
46
+ } catch {
47
+ // ignore — best effort on platforms without POSIX perms
48
+ }
49
+ return payload;
50
+ }
51
+
52
+ export function clearAuth() {
53
+ if (!existsSync(AUTH_FILE)) return false;
54
+ unlinkSync(AUTH_FILE);
55
+ return true;
56
+ }
57
+
58
+ /**
59
+ * Decode the payload of a JWT without verifying the signature.
60
+ * The CLI only uses this to peek at `exp` for the refresh window —
61
+ * the server still validates the signature on the actual request.
62
+ *
63
+ * Returns null for anything that does not look like a JWT.
64
+ */
65
+ export function decodeJwtPayload(token) {
66
+ if (typeof token !== 'string' || !token.startsWith('ey')) return null;
67
+ const parts = token.split('.');
68
+ if (parts.length !== 3) return null;
69
+ try {
70
+ const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/');
71
+ const padLen = (4 - (padded.length % 4)) % 4;
72
+ const b64 = padded + '='.repeat(padLen);
73
+ const json = Buffer.from(b64, 'base64').toString('utf-8');
74
+ return JSON.parse(json);
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Seconds remaining until the JWT expires, or null if not a JWT
82
+ * (or if the token has no `exp` claim).
83
+ */
84
+ export function jwtSecondsToExpiry(token) {
85
+ const payload = decodeJwtPayload(token);
86
+ if (!payload?.exp) return null;
87
+ const nowSec = Math.floor(Date.now() / 1000);
88
+ return payload.exp - nowSec;
89
+ }
90
+
91
+ /**
92
+ * Best-effort local check: is the cached `~/.webmate/auth.json` JWT for a
93
+ * SuperAdmin? Used to decide whether to surface the `wm core` command in
94
+ * `wm --help`. Returns false if no auth file, the token isn't a JWT, or the
95
+ * payload lacks the claim. The server still enforces real authorization on
96
+ * every `wm core …` call, so this is purely a UX filter — anyone whose
97
+ * cached token is stale can still try the command and will just get a 403.
98
+ */
99
+ export function isLocallyKnownSuperAdmin() {
100
+ const auth = readAuth();
101
+ if (!auth?.token) return false;
102
+ const payload = decodeJwtPayload(auth.token);
103
+ return payload?.isSuperAdmin === true;
104
+ }
@@ -0,0 +1,195 @@
1
+ import { readdirSync, readFileSync, statSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'fs';
2
+ import { dirname, join, posix, relative, sep } from 'path';
3
+ import crypto from 'crypto';
4
+
5
+ const ALWAYS_TEXT_EXT = new Set([
6
+ '.html', '.htm', '.json', '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx',
7
+ '.svelte', '.vue', '.css', '.scss', '.sass', '.less', '.svg', '.md',
8
+ '.markdown', '.txt', '.xml', '.yaml', '.yml', '.toml'
9
+ ]);
10
+
11
+ const ALWAYS_BINARY_EXT = new Set([
12
+ '.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.ico', '.bmp',
13
+ '.tiff', '.tif', '.pdf', '.woff', '.woff2', '.ttf', '.otf', '.eot',
14
+ '.mp3', '.mp4', '.webm', '.ogg', '.wav', '.zip', '.gz'
15
+ ]);
16
+
17
+ const EXCLUDED_DIRS = new Set([
18
+ 'node_modules', '.git', '.svn', '.hg', '.DS_Store', 'dist', 'build',
19
+ '.cache', '.turbo', '.next', '.svelte-kit', '.wm-cache', '.webmate'
20
+ ]);
21
+
22
+ const EXCLUDED_FILES = new Set([
23
+ '.webmate.json', '.DS_Store', '.gitignore', '.npmignore', 'package-lock.json',
24
+ 'yarn.lock', 'pnpm-lock.yaml'
25
+ ]);
26
+
27
+ function extensionOf(filename) {
28
+ const dot = filename.lastIndexOf('.');
29
+ return dot < 0 ? '' : filename.slice(dot).toLowerCase();
30
+ }
31
+
32
+ function isBinaryExtension(ext) {
33
+ if (ALWAYS_BINARY_EXT.has(ext)) return true;
34
+ if (ALWAYS_TEXT_EXT.has(ext)) return false;
35
+ return false;
36
+ }
37
+
38
+ function isProbablyBinaryBuffer(buf, sampleSize = 512) {
39
+ const len = Math.min(buf.length, sampleSize);
40
+ for (let i = 0; i < len; i++) {
41
+ const b = buf[i];
42
+ if (b === 0) return true;
43
+ if (b < 7 && b !== 0) return true;
44
+ }
45
+ return false;
46
+ }
47
+
48
+ function toPosixPath(p) {
49
+ return p.split(sep).join(posix.sep);
50
+ }
51
+
52
+ // Canonicalise text content before hashing so editor defaults
53
+ // ("Insert Final Newline", CRLF on Windows) don't manifest as a
54
+ // false-positive "modified" state. The file on disk stays untouched —
55
+ // only the hash sees the normalised form. Binary files bypass this and
56
+ // stay byte-exact.
57
+ function normalizeTextForHash(text) {
58
+ if (text === '') return '';
59
+ let s = text.replace(/\r\n/g, '\n');
60
+ if (!s.endsWith('\n')) s += '\n';
61
+ return s;
62
+ }
63
+
64
+ export function walkComponent(rootDir) {
65
+ if (!existsSync(rootDir)) {
66
+ throw new Error(`Component directory not found: ${rootDir}`);
67
+ }
68
+
69
+ const results = [];
70
+
71
+ function recurse(absDir, relDir) {
72
+ const entries = readdirSync(absDir, { withFileTypes: true });
73
+ for (const entry of entries) {
74
+ if (EXCLUDED_DIRS.has(entry.name)) continue;
75
+ if (entry.isDirectory()) {
76
+ recurse(join(absDir, entry.name), relDir ? `${relDir}/${entry.name}` : entry.name);
77
+ continue;
78
+ }
79
+ if (!entry.isFile()) continue;
80
+ if (EXCLUDED_FILES.has(entry.name)) continue;
81
+ const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
82
+ results.push({
83
+ absPath: join(absDir, entry.name),
84
+ relPath: toPosixPath(relPath)
85
+ });
86
+ }
87
+ }
88
+
89
+ recurse(rootDir, '');
90
+ results.sort((a, b) => (a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0));
91
+ return results;
92
+ }
93
+
94
+ export function readComponentFiles(rootDir) {
95
+ const entries = walkComponent(rootDir);
96
+ const files = {};
97
+ const fileHashes = {};
98
+
99
+ for (const { absPath, relPath } of entries) {
100
+ const buf = readFileSync(absPath);
101
+ const ext = extensionOf(relPath);
102
+ const binary = isBinaryExtension(ext) || (!ALWAYS_TEXT_EXT.has(ext) && isProbablyBinaryBuffer(buf));
103
+ if (binary) {
104
+ fileHashes[relPath] = sha256Hex(buf);
105
+ files[relPath] = `base64:${buf.toString('base64')}`;
106
+ } else {
107
+ const text = buf.toString('utf-8');
108
+ fileHashes[relPath] = sha256Hex(normalizeTextForHash(text));
109
+ files[relPath] = text;
110
+ }
111
+ }
112
+
113
+ return { files, fileHashes };
114
+ }
115
+
116
+ export function sha256Hex(input) {
117
+ return crypto.createHash('sha256').update(input).digest('hex');
118
+ }
119
+
120
+ export function computeFileHashesFromMap(filesMap) {
121
+ const out = {};
122
+ for (const [relPath, content] of Object.entries(filesMap)) {
123
+ if (content.startsWith('base64:')) {
124
+ out[relPath] = sha256Hex(Buffer.from(content.slice(7), 'base64'));
125
+ } else {
126
+ out[relPath] = sha256Hex(normalizeTextForHash(content));
127
+ }
128
+ }
129
+ return out;
130
+ }
131
+
132
+ export function writeComponentFiles(rootDir, filesMap, { clean = false } = {}) {
133
+ if (!existsSync(rootDir)) {
134
+ mkdirSync(rootDir, { recursive: true });
135
+ }
136
+
137
+ if (clean) {
138
+ const existing = walkComponent(rootDir);
139
+ const incomingPaths = new Set(Object.keys(filesMap));
140
+ for (const { absPath, relPath } of existing) {
141
+ if (!incomingPaths.has(relPath)) {
142
+ rmSync(absPath, { force: true });
143
+ }
144
+ }
145
+ }
146
+
147
+ for (const [relPath, content] of Object.entries(filesMap)) {
148
+ const absPath = join(rootDir, ...relPath.split('/'));
149
+ mkdirSync(dirname(absPath), { recursive: true });
150
+ if (content.startsWith('base64:')) {
151
+ writeFileSync(absPath, Buffer.from(content.slice(7), 'base64'));
152
+ } else {
153
+ writeFileSync(absPath, content, 'utf-8');
154
+ }
155
+ }
156
+ }
157
+
158
+ export function diffHashes(localHashes, baseHashes) {
159
+ const added = [];
160
+ const modified = [];
161
+ const removed = [];
162
+ const baseKeys = new Set(Object.keys(baseHashes));
163
+ for (const [path, hash] of Object.entries(localHashes)) {
164
+ if (!baseKeys.has(path)) {
165
+ added.push(path);
166
+ } else if (baseHashes[path] !== hash) {
167
+ modified.push(path);
168
+ }
169
+ baseKeys.delete(path);
170
+ }
171
+ for (const path of baseKeys) {
172
+ removed.push(path);
173
+ }
174
+ return { added, modified, removed };
175
+ }
176
+
177
+ export function getComponentId(rootDir) {
178
+ const componentJsonPath = join(rootDir, 'component.json');
179
+ if (!existsSync(componentJsonPath)) {
180
+ throw new Error(`component.json not found in ${rootDir}`);
181
+ }
182
+ const raw = readFileSync(componentJsonPath, 'utf-8');
183
+ let data;
184
+ try {
185
+ data = JSON.parse(raw);
186
+ } catch (err) {
187
+ throw new Error(`Invalid component.json in ${rootDir}: ${err.message}`);
188
+ }
189
+ if (!data?.id) {
190
+ throw new Error(`component.json in ${rootDir} is missing the "id" field`);
191
+ }
192
+ return { id: data.id, displayName: data.displayName ?? null, raw: data };
193
+ }
194
+
195
+ export { toPosixPath, extensionOf };
@@ -0,0 +1,111 @@
1
+ function buildUrl(baseUrl, path) {
2
+ const base = baseUrl.replace(/\/+$/, '');
3
+ return `${base}${path.startsWith('/') ? path : `/${path}`}`;
4
+ }
5
+
6
+ export async function startDeviceAuthorization(baseUrl) {
7
+ const response = await fetch(buildUrl(baseUrl, '/api/auth/device-authorize'), {
8
+ method: 'POST',
9
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' }
10
+ });
11
+
12
+ if (!response.ok) {
13
+ const body = await response.json().catch(() => null);
14
+ throw new Error(
15
+ `Device authorization failed (HTTP ${response.status}): ${body?.error ?? response.statusText}`
16
+ );
17
+ }
18
+
19
+ const data = await response.json();
20
+ // Server quirk: `deviceCode` actually contains the user-facing code (XXXX-XXXX)
21
+ const userCode = data.deviceCode;
22
+ if (!userCode || !data.verificationUrl) {
23
+ throw new Error('Malformed response from device-authorize endpoint');
24
+ }
25
+
26
+ return {
27
+ userCode,
28
+ verificationUrl: data.verificationUrl,
29
+ expiresIn: data.expiresIn ?? 900,
30
+ pollInterval: Math.max(2, data.pollInterval ?? 5)
31
+ };
32
+ }
33
+
34
+ function sleep(ms) {
35
+ return new Promise((resolve) => setTimeout(resolve, ms));
36
+ }
37
+
38
+ export class DeviceFlowError extends Error {
39
+ constructor(message, code) {
40
+ super(message);
41
+ this.name = 'DeviceFlowError';
42
+ this.code = code;
43
+ }
44
+ }
45
+
46
+ export async function pollForDeviceToken(baseUrl, userCode, opts = {}) {
47
+ const {
48
+ intervalSeconds = 5,
49
+ expiresInSeconds = 900,
50
+ onTick,
51
+ signal
52
+ } = opts;
53
+
54
+ const deadline = Date.now() + expiresInSeconds * 1000;
55
+ const url = buildUrl(
56
+ baseUrl,
57
+ `/api/auth/device-token?code=${encodeURIComponent(userCode)}`
58
+ );
59
+
60
+ while (Date.now() < deadline) {
61
+ if (signal?.aborted) {
62
+ throw new DeviceFlowError('Aborted by user', 'aborted');
63
+ }
64
+
65
+ let response;
66
+ try {
67
+ response = await fetch(url, { headers: { Accept: 'application/json' } });
68
+ } catch (err) {
69
+ // transient network error — back off and retry
70
+ onTick?.({ status: 'network-error', error: err });
71
+ await sleep(intervalSeconds * 1000);
72
+ continue;
73
+ }
74
+
75
+ const body = await response.json().catch(() => null);
76
+
77
+ if (response.status === 202 && body?.status === 'pending') {
78
+ onTick?.({ status: 'pending' });
79
+ await sleep(intervalSeconds * 1000);
80
+ continue;
81
+ }
82
+
83
+ if (response.status === 200 && body?.status === 'approved') {
84
+ return body;
85
+ }
86
+
87
+ if (response.status === 403 || body?.status === 'denied') {
88
+ throw new DeviceFlowError('Authorization was denied in the browser', 'denied');
89
+ }
90
+
91
+ if (response.status === 410) {
92
+ throw new DeviceFlowError(
93
+ body?.error === 'code_already_used'
94
+ ? 'Device code already used. Run `wm login` again.'
95
+ : 'Device code expired. Run `wm login` again.',
96
+ body?.error ?? 'expired'
97
+ );
98
+ }
99
+
100
+ if (response.status === 404) {
101
+ throw new DeviceFlowError('Device code not recognized by server', 'invalid_device_code');
102
+ }
103
+
104
+ throw new DeviceFlowError(
105
+ `Unexpected response while polling (HTTP ${response.status}): ${body?.error ?? response.statusText}`,
106
+ 'unknown'
107
+ );
108
+ }
109
+
110
+ throw new DeviceFlowError('Timed out waiting for browser authorization', 'expired');
111
+ }
@@ -0,0 +1,63 @@
1
+ import { existsSync } from 'fs';
2
+ import { execFile } from 'child_process';
3
+ import { join, dirname } from 'path';
4
+ import { promisify } from 'util';
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ // Sucht aufsteigend nach einem .git-Verzeichnis. Sonst greift der Snapshot
9
+ // nicht, wenn `wm push` aus einem Komponenten-Unterordner kommt während
10
+ // das echte Repo im Workspace-Root liegt — der häufige Fall.
11
+ export function hasGitRepo(rootDir) {
12
+ let current = rootDir;
13
+ while (true) {
14
+ if (existsSync(join(current, '.git'))) return true;
15
+ const parent = dirname(current);
16
+ if (parent === current) return false;
17
+ current = parent;
18
+ }
19
+ }
20
+
21
+ async function git(rootDir, args, { allowFail = false } = {}) {
22
+ try {
23
+ const { stdout, stderr } = await execFileAsync('git', args, {
24
+ cwd: rootDir,
25
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
26
+ maxBuffer: 32 * 1024 * 1024
27
+ });
28
+ return { stdout: stdout.trim(), stderr: stderr.trim(), code: 0 };
29
+ } catch (err) {
30
+ if (allowFail) {
31
+ return { stdout: '', stderr: err.stderr ?? err.message, code: err.code ?? 1 };
32
+ }
33
+ throw err;
34
+ }
35
+ }
36
+
37
+ export async function autoSnapshot(rootDir, message, opts = {}) {
38
+ if (!hasGitRepo(rootDir)) {
39
+ return { skipped: true, reason: 'no-git' };
40
+ }
41
+ if (opts.skip) {
42
+ return { skipped: true, reason: 'flag' };
43
+ }
44
+
45
+ const status = await git(rootDir, ['status', '--porcelain'], { allowFail: true });
46
+ if (!status.stdout) {
47
+ return { skipped: true, reason: 'no-changes' };
48
+ }
49
+
50
+ await git(rootDir, ['add', '-A'], { allowFail: true });
51
+
52
+ const commit = await git(
53
+ rootDir,
54
+ ['-c', 'commit.gpgsign=false', 'commit', '-m', message, '--no-verify'],
55
+ { allowFail: true }
56
+ );
57
+ if (commit.code !== 0) {
58
+ return { skipped: false, committed: false, error: commit.stderr };
59
+ }
60
+
61
+ const rev = await git(rootDir, ['rev-parse', '--short', 'HEAD'], { allowFail: true });
62
+ return { skipped: false, committed: true, sha: rev.stdout };
63
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Tenant-API-Routing für den Komponenten-Refactor.
3
+ *
4
+ * Ab Phase 5 lebt der Component-Push/Pull nicht mehr unter
5
+ * `<base>/api/organization/components/...` (app.*-Subdomain), sondern
6
+ * unter `<sub>.cms.<basedomain>/api/tenant-components/...`. Da der CLI-
7
+ * Login die app.*-Base liefert (`auth.baseUrl`), brauchen wir einen
8
+ * Helper, der die cms-URL pro Tenant ableitet und für tenant-Endpoints
9
+ * die `baseUrl` im apiFetch-Call überschreibt.
10
+ *
11
+ * Übergangslogik: `tenantSubdomain` ist optional in `.webmate.json`.
12
+ * - Wenn gesetzt: tenant-Modus (neue Endpoints, cms.*-Subdomain)
13
+ * - Wenn nicht: Legacy-Modus (alte org-Endpoints, unverändert)
14
+ *
15
+ * Per env-Variable `WEBMATE_TENANT_SUBDOMAIN` lässt sich der Modus
16
+ * auch ohne meta-Datei aktivieren (CI/Scripts).
17
+ */
18
+
19
+ import { apiFetch } from './api-client.js';
20
+ import { resolveAuth, requireAuth } from './auth-resolver.js';
21
+
22
+ /**
23
+ * Liefert den effektiven Tenant-Subdomain aus meta oder env. null wenn
24
+ * keiner gesetzt ist (= Legacy-Modus).
25
+ */
26
+ export function resolveTenantSubdomain(meta) {
27
+ const fromEnv = (process.env.WEBMATE_TENANT_SUBDOMAIN || '').trim();
28
+ if (fromEnv) return fromEnv;
29
+ if (meta?.tenantSubdomain) return String(meta.tenantSubdomain).trim() || null;
30
+ return null;
31
+ }
32
+
33
+ /**
34
+ * Baut die cms-Subdomain-URL aus der App-Basis-URL.
35
+ * Beispiel:
36
+ * https://app.webmate-studio.io + "zak-staging"
37
+ * → https://zak-staging.cms.webmate-studio.io
38
+ *
39
+ * Akzeptiert optional schon eine cms.*-URL — dann wird nur der Subdomain
40
+ * neu gesetzt. Damit funktioniert der Helper sowohl wenn der User
41
+ * gegen app.* als auch wenn er gegen cms.* eingeloggt ist.
42
+ */
43
+ export function buildTenantBaseUrl(appBaseUrl, tenantSubdomain) {
44
+ if (!tenantSubdomain) return null;
45
+ let url;
46
+ try {
47
+ url = new URL(appBaseUrl);
48
+ } catch {
49
+ throw new Error(`Ungültige baseUrl: ${appBaseUrl}`);
50
+ }
51
+ // host = "app.webmate-studio.io" oder "zak.cms.webmate-studio.io" o.ä.
52
+ const host = url.host;
53
+ const parts = host.split('.');
54
+ // Erkenne app.* / cms.* prefix
55
+ let baseDomain;
56
+ if (parts[0] === 'app') {
57
+ baseDomain = parts.slice(1).join('.');
58
+ } else if (parts.length >= 2 && parts[1] === 'cms') {
59
+ // schon cms.* → ersetze ersten Teil
60
+ baseDomain = parts.slice(2).join('.');
61
+ } else {
62
+ // fallback: nimm alles ab erstem Punkt
63
+ baseDomain = parts.slice(1).join('.') || host;
64
+ }
65
+ const port = url.port ? `:${url.port}` : '';
66
+ return `${url.protocol}//${tenantSubdomain}.cms.${baseDomain}${port}`;
67
+ }
68
+
69
+ /**
70
+ * Wrapper um apiFetch, der bei vorhandenem tenantSubdomain auf die
71
+ * cms-Subdomain umroutet. Sonst durchgereicht.
72
+ *
73
+ * Aufruf-Beispiel:
74
+ * await tenantApiFetch('/api/tenant-components', { method: 'GET' }, meta)
75
+ */
76
+ export async function tenantApiFetch(path, opts = {}, meta = null) {
77
+ const sub = resolveTenantSubdomain(meta);
78
+ if (!sub) {
79
+ return apiFetch(path, opts);
80
+ }
81
+ const auth = opts.baseUrl ? null : requireAuth();
82
+ const appBase = opts.baseUrl || auth.baseUrl;
83
+ const tenantBase = buildTenantBaseUrl(appBase, sub);
84
+ return apiFetch(path, { ...opts, baseUrl: tenantBase });
85
+ }
86
+
87
+ /**
88
+ * Convenience: Liefert ein Objekt mit baseUrl + Token, mit dem ein
89
+ * Caller eigene fetch-Logik bauen kann (z.B. für Streaming oder
90
+ * Multipart-Uploads, die apiFetch nicht abdeckt).
91
+ */
92
+ export function resolveTenantBase(meta = null) {
93
+ const auth = resolveAuth();
94
+ if (!auth) return null;
95
+ const sub = resolveTenantSubdomain(meta);
96
+ if (!sub) return { baseUrl: auth.baseUrl, token: auth.token, mode: 'legacy' };
97
+ return {
98
+ baseUrl: buildTenantBaseUrl(auth.baseUrl, sub),
99
+ token: auth.token,
100
+ mode: 'tenant',
101
+ tenantSubdomain: sub
102
+ };
103
+ }
@@ -0,0 +1,75 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ const META_FILENAME = '.webmate.json';
5
+
6
+ export function getMetaPath(rootDir) {
7
+ return join(rootDir, META_FILENAME);
8
+ }
9
+
10
+ export function readMeta(rootDir) {
11
+ const p = getMetaPath(rootDir);
12
+ if (!existsSync(p)) return null;
13
+ try {
14
+ const raw = readFileSync(p, 'utf-8');
15
+ const data = JSON.parse(raw);
16
+ if (!data?.componentId) return null;
17
+ return data;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ export function writeMeta(rootDir, meta) {
24
+ if (!meta?.componentId) {
25
+ throw new Error('writeMeta requires componentId');
26
+ }
27
+ const existing = readMeta(rootDir) ?? {};
28
+ // `??` would coerce explicit `null` back to the existing value,
29
+ // which is wrong: callers that want to *reset* a field (e.g. the
30
+ // deleted-upstream recovery path wiping baseVersion + fileHashes
31
+ // to flip the component back to never-pushed) need null to mean
32
+ // "set to null". We distinguish "field present in meta" (use it,
33
+ // even if null) from "field omitted" (fall through to existing).
34
+ const pick = (key, deflt) =>
35
+ key in meta ? meta[key] : (existing[key] ?? deflt);
36
+ const merged = {
37
+ ...existing,
38
+ componentId: meta.componentId,
39
+ baseVersion: pick('baseVersion', null),
40
+ version: pick('version', null),
41
+ pulledAt: pick('pulledAt', new Date().toISOString()),
42
+ fileHashes: pick('fileHashes', {}),
43
+ // Records which ComponentRepository this meta was last synced
44
+ // against. The scanner uses it to tell a real upstream deletion
45
+ // (origin == current workspace repo) apart from a cross-workspace
46
+ // copy (origin != current). Legacy metas without the field stay
47
+ // classified as 'deleted-upstream' for safety.
48
+ originRepositoryId: pick('originRepositoryId', null),
49
+ // Tenant-Routing: wenn gesetzt, geht jeder push/pull/status
50
+ // gegen die cms.<sub>.<basedomain>/api/tenant-components/...
51
+ // Endpunkte. Legacy-Metas ohne das Feld bleiben am alten
52
+ // /api/organization/components/... Pfad — Phase 6 entfernt den.
53
+ tenantSubdomain: pick('tenantSubdomain', null)
54
+ };
55
+ if (existing.filesHash !== undefined && meta.filesHash === undefined) {
56
+ merged.filesHash = existing.filesHash;
57
+ } else if (meta.filesHash !== undefined) {
58
+ merged.filesHash = meta.filesHash;
59
+ }
60
+
61
+ writeFileSync(getMetaPath(rootDir), JSON.stringify(merged, null, 2) + '\n', 'utf-8');
62
+ return merged;
63
+ }
64
+
65
+ export function requireMeta(rootDir) {
66
+ const meta = readMeta(rootDir);
67
+ if (!meta) {
68
+ const err = new Error(
69
+ `No .webmate.json found in ${rootDir}. Run \`wm pull\` first or check that you are in the component directory.`
70
+ );
71
+ err.code = 'WM_NO_META';
72
+ throw err;
73
+ }
74
+ return meta;
75
+ }