@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,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
|
+
}
|