@motherbase/cli 0.2.5 → 0.2.8

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Motherbase contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -146,7 +146,8 @@ emits, so `createClient<Database>(url, key)` types every row, rejects unknown
146
146
  columns and types `.rpc()` calls:
147
147
 
148
148
  ```bash
149
- motherbase gen types typescript > database.types.ts
149
+ motherbase gen types typescript # in a terminal: writes ./database.types.ts
150
+ motherbase gen types typescript > database.types.ts # piped: prints the file
150
151
  motherbase gen types typescript --schema shop --output src/shop.types.ts
151
152
  ```
152
153
 
@@ -0,0 +1,5 @@
1
+ {
2
+ "consoleUrl": "https://console.test.fidelix.pro",
3
+ "projectId": "gvask8",
4
+ "projectName": "gvask8"
5
+ }
package/package.json CHANGED
@@ -1,26 +1,35 @@
1
- {
2
- "name": "@motherbase/cli",
3
- "version": "0.2.5",
4
- "description": "Motherbase operator CLI: drives the Platform Console and linked projects.",
5
- "type": "commonjs",
6
- "bin": {
7
- "motherbase": "./bin/motherbase.js"
8
- },
9
- "engines": {
10
- "node": ">=18"
11
- },
12
- "scripts": {
13
- "test": "node --test",
14
- "lint": "eslint ."
15
- },
16
- "files": [
17
- "bin",
18
- "src",
19
- "README.md"
20
- ],
21
- "publishConfig": {
22
- "access": "public"
23
- },
24
- "license": "MIT",
25
- "dependencies": {}
26
- }
1
+ {
2
+ "name": "@motherbase/cli",
3
+ "version": "0.2.8",
4
+ "description": "Motherbase operator CLI: drives the Platform Console and linked projects.",
5
+ "type": "commonjs",
6
+ "bin": {
7
+ "motherbase": "./bin/motherbase.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "scripts": {
13
+ "test": "node --test",
14
+ "docs:cli": "node scripts/generate-cli-docs.js",
15
+ "lint": "eslint ."
16
+ },
17
+ "files": [
18
+ "bin",
19
+ "src",
20
+ "README.md"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/JulienGuinot/Boilerplate-backend.git",
29
+ "directory": "packages/cli"
30
+ },
31
+ "homepage": "https://github.com/JulienGuinot/Boilerplate-backend#readme",
32
+ "bugs": {
33
+ "url": "https://github.com/JulienGuinot/Boilerplate-backend/issues"
34
+ }
35
+ }
package/src/api.js CHANGED
@@ -1,241 +1,256 @@
1
- 'use strict';
2
-
3
- // The one and only HTTP layer. Every command routes through here so auth headers,
4
- // URL construction and error mapping live in a single place.
5
- //
6
- // URL shapes:
7
- // console request -> ${consoleUrl}/api/platform${path}
8
- // admin (console mode) -> ${consoleUrl}/api/projects/${projectId}/admin${path}
9
- // admin (direct mode) -> ${directUrl}/api/admin${path}
10
- // edge invoke (console)-> ${consoleUrl}/api/projects/${projectId}/edge/${route}
11
- // edge invoke (direct) -> ${directUrl}/api/edge/${route}
12
- //
13
- // Auth: the console session token is sent as `Authorization: Bearer <token>`.
14
- // Success envelopes differ across surfaces; both wrap payload under `data`, so we
15
- // unwrap `body.data` when present. Non-2xx becomes a thrown CliError with the
16
- // status and a short message/body snippet.
17
-
18
- const fs = require('fs');
19
- const path = require('path');
20
- const { Readable } = require('stream');
21
- const { CliError } = require('./context');
22
- const ui = require('./ui');
23
-
24
- function buildQuery(query) {
25
- if (!query) return '';
26
- const params = new URLSearchParams();
27
- for (const [k, v] of Object.entries(query)) {
28
- if (v === undefined || v === null) continue;
29
- params.append(k, String(v));
30
- }
31
- const s = params.toString();
32
- return s ? `?${s}` : '';
33
- }
34
-
35
- function unwrap(body) {
36
- if (body && typeof body === 'object' && 'data' in body) return body.data;
37
- return body;
38
- }
39
-
40
- // Non-browser clients send no Origin header. Better Auth (and reverse proxies
41
- // that inject cookies) then treat the request as CSRF-suspect and reject it with
42
- // "Missing or null Origin". Sending the target's own origin (which the server
43
- // keeps in its trustedOrigins (baseURL)) satisfies that check for every route.
44
- function originFor(url) {
45
- try { return new URL(url).origin; } catch { return undefined; }
46
- }
47
-
48
- function errorMessage(status, body, text) {
49
- const snippet = (body && (body.message || body.error)) || (text ? text.slice(0, 300) : '');
50
- if (status === 401) {
51
- return `Unauthorized (401)${snippet ? `: ${snippet}` : ''}. Run \`motherbase login\` (or check --token).`;
52
- }
53
- if (status === 403) {
54
- return `Forbidden (403)${snippet ? `: ${snippet}` : ''}.`;
55
- }
56
- if (status === 404) {
57
- return `Not found (404)${snippet ? `: ${snippet}` : ''}.`;
58
- }
59
- return `Request failed (${status})${snippet ? `: ${snippet}` : ''}`;
60
- }
61
-
62
- // `--debug` (or MOTHERBASE_DEBUG=1): one stderr line per request. Never the
63
- // headers or body, which carry the token and passwords.
64
- function trace(method, url, res) {
65
- if (!process.env.MOTHERBASE_DEBUG) return;
66
- ui.printErr(ui.color.dim(`[debug] ${method} ${url} → ${res.status}`));
67
- }
68
-
69
- async function parseResponse(res) {
70
- const text = await res.text();
71
- let body;
72
- if (text) {
73
- try { body = JSON.parse(text); } catch { body = undefined; }
74
- }
75
- if (!res.ok) {
76
- throw new CliError(errorMessage(res.status, body, text));
77
- }
78
- return body === undefined ? text : unwrap(body);
79
- }
80
-
81
- async function doFetch(url, { method = 'GET', token, body, headers = {} } = {}) {
82
- const finalHeaders = { ...headers };
83
- if (token) finalHeaders['authorization'] = `Bearer ${token}`;
84
- if (!finalHeaders['origin']) {
85
- const origin = originFor(url);
86
- if (origin) finalHeaders['origin'] = origin;
87
- }
88
- let payload;
89
- if (body !== undefined && body !== null) {
90
- finalHeaders['content-type'] = 'application/json';
91
- payload = JSON.stringify(body);
92
- }
93
- let res;
94
- try {
95
- res = await fetch(url, { method, headers: finalHeaders, body: payload });
96
- } catch (err) {
97
- throw new CliError(`Cannot reach ${url}: ${err.message}`);
98
- }
99
- trace(method, url, res);
100
- return parseResponse(res);
101
- }
102
-
103
- // ── Console (account / multi-project) ────────────────────────────────────────
104
-
105
- function consoleRequest(ctx, method, apiPath, { body, query } = {}) {
106
- const { consoleUrl, token } = ctx.requireConsole();
107
- const url = `${consoleUrl}/api/platform${apiPath}${buildQuery(query)}`;
108
- return doFetch(url, { method, token, body });
109
- }
110
-
111
- // Login is special: no token yet. Uses Better Auth's native sign-in endpoint;
112
- // the session token comes back in the body and/or the `set-auth-token` header
113
- // (bearer plugin). Returns `{ token, user }` so the caller can persist it.
114
- async function loginRequest(consoleUrl, email, password) {
115
- const url = `${consoleUrl}/api/auth/sign-in/email`;
116
- let res;
117
- try {
118
- const headers = { 'content-type': 'application/json' };
119
- const origin = originFor(url);
120
- if (origin) headers['origin'] = origin;
121
- res = await fetch(url, {
122
- method: 'POST',
123
- headers,
124
- body: JSON.stringify({ email, password }),
125
- });
126
- } catch (err) {
127
- throw new CliError(`Cannot reach ${url}: ${err.message}`);
128
- }
129
- trace('POST', url, res);
130
- const text = await res.text();
131
- let body;
132
- if (text) { try { body = JSON.parse(text); } catch { body = undefined; } }
133
- if (!res.ok) {
134
- throw new CliError(errorMessage(res.status, body, text));
135
- }
136
- const token = (body && body.token) || res.headers.get('set-auth-token') || undefined;
137
- return { token, user: body && body.user };
138
- }
139
-
140
- // ── Admin / resource (via proxy or direct) ───────────────────────────────────
141
-
142
- function adminBase(target) {
143
- if (target.mode === 'direct') {
144
- return { base: `${target.baseUrl}/api/admin`, token: target.token };
145
- }
146
- return { base: `${target.consoleUrl}/api/projects/${target.projectId}/admin`, token: target.token };
147
- }
148
-
149
- function adminRequest(ctx, method, apiPath, { body, query } = {}) {
150
- const target = ctx.requireProject();
151
- const { base, token } = adminBase(target);
152
- const url = `${base}${apiPath}${buildQuery(query)}`;
153
- return doFetch(url, { method, token, body });
154
- }
155
-
156
- // The public URL of a project path (`/edge/<route>`, `/rest/v1/<table>`), for
157
- // printing. Mirrors how the proxy addresses a project in each mode.
158
- function projectUrl(ctx, projectPath) {
159
- const target = ctx.requireProject();
160
- return target.mode === 'direct'
161
- ? `${target.baseUrl}/api${projectPath}`
162
- : `${target.consoleUrl}/api/projects/${target.projectId}${projectPath}`;
163
- }
164
-
165
- // Edge invoke goes through the un-authenticated edge route, not /admin.
166
- function edgeInvokeRequest(ctx, route, body) {
167
- const url = projectUrl(ctx, `/edge/${encodeURIComponent(route)}`);
168
- const target = ctx.requireProject();
169
- return doFetch(url, { method: 'POST', token: target.token, body: body || {} });
170
- }
171
-
172
- // ── Multipart upload (native FormData/Blob; zero-dep on Node >=18) ────────────
173
-
174
- async function uploadFile(ctx, apiPath, filePath, fields = {}) {
175
- const target = ctx.requireProject();
176
- const { base, token } = adminBase(target);
177
- const url = `${base}${apiPath}`;
178
- const buf = fs.readFileSync(filePath);
179
- const form = new FormData();
180
- form.append('file', new Blob([buf]), path.basename(filePath));
181
- for (const [k, v] of Object.entries(fields)) {
182
- if (v !== undefined && v !== null) form.append(k, String(v));
183
- }
184
- const uploadHeaders = token ? { authorization: `Bearer ${token}` } : {};
185
- const uploadOrigin = originFor(url);
186
- if (uploadOrigin) uploadHeaders['origin'] = uploadOrigin;
187
- let res;
188
- try {
189
- res = await fetch(url, {
190
- method: 'POST',
191
- headers: uploadHeaders,
192
- body: form,
193
- });
194
- } catch (err) {
195
- throw new CliError(`Cannot reach ${url}: ${err.message}`);
196
- }
197
- trace('POST', url, res);
198
- return parseResponse(res);
199
- }
200
-
201
- // ── Streamed download to a local file ────────────────────────────────────────
202
-
203
- async function downloadFile(ctx, apiPath, destPath, { query } = {}) {
204
- const target = ctx.requireProject();
205
- const { base, token } = adminBase(target);
206
- const url = `${base}${apiPath}${buildQuery(query)}`;
207
- const dlHeaders = token ? { authorization: `Bearer ${token}` } : {};
208
- const dlOrigin = originFor(url);
209
- if (dlOrigin) dlHeaders['origin'] = dlOrigin;
210
- let res;
211
- try {
212
- res = await fetch(url, { headers: dlHeaders });
213
- } catch (err) {
214
- throw new CliError(`Cannot reach ${url}: ${err.message}`);
215
- }
216
- trace('GET', url, res);
217
- if (!res.ok) {
218
- const text = await res.text().catch(() => '');
219
- let body; try { body = JSON.parse(text); } catch { /* noop */ }
220
- throw new CliError(errorMessage(res.status, body, text));
221
- }
222
- await new Promise((resolve, reject) => {
223
- const out = fs.createWriteStream(destPath);
224
- out.on('error', reject);
225
- out.on('finish', resolve);
226
- Readable.fromWeb(res.body).pipe(out);
227
- });
228
- return { path: destPath, contentType: res.headers.get('content-type') || undefined };
229
- }
230
-
231
- module.exports = {
232
- consoleRequest,
233
- loginRequest,
234
- adminRequest,
235
- edgeInvokeRequest,
236
- projectUrl,
237
- uploadFile,
238
- downloadFile,
239
- buildQuery,
240
- unwrap,
241
- };
1
+ 'use strict';
2
+
3
+ // The one and only HTTP layer. Every command routes through here so auth headers,
4
+ // URL construction and error mapping live in a single place.
5
+ //
6
+ // URL shapes:
7
+ // console request -> ${consoleUrl}/api/platform${path}
8
+ // admin (console mode) -> ${consoleUrl}/api/projects/${projectId}/admin${path}
9
+ // admin (direct mode) -> ${directUrl}/api/admin${path}
10
+ //
11
+ // Auth: the console session token is sent as `Authorization: Bearer <token>`.
12
+ // Success envelopes differ across surfaces; both wrap payload under `data`, so we
13
+ // unwrap `body.data` when present. Non-2xx becomes a thrown CliError with the
14
+ // status and a short message/body snippet.
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const { Readable } = require('stream');
19
+ const { pipeline } = require('stream/promises');
20
+ const { CliError } = require('./context');
21
+ const ui = require('./ui');
22
+
23
+ function buildQuery(query) {
24
+ if (!query) return '';
25
+ const params = new URLSearchParams();
26
+ for (const [k, v] of Object.entries(query)) {
27
+ if (v === undefined || v === null) continue;
28
+ params.append(k, String(v));
29
+ }
30
+ const s = params.toString();
31
+ return s ? `?${s}` : '';
32
+ }
33
+
34
+ function unwrap(body) {
35
+ if (body && typeof body === 'object' && 'data' in body) return body.data;
36
+ return body;
37
+ }
38
+
39
+ // Non-browser clients send no Origin header. Better Auth (and reverse proxies
40
+ // that inject cookies) then treat the request as CSRF-suspect and reject it with
41
+ // "Missing or null Origin". Sending the target's own origin (which the server
42
+ // keeps in its trustedOrigins (baseURL)) satisfies that check for every route.
43
+ function originFor(url) {
44
+ try { return new URL(url).origin; } catch { return undefined; }
45
+ }
46
+
47
+ function errorMessage(status, body, text) {
48
+ const snippet = (body && (body.message || body.error)) || (text ? text.slice(0, 300) : '');
49
+ if (status === 401) {
50
+ return `Unauthorized (401)${snippet ? `: ${snippet}` : ''}. Run \`motherbase login\` (or check --token).`;
51
+ }
52
+ if (status === 403) {
53
+ return `Forbidden (403)${snippet ? `: ${snippet}` : ''}.`;
54
+ }
55
+ if (status === 404) {
56
+ return `Not found (404)${snippet ? `: ${snippet}` : ''}.`;
57
+ }
58
+ return `Request failed (${status})${snippet ? `: ${snippet}` : ''}`;
59
+ }
60
+
61
+ // `--debug` (or MOTHERBASE_DEBUG=1): one stderr line per request. Never the
62
+ // headers or body, which carry the token and passwords.
63
+ function trace(method, url, res) {
64
+ if (!process.env.MOTHERBASE_DEBUG) return;
65
+ ui.printErr(ui.color.dim(`[debug] ${method} ${url} → ${res.status}`));
66
+ }
67
+
68
+ async function parseResponse(res) {
69
+ const text = await res.text();
70
+ let body;
71
+ if (text) {
72
+ try { body = JSON.parse(text); } catch { body = undefined; }
73
+ }
74
+ if (!res.ok) {
75
+ throw new CliError(errorMessage(res.status, body, text));
76
+ }
77
+ return body === undefined ? text : unwrap(body);
78
+ }
79
+
80
+ async function doFetch(url, { method = 'GET', token, body, headers = {} } = {}) {
81
+ const finalHeaders = { ...headers };
82
+ if (token) finalHeaders['authorization'] = `Bearer ${token}`;
83
+ if (!finalHeaders['origin']) {
84
+ const origin = originFor(url);
85
+ if (origin) finalHeaders['origin'] = origin;
86
+ }
87
+ let payload;
88
+ if (body !== undefined && body !== null) {
89
+ finalHeaders['content-type'] = 'application/json';
90
+ payload = JSON.stringify(body);
91
+ }
92
+ let res;
93
+ try {
94
+ res = await fetch(url, { method, headers: finalHeaders, body: payload });
95
+ } catch (err) {
96
+ throw new CliError(`Cannot reach ${url}: ${err.message}`);
97
+ }
98
+ trace(method, url, res);
99
+ return parseResponse(res);
100
+ }
101
+
102
+ // ── Console (account / multi-project) ────────────────────────────────────────
103
+
104
+ function consoleRequest(ctx, method, apiPath, { body, query } = {}) {
105
+ const { consoleUrl, token } = ctx.requireConsole();
106
+ const url = `${consoleUrl}/api/platform${apiPath}${buildQuery(query)}`;
107
+ return doFetch(url, { method, token, body });
108
+ }
109
+
110
+ // Login is special: no token yet, so it talks to Better Auth's native endpoints
111
+ // directly. Returns the raw response next to the parsed body because the token
112
+ // lives in the `set-auth-token` header (bearer plugin) and the 2FA step needs
113
+ // the response cookies.
114
+ async function authPost(url, body, headers = {}) {
115
+ const finalHeaders = { 'content-type': 'application/json', ...headers };
116
+ const origin = originFor(url);
117
+ if (origin) finalHeaders['origin'] = origin;
118
+ let res;
119
+ try {
120
+ res = await fetch(url, { method: 'POST', headers: finalHeaders, body: JSON.stringify(body) });
121
+ } catch (err) {
122
+ throw new CliError(`Cannot reach ${url}: ${err.message}`);
123
+ }
124
+ trace('POST', url, res);
125
+ const text = await res.text();
126
+ let parsed;
127
+ if (text) { try { parsed = JSON.parse(text); } catch { parsed = undefined; } }
128
+ if (!res.ok) {
129
+ throw new CliError(errorMessage(res.status, parsed, text));
130
+ }
131
+ return { res, body: parsed };
132
+ }
133
+
134
+ // The `two_factor` cookie (prefixed `__Secure-` on https) scopes the
135
+ // verify-totp call to the pending sign-in.
136
+ function twoFactorCookie(res) {
137
+ const setCookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [];
138
+ const pair = setCookies.map(c => c.split(';')[0]).find(c => /two_factor=./.test(c));
139
+ if (!pair) throw new CliError('two-factor sign-in started but the server sent no two_factor cookie');
140
+ return pair;
141
+ }
142
+
143
+ // Returns `{ token, user }`, or `{ twoFactorCookie }` when the account has 2FA
144
+ // enrolled. In that case Better Auth answers `{ twoFactorRedirect: true }` and
145
+ // the `set-auth-token` header still carries the pre-2FA session it has just
146
+ // deleted, so the header must not be trusted (see the console login route).
147
+ async function loginRequest(consoleUrl, email, password) {
148
+ const { res, body } = await authPost(`${consoleUrl}/api/auth/sign-in/email`, { email, password });
149
+ if (body && body.twoFactorRedirect) return { twoFactorCookie: twoFactorCookie(res) };
150
+ const token = (body && body.token) || res.headers.get('set-auth-token') || undefined;
151
+ return { token, user: body && body.user };
152
+ }
153
+
154
+ // Second step of a 2FA sign-in: trade the TOTP code for the real session token.
155
+ async function verifyTotpRequest(consoleUrl, cookie, code) {
156
+ const { res, body } = await authPost(`${consoleUrl}/api/auth/two-factor/verify-totp`, { code }, { cookie });
157
+ const token = res.headers.get('set-auth-token') || (body && body.token) || undefined;
158
+ return { token, user: body && body.user };
159
+ }
160
+
161
+ // ── Admin / resource (via proxy or direct) ───────────────────────────────────
162
+
163
+ function adminBase(target) {
164
+ if (target.mode === 'direct') {
165
+ return { base: `${target.baseUrl}/api/admin`, token: target.token };
166
+ }
167
+ return { base: `${target.consoleUrl}/api/projects/${target.projectId}/admin`, token: target.token };
168
+ }
169
+
170
+ function adminRequest(ctx, method, apiPath, { body, query } = {}) {
171
+ const target = ctx.requireProject();
172
+ const { base, token } = adminBase(target);
173
+ const url = `${base}${apiPath}${buildQuery(query)}`;
174
+ return doFetch(url, { method, token, body });
175
+ }
176
+
177
+ // The public URL of a project path (`/edge/<route>`, `/rest/v1/<table>`), for
178
+ // printing. Mirrors how the proxy addresses a project in each mode.
179
+ function projectUrl(ctx, projectPath) {
180
+ const target = ctx.requireProject();
181
+ return target.mode === 'direct'
182
+ ? `${target.baseUrl}/api${projectPath}`
183
+ : `${target.consoleUrl}/api/projects/${target.projectId}${projectPath}`;
184
+ }
185
+
186
+ // ── Multipart upload (native FormData/Blob; zero-dep on Node >=18) ────────────
187
+
188
+ async function uploadFile(ctx, apiPath, filePath, fields = {}) {
189
+ const target = ctx.requireProject();
190
+ const { base, token } = adminBase(target);
191
+ const url = `${base}${apiPath}`;
192
+ const buf = fs.readFileSync(filePath);
193
+ const form = new FormData();
194
+ form.append('file', new Blob([buf]), path.basename(filePath));
195
+ for (const [k, v] of Object.entries(fields)) {
196
+ if (v !== undefined && v !== null) form.append(k, String(v));
197
+ }
198
+ const uploadHeaders = token ? { authorization: `Bearer ${token}` } : {};
199
+ const uploadOrigin = originFor(url);
200
+ if (uploadOrigin) uploadHeaders['origin'] = uploadOrigin;
201
+ let res;
202
+ try {
203
+ res = await fetch(url, {
204
+ method: 'POST',
205
+ headers: uploadHeaders,
206
+ body: form,
207
+ });
208
+ } catch (err) {
209
+ throw new CliError(`Cannot reach ${url}: ${err.message}`);
210
+ }
211
+ trace('POST', url, res);
212
+ return parseResponse(res);
213
+ }
214
+
215
+ // ── Streamed download to a local file ────────────────────────────────────────
216
+
217
+ async function downloadFile(ctx, apiPath, destPath, { query } = {}) {
218
+ const target = ctx.requireProject();
219
+ const { base, token } = adminBase(target);
220
+ const url = `${base}${apiPath}${buildQuery(query)}`;
221
+ const dlHeaders = token ? { authorization: `Bearer ${token}` } : {};
222
+ const dlOrigin = originFor(url);
223
+ if (dlOrigin) dlHeaders['origin'] = dlOrigin;
224
+ let res;
225
+ try {
226
+ res = await fetch(url, { headers: dlHeaders });
227
+ } catch (err) {
228
+ throw new CliError(`Cannot reach ${url}: ${err.message}`);
229
+ }
230
+ trace('GET', url, res);
231
+ if (!res.ok) {
232
+ const text = await res.text().catch(() => '');
233
+ let body; try { body = JSON.parse(text); } catch { /* noop */ }
234
+ throw new CliError(errorMessage(res.status, body, text));
235
+ }
236
+ try {
237
+ await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(destPath));
238
+ } catch (err) {
239
+ // A dropped connection leaves a truncated file that looks like a download.
240
+ fs.rmSync(destPath, { force: true });
241
+ throw new CliError(`Download failed: ${err.message}`);
242
+ }
243
+ return { path: destPath, contentType: res.headers.get('content-type') || undefined };
244
+ }
245
+
246
+ module.exports = {
247
+ consoleRequest,
248
+ loginRequest,
249
+ verifyTotpRequest,
250
+ adminRequest,
251
+ projectUrl,
252
+ uploadFile,
253
+ downloadFile,
254
+ buildQuery,
255
+ unwrap,
256
+ };
@@ -2,6 +2,7 @@
2
2
 
3
3
  // Small shared helpers for command modules.
4
4
 
5
+ const path = require('path');
5
6
  const { CliError } = require('../context');
6
7
  const { outputFormat, toYaml, toEnv } = require('../format');
7
8
 
@@ -90,4 +91,16 @@ function consoleProjectPath(ctx, suffix = '') {
90
91
  return `/projects/${encodeURIComponent(ctx.projectId)}${suffix}`;
91
92
  }
92
93
 
93
- module.exports = { parseKeyValues, output, alias, parseDotenv, requireArg, splitRef, consoleProjectPath };
94
+ // Join `parts` under `root`, refusing a result that escapes it. Names that come
95
+ // from the server (object keys, function routes) must not write outside the
96
+ // directory the user picked: `../../.bashrc` is a valid object key.
97
+ function resolveInside(root, ...parts) {
98
+ const target = path.resolve(root, ...parts);
99
+ const rel = path.relative(root, target);
100
+ if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
101
+ throw new CliError(`Refusing to write outside ${root}: ${parts.join('/')}`);
102
+ }
103
+ return target;
104
+ }
105
+
106
+ module.exports = { parseKeyValues, output, alias, parseDotenv, requireArg, splitRef, consoleProjectPath, resolveInside };