@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 +21 -0
- package/README.md +2 -1
- package/bin/.motherbase/config.json +5 -0
- package/package.json +35 -26
- package/src/api.js +256 -241
- package/src/commands/_util.js +14 -1
- package/src/commands/db-catalog.js +326 -326
- package/src/commands/functions.js +304 -297
- package/src/commands/gen.js +7 -1
- package/src/commands/local.js +371 -273
- package/src/commands/login.js +45 -39
- package/src/commands/migrate.js +202 -179
- package/src/commands/migration.js +108 -108
- package/src/commands/projects.js +2 -1
- package/src/commands/storage.js +2 -2
- package/src/commands/users.js +142 -142
- package/src/context.js +6 -2
- package/src/docs.js +68 -0
- package/src/main.js +224 -194
- package/src/mcp/protocol.js +4 -1
- package/src/migration/commands.js +375 -375
- package/src/migration/files.js +258 -245
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
|
|
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
|
|
package/package.json
CHANGED
|
@@ -1,26 +1,35 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@motherbase/cli",
|
|
3
|
-
"version": "0.2.
|
|
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
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
async function
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
})
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
+
};
|
package/src/commands/_util.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|