@micropage-sh/cli 2.0.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 +183 -0
- package/bin/micropage.js +3 -0
- package/package.json +65 -0
- package/src/api.js +51 -0
- package/src/auth.js +123 -0
- package/src/commands/builds.js +496 -0
- package/src/commands/files.js +225 -0
- package/src/commands/forms.js +71 -0
- package/src/commands/links.js +107 -0
- package/src/commands/login.js +255 -0
- package/src/commands/logout.js +16 -0
- package/src/commands/projects.js +573 -0
- package/src/commands/publish.js +10 -0
- package/src/commands/submissions.js +194 -0
- package/src/commands/whoami.js +87 -0
- package/src/config.js +22 -0
- package/src/index.js +243 -0
- package/src/mime.js +18 -0
- package/src/parser.js +72 -0
- package/src/project-ref.js +54 -0
- package/src/supabase.js +637 -0
- package/src/utils.js +47 -0
- package/templates/PROJECT_AGENT.template.md +31 -0
- package/templates/assets/favicon.svg +8 -0
- package/templates/assets/logo.svg +14 -0
- package/templates/examples/components-colors.page +84 -0
- package/templates/examples/components-hero-variants.page +28 -0
- package/templates/examples/components-pricing-and-forms.page +45 -0
- package/templates/examples/mobile-app-landing.page +77 -0
- package/templates/examples/portfolio.page +75 -0
- package/templates/examples/startup-landing.page +100 -0
package/src/supabase.js
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Thin Supabase client for the CLI.
|
|
5
|
+
*
|
|
6
|
+
* Uses the stored user session (access_token + refresh_token) together with
|
|
7
|
+
* the public anon key for every request. RLS on the database enforces ownership.
|
|
8
|
+
*
|
|
9
|
+
* Supports:
|
|
10
|
+
* - db.from(table) – PostgREST query builder
|
|
11
|
+
* - fn.invoke(name, body?) – edge function calls
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { getSession, setSession, clearSession } = require('./auth');
|
|
15
|
+
const { SUPABASE_URL, SUPABASE_ANON_KEY } = require('./config');
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// JWT helpers
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
function isTokenExpired(token) {
|
|
22
|
+
try {
|
|
23
|
+
const b64 = token.split('.')[1];
|
|
24
|
+
const payload = JSON.parse(Buffer.from(b64, 'base64').toString());
|
|
25
|
+
// Refresh if expiry is within 60 seconds
|
|
26
|
+
return payload.exp < Math.floor(Date.now() / 1000) + 60;
|
|
27
|
+
} catch {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Single in-flight refresh so parallel requests cannot reuse the same refresh_token (rotation revokes it). */
|
|
33
|
+
let refreshInFlight = null;
|
|
34
|
+
|
|
35
|
+
async function refreshSession() {
|
|
36
|
+
if (refreshInFlight) {
|
|
37
|
+
return refreshInFlight;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const session = getSession();
|
|
41
|
+
if (!session?.refresh_token) return null;
|
|
42
|
+
|
|
43
|
+
refreshInFlight = (async () => {
|
|
44
|
+
const res = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: {
|
|
47
|
+
'apikey': SUPABASE_ANON_KEY,
|
|
48
|
+
'Content-Type': 'application/json',
|
|
49
|
+
},
|
|
50
|
+
body: JSON.stringify({ refresh_token: session.refresh_token }),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
const body = await res.text().catch(() => '');
|
|
55
|
+
console.error(`[micropage] token refresh failed (HTTP ${res.status}): ${body}`);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const data = await res.json();
|
|
59
|
+
if (!data.access_token) return null;
|
|
60
|
+
|
|
61
|
+
const newSession = {
|
|
62
|
+
access_token: data.access_token,
|
|
63
|
+
refresh_token: data.refresh_token || session.refresh_token,
|
|
64
|
+
user: data.user || session.user,
|
|
65
|
+
};
|
|
66
|
+
setSession(newSession);
|
|
67
|
+
return newSession;
|
|
68
|
+
})().finally(() => {
|
|
69
|
+
refreshInFlight = null;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return refreshInFlight;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function makeAuthError(code, message) {
|
|
76
|
+
const err = new Error(message);
|
|
77
|
+
err.code = code;
|
|
78
|
+
return err;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function getValidAccessToken() {
|
|
82
|
+
let session = getSession();
|
|
83
|
+
if (!session) {
|
|
84
|
+
throw makeAuthError('NO_SESSION', 'Not logged in. Run: micropage login');
|
|
85
|
+
}
|
|
86
|
+
if (isTokenExpired(session.access_token)) {
|
|
87
|
+
session = await refreshSession();
|
|
88
|
+
if (!session) {
|
|
89
|
+
clearSession();
|
|
90
|
+
throw makeAuthError('SESSION_EXPIRED', 'Session expired. Run: micropage login');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return session.access_token;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Base request
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
async function requestWithToken(method, url, body, accessToken, extraHeaders = {}) {
|
|
101
|
+
const headers = {
|
|
102
|
+
'apikey': SUPABASE_ANON_KEY,
|
|
103
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
104
|
+
'Content-Type': 'application/json',
|
|
105
|
+
...extraHeaders,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const options = { method, headers };
|
|
109
|
+
if (body !== null && body !== undefined) {
|
|
110
|
+
options.body = JSON.stringify(body);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const res = await fetch(url, options);
|
|
114
|
+
const text = await res.text();
|
|
115
|
+
let data = null;
|
|
116
|
+
try { data = text ? JSON.parse(text) : null; } catch { data = text || null; }
|
|
117
|
+
|
|
118
|
+
if (!res.ok) {
|
|
119
|
+
const msg =
|
|
120
|
+
(typeof data === 'object' && data && (data.message || data.error)) ||
|
|
121
|
+
res.statusText ||
|
|
122
|
+
`HTTP ${res.status}`;
|
|
123
|
+
const err = new Error(msg);
|
|
124
|
+
err.status = res.status;
|
|
125
|
+
err.data = data;
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return data;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function request(method, url, body, extraHeaders = {}) {
|
|
133
|
+
const token = await getValidAccessToken();
|
|
134
|
+
const headers = {
|
|
135
|
+
'apikey': SUPABASE_ANON_KEY,
|
|
136
|
+
'Authorization': `Bearer ${token}`,
|
|
137
|
+
'Content-Type': 'application/json',
|
|
138
|
+
...extraHeaders,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const options = { method, headers };
|
|
142
|
+
if (body !== null && body !== undefined) {
|
|
143
|
+
options.body = JSON.stringify(body);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let res = await fetch(url, options);
|
|
147
|
+
|
|
148
|
+
// On 401, attempt one silent refresh and retry before giving up.
|
|
149
|
+
// This covers clock-skew and edge cases where the token expired server-side
|
|
150
|
+
// slightly before isTokenExpired() would have caught it proactively.
|
|
151
|
+
if (res.status === 401) {
|
|
152
|
+
const refreshed = await refreshSession();
|
|
153
|
+
if (refreshed) {
|
|
154
|
+
options.headers = {
|
|
155
|
+
...options.headers,
|
|
156
|
+
'Authorization': `Bearer ${refreshed.access_token}`,
|
|
157
|
+
};
|
|
158
|
+
res = await fetch(url, options);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const text = await res.text();
|
|
163
|
+
let data = null;
|
|
164
|
+
try { data = text ? JSON.parse(text) : null; } catch { data = text || null; }
|
|
165
|
+
|
|
166
|
+
if (!res.ok) {
|
|
167
|
+
const msg =
|
|
168
|
+
(typeof data === 'object' && data && (data.message || data.error)) ||
|
|
169
|
+
res.statusText ||
|
|
170
|
+
`HTTP ${res.status}`;
|
|
171
|
+
const err = new Error(msg);
|
|
172
|
+
err.status = res.status;
|
|
173
|
+
err.data = data;
|
|
174
|
+
if (res.status === 401) err.code = 'SESSION_EXPIRED';
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return data;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Edge function invocations
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
const fn = {
|
|
186
|
+
async invoke(name, body = null) {
|
|
187
|
+
return request('POST', `${SUPABASE_URL}/functions/v1/${name}`, body);
|
|
188
|
+
},
|
|
189
|
+
async invokeGet(name, params = {}) {
|
|
190
|
+
const qs = new URLSearchParams(params).toString();
|
|
191
|
+
const url = `${SUPABASE_URL}/functions/v1/${name}${qs ? '?' + qs : ''}`;
|
|
192
|
+
return request('GET', url, null);
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// PostgREST query builder
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
function buildQuery(table) {
|
|
201
|
+
const filters = [];
|
|
202
|
+
let _select = '*';
|
|
203
|
+
let _order = null;
|
|
204
|
+
let _limit = null;
|
|
205
|
+
|
|
206
|
+
const q = {
|
|
207
|
+
select(cols) { _select = cols; return q; },
|
|
208
|
+
eq(col, val) { filters.push(`${col}=eq.${val}`); return q; },
|
|
209
|
+
in(col, vals) { filters.push(`${col}=in.(${vals.join(',')})`); return q; },
|
|
210
|
+
order(col, dir = 'asc') { _order = `${col}.${dir}`; return q; },
|
|
211
|
+
limit(n) { _limit = n; return q; },
|
|
212
|
+
|
|
213
|
+
async execute(method = 'GET', body = null, extraHeaders = {}) {
|
|
214
|
+
const params = new URLSearchParams();
|
|
215
|
+
params.set('select', _select);
|
|
216
|
+
for (const f of filters) {
|
|
217
|
+
const [key, value] = f.split('=');
|
|
218
|
+
params.append(key, value);
|
|
219
|
+
}
|
|
220
|
+
if (_order) params.set('order', _order);
|
|
221
|
+
if (_limit) params.set('limit', String(_limit));
|
|
222
|
+
|
|
223
|
+
const url = `${SUPABASE_URL}/rest/v1/${table}?${params.toString()}`;
|
|
224
|
+
return request(method, url, body, extraHeaders);
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
async get() { return q.execute('GET'); },
|
|
228
|
+
|
|
229
|
+
async single() {
|
|
230
|
+
const results = await q.limit(1).execute('GET');
|
|
231
|
+
return Array.isArray(results) ? results[0] || null : results;
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
async insert(data) {
|
|
235
|
+
const url = `${SUPABASE_URL}/rest/v1/${table}`;
|
|
236
|
+
return request('POST', url, data, { 'Prefer': 'return=representation' });
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
async update(data) {
|
|
240
|
+
return q.execute('PATCH', data, { 'Prefer': 'return=representation' });
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async delete() {
|
|
244
|
+
return q.execute('DELETE', null, { 'Prefer': 'return=representation' });
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
return q;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const db = {
|
|
252
|
+
from: (table) => buildQuery(table),
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Auth helpers (user info)
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
async function getUserInfo(accessToken) {
|
|
260
|
+
const res = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
|
|
261
|
+
headers: {
|
|
262
|
+
'apikey': SUPABASE_ANON_KEY,
|
|
263
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
if (!res.ok) return null;
|
|
267
|
+
return res.json();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Common error handler for commands
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
function handleAuthError(err) {
|
|
275
|
+
if (err.code === 'NO_SESSION' || err.code === 'SESSION_EXPIRED') {
|
|
276
|
+
console.error(err.message);
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// Deploy token + SSE (Edge functions with verify_jwt = false + Bearer token)
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
const TERMINAL_DEPLOY_EVENTS = new Set([
|
|
286
|
+
'build.failed',
|
|
287
|
+
'deployment.completed',
|
|
288
|
+
'archive.completed',
|
|
289
|
+
'archive.failed',
|
|
290
|
+
]);
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Human-readable line for deploy stream (falls back to event_type + JSON payload).
|
|
294
|
+
* @param {{ event_type?: string, payload?: Record<string, unknown> }} ev
|
|
295
|
+
*/
|
|
296
|
+
function formatDeployEventForConsole(ev) {
|
|
297
|
+
const eventType = ev.event_type || '';
|
|
298
|
+
const payload = ev.payload && typeof ev.payload === 'object' ? ev.payload : null;
|
|
299
|
+
if (eventType === 'deployment.domain_wiring' && payload) {
|
|
300
|
+
const step = payload.step;
|
|
301
|
+
const host = typeof payload.hostname === 'string' ? payload.hostname : '';
|
|
302
|
+
const https = host ? `https://${host}` : '';
|
|
303
|
+
switch (step) {
|
|
304
|
+
case 'started':
|
|
305
|
+
return `${eventType}: preparing ${host || 'platform hostname'}`;
|
|
306
|
+
case 'dns_ok':
|
|
307
|
+
return `${eventType}: DNS CNAME ready (${host})`;
|
|
308
|
+
case 'dns_resolve_ok':
|
|
309
|
+
return payload.skipped
|
|
310
|
+
? `${eventType}: skipped public DNS wait`
|
|
311
|
+
: `${eventType}: hostname resolves (${host})`;
|
|
312
|
+
case 'attach_ok':
|
|
313
|
+
return `${eventType}: registered on Cloudflare Pages (${host})`;
|
|
314
|
+
case 'polling':
|
|
315
|
+
return `${eventType}: Pages status "${payload.pages_status || '…'}" (${host})`;
|
|
316
|
+
case 'active':
|
|
317
|
+
return `${eventType}: live — ${https}`;
|
|
318
|
+
case 'failed':
|
|
319
|
+
return `${eventType}: failed — ${payload.error || 'unknown'}`;
|
|
320
|
+
case 'timeout':
|
|
321
|
+
return `${eventType}: still pending after wait (try ${https || 'URL'} shortly)`;
|
|
322
|
+
default:
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const extra = ev.payload ? ` ${JSON.stringify(ev.payload)}` : '';
|
|
327
|
+
return `${eventType}${extra}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Highest `build_deploy_events.id` for this build (for SSE `afterId` cursor).
|
|
332
|
+
* Pass accessToken when using exchanged deploy JWT (CI); omit for normal session.
|
|
333
|
+
*/
|
|
334
|
+
async function getMaxDeployEventIdForBuild(buildId, accessToken = null) {
|
|
335
|
+
const url = `${SUPABASE_URL}/rest/v1/build_deploy_events?select=id&build_id=eq.${encodeURIComponent(String(buildId))}&order=id.desc&limit=1`;
|
|
336
|
+
const data =
|
|
337
|
+
accessToken != null && String(accessToken).trim() !== ''
|
|
338
|
+
? await requestWithToken('GET', url, null, accessToken)
|
|
339
|
+
: await request('GET', url, null);
|
|
340
|
+
const row = Array.isArray(data) ? data[0] : null;
|
|
341
|
+
if (!row || row.id == null) return 0;
|
|
342
|
+
const n = Number(row.id);
|
|
343
|
+
return Number.isFinite(n) ? n : 0;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Stream build/deploy/archive events until a terminal event or the connection closes.
|
|
348
|
+
* @param {string} bearerToken
|
|
349
|
+
* @param {number|string} projectId
|
|
350
|
+
* @param {number|string} buildId
|
|
351
|
+
* @param {{ afterId?: number }} [options] afterId skips older rows (avoids instant exit on past deployment.completed)
|
|
352
|
+
* @returns {Promise<{ terminalEvent: object | null }>}
|
|
353
|
+
*/
|
|
354
|
+
async function streamDeployEventsUntilDone(bearerToken, projectId, buildId, options = {}) {
|
|
355
|
+
const qs = new URLSearchParams({
|
|
356
|
+
projectId: String(projectId),
|
|
357
|
+
buildId: String(buildId),
|
|
358
|
+
});
|
|
359
|
+
const aid = options.afterId;
|
|
360
|
+
if (aid != null && Number(aid) > 0) {
|
|
361
|
+
qs.set('afterId', String(aid));
|
|
362
|
+
}
|
|
363
|
+
const res = await fetch(
|
|
364
|
+
`${SUPABASE_URL}/functions/v1/deploy-events-stream?${qs.toString()}`,
|
|
365
|
+
{
|
|
366
|
+
headers: {
|
|
367
|
+
apikey: SUPABASE_ANON_KEY,
|
|
368
|
+
Authorization: `Bearer ${bearerToken}`,
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
if (!res.ok) {
|
|
374
|
+
const t = await res.text();
|
|
375
|
+
throw new Error(t || `HTTP ${res.status}`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const reader = res.body.getReader();
|
|
379
|
+
const dec = new TextDecoder();
|
|
380
|
+
let buf = '';
|
|
381
|
+
let terminalEvent = null;
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
outer: while (true) {
|
|
385
|
+
const { done, value } = await reader.read();
|
|
386
|
+
if (done) break;
|
|
387
|
+
buf += dec.decode(value, { stream: true });
|
|
388
|
+
let sep;
|
|
389
|
+
while ((sep = buf.indexOf('\n\n')) >= 0) {
|
|
390
|
+
const block = buf.slice(0, sep);
|
|
391
|
+
buf = buf.slice(sep + 2);
|
|
392
|
+
for (const line of block.split('\n')) {
|
|
393
|
+
if (!line.startsWith('data: ')) continue;
|
|
394
|
+
const json = line.slice(6).trim();
|
|
395
|
+
try {
|
|
396
|
+
const ev = JSON.parse(json);
|
|
397
|
+
if (ev.error) {
|
|
398
|
+
console.error('stream:', ev.error);
|
|
399
|
+
throw new Error(String(ev.error));
|
|
400
|
+
}
|
|
401
|
+
if (ev.event_type) {
|
|
402
|
+
const ts = ev.created_at || '';
|
|
403
|
+
console.log(`[${ts}] ${formatDeployEventForConsole(ev)}`);
|
|
404
|
+
if (TERMINAL_DEPLOY_EVENTS.has(String(ev.event_type))) {
|
|
405
|
+
terminalEvent = ev;
|
|
406
|
+
break outer;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
} catch (e) {
|
|
410
|
+
if (e instanceof SyntaxError) {
|
|
411
|
+
console.log(json);
|
|
412
|
+
} else {
|
|
413
|
+
throw e;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
} finally {
|
|
420
|
+
try {
|
|
421
|
+
await reader.cancel();
|
|
422
|
+
} catch {
|
|
423
|
+
// ignore
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return { terminalEvent };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Upload a single asset file to the project via the upload-file Edge function.
|
|
432
|
+
* Uses a pre-obtained Supabase access token.
|
|
433
|
+
*/
|
|
434
|
+
async function uploadAssetWithToken(accessToken, projectId, filePath, filename) {
|
|
435
|
+
const fs = require('fs');
|
|
436
|
+
const mime = require('./mime');
|
|
437
|
+
|
|
438
|
+
const bytes = fs.readFileSync(filePath);
|
|
439
|
+
const mimeType = mime.fromFilename(filename);
|
|
440
|
+
|
|
441
|
+
const formData = new FormData();
|
|
442
|
+
formData.append('file', new Blob([bytes], { type: mimeType }), filename);
|
|
443
|
+
formData.append('project_id', String(projectId));
|
|
444
|
+
|
|
445
|
+
const res = await fetch(`${SUPABASE_URL}/functions/v1/upload-file`, {
|
|
446
|
+
method: 'POST',
|
|
447
|
+
headers: {
|
|
448
|
+
'apikey': SUPABASE_ANON_KEY,
|
|
449
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
450
|
+
},
|
|
451
|
+
body: formData,
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const text = await res.text();
|
|
455
|
+
let data = null;
|
|
456
|
+
try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
|
|
457
|
+
if (!res.ok) {
|
|
458
|
+
const msg = (data && (data.error || data.message)) || `HTTP ${res.status}`;
|
|
459
|
+
const err = new Error(msg);
|
|
460
|
+
err.status = res.status;
|
|
461
|
+
throw err;
|
|
462
|
+
}
|
|
463
|
+
return data;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Upload all assets from the local `assets/` directory for a project.
|
|
468
|
+
* Skips files that already exist on the server (matched by filename).
|
|
469
|
+
* Returns the count of files uploaded.
|
|
470
|
+
*/
|
|
471
|
+
async function uploadAssetsWithToken(accessToken, projectId, cwd, onProgress) {
|
|
472
|
+
const path = require('path');
|
|
473
|
+
const { listAssetsFromDir } = require('./parser');
|
|
474
|
+
|
|
475
|
+
const assetFiles = listAssetsFromDir(cwd);
|
|
476
|
+
if (assetFiles.length === 0) return 0;
|
|
477
|
+
|
|
478
|
+
// Fetch existing filenames so we can skip unchanged files
|
|
479
|
+
const listRes = await fetch(
|
|
480
|
+
`${SUPABASE_URL}/functions/v1/list-files?project_id=${encodeURIComponent(projectId)}`,
|
|
481
|
+
{
|
|
482
|
+
headers: {
|
|
483
|
+
'apikey': SUPABASE_ANON_KEY,
|
|
484
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
);
|
|
488
|
+
const listData = listRes.ok ? await listRes.json() : null;
|
|
489
|
+
const existing = new Set((listData?.files || []).map((f) => f.filename));
|
|
490
|
+
|
|
491
|
+
let uploaded = 0;
|
|
492
|
+
for (const filename of assetFiles) {
|
|
493
|
+
if (existing.has(filename)) continue;
|
|
494
|
+
const filePath = path.join(cwd, 'assets', filename);
|
|
495
|
+
await uploadAssetWithToken(accessToken, projectId, filePath, filename);
|
|
496
|
+
if (onProgress) onProgress(filename);
|
|
497
|
+
uploaded++;
|
|
498
|
+
}
|
|
499
|
+
return uploaded;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Parse `.page` source content via the build compiler and insert a new build row, using a
|
|
504
|
+
* pre-obtained Supabase access token (e.g. from exchangeDeployTokenForAccessToken).
|
|
505
|
+
* Returns the created build row (`{ id, number, status }`).
|
|
506
|
+
*/
|
|
507
|
+
async function pushWithToken(accessToken, projectId, rawContent, buildCompilerUrl) {
|
|
508
|
+
const { BUILD_COMPILER_URL } = require('./config');
|
|
509
|
+
const url = buildCompilerUrl || BUILD_COMPILER_URL;
|
|
510
|
+
|
|
511
|
+
const parseRes = await fetch(`${url}/parse`, {
|
|
512
|
+
method: 'POST',
|
|
513
|
+
headers: {
|
|
514
|
+
'Content-Type': 'application/json',
|
|
515
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
516
|
+
},
|
|
517
|
+
body: JSON.stringify({
|
|
518
|
+
text: rawContent,
|
|
519
|
+
project_id: projectId,
|
|
520
|
+
version: '2',
|
|
521
|
+
}),
|
|
522
|
+
});
|
|
523
|
+
if (!parseRes.ok) {
|
|
524
|
+
const t = await parseRes.text();
|
|
525
|
+
throw new Error(`Parser error: ${t}`);
|
|
526
|
+
}
|
|
527
|
+
const jsonContent = await parseRes.json();
|
|
528
|
+
|
|
529
|
+
const insertUrl = `${SUPABASE_URL}/rest/v1/builds`;
|
|
530
|
+
const build = await requestWithToken(
|
|
531
|
+
'POST',
|
|
532
|
+
insertUrl,
|
|
533
|
+
{
|
|
534
|
+
project_id: projectId,
|
|
535
|
+
raw_content: rawContent,
|
|
536
|
+
json_content: jsonContent,
|
|
537
|
+
status: 'draft',
|
|
538
|
+
parser_version: '2',
|
|
539
|
+
},
|
|
540
|
+
accessToken,
|
|
541
|
+
{ 'Prefer': 'return=representation' },
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
return Array.isArray(build) ? build[0] : build;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Exchange a project deploy token for a short-lived Supabase access JWT (project owner).
|
|
549
|
+
* `projectUuid` is the project UUID from `.micropage/project.json` (`projectUuid` field).
|
|
550
|
+
* Response includes `project_id` (internal id) used for subsequent API calls.
|
|
551
|
+
*/
|
|
552
|
+
async function exchangeDeployTokenForAccessToken(deployTokenPlaintext, projectUuid, expiresInSeconds) {
|
|
553
|
+
const ttl =
|
|
554
|
+
expiresInSeconds != null && Number.isFinite(Number(expiresInSeconds))
|
|
555
|
+
? Math.min(3600, Math.max(60, Math.trunc(Number(expiresInSeconds))))
|
|
556
|
+
: undefined;
|
|
557
|
+
const body = { projectUuid: String(projectUuid).trim() };
|
|
558
|
+
if (ttl != null) body.expiresInSeconds = ttl;
|
|
559
|
+
|
|
560
|
+
const res = await fetch(`${SUPABASE_URL}/functions/v1/exchange-deploy-token`, {
|
|
561
|
+
method: 'POST',
|
|
562
|
+
headers: {
|
|
563
|
+
apikey: SUPABASE_ANON_KEY,
|
|
564
|
+
Authorization: `Bearer ${deployTokenPlaintext}`,
|
|
565
|
+
'Content-Type': 'application/json',
|
|
566
|
+
},
|
|
567
|
+
body: JSON.stringify(body),
|
|
568
|
+
});
|
|
569
|
+
const text = await res.text();
|
|
570
|
+
let data = null;
|
|
571
|
+
try {
|
|
572
|
+
data = text ? JSON.parse(text) : null;
|
|
573
|
+
} catch {
|
|
574
|
+
data = { raw: text };
|
|
575
|
+
}
|
|
576
|
+
if (!res.ok) {
|
|
577
|
+
const err = new Error(
|
|
578
|
+
(data && (data.error || data.message)) || text || `HTTP ${res.status}`,
|
|
579
|
+
);
|
|
580
|
+
err.status = res.status;
|
|
581
|
+
err.data = data;
|
|
582
|
+
throw err;
|
|
583
|
+
}
|
|
584
|
+
if (!data?.access_token) {
|
|
585
|
+
const err = new Error('exchange-deploy-token: missing access_token in response');
|
|
586
|
+
err.data = data;
|
|
587
|
+
throw err;
|
|
588
|
+
}
|
|
589
|
+
if (data.project_id == null || !Number.isFinite(Number(data.project_id))) {
|
|
590
|
+
const err = new Error('exchange-deploy-token: missing project_id in response');
|
|
591
|
+
err.data = data;
|
|
592
|
+
throw err;
|
|
593
|
+
}
|
|
594
|
+
return data;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Publish a build using a Supabase access JWT (e.g. from exchangeDeployTokenForAccessToken). */
|
|
598
|
+
async function invokePublishBuild(accessToken, projectId, buildId) {
|
|
599
|
+
const res = await fetch(`${SUPABASE_URL}/functions/v1/publish-build`, {
|
|
600
|
+
method: 'POST',
|
|
601
|
+
headers: {
|
|
602
|
+
apikey: SUPABASE_ANON_KEY,
|
|
603
|
+
Authorization: `Bearer ${accessToken}`,
|
|
604
|
+
'Content-Type': 'application/json',
|
|
605
|
+
},
|
|
606
|
+
body: JSON.stringify({ projectId: Number(projectId), buildId: Number(buildId) }),
|
|
607
|
+
});
|
|
608
|
+
const text = await res.text();
|
|
609
|
+
let data = null;
|
|
610
|
+
try {
|
|
611
|
+
data = text ? JSON.parse(text) : null;
|
|
612
|
+
} catch {
|
|
613
|
+
data = { raw: text };
|
|
614
|
+
}
|
|
615
|
+
if (!res.ok) {
|
|
616
|
+
const err = new Error(
|
|
617
|
+
(data && (data.error || data.message)) || text || `HTTP ${res.status}`,
|
|
618
|
+
);
|
|
619
|
+
err.status = res.status;
|
|
620
|
+
err.data = data;
|
|
621
|
+
throw err;
|
|
622
|
+
}
|
|
623
|
+
return data;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
module.exports = {
|
|
627
|
+
db,
|
|
628
|
+
fn,
|
|
629
|
+
getUserInfo,
|
|
630
|
+
handleAuthError,
|
|
631
|
+
getValidAccessToken,
|
|
632
|
+
getMaxDeployEventIdForBuild,
|
|
633
|
+
streamDeployEventsUntilDone,
|
|
634
|
+
exchangeDeployTokenForAccessToken,
|
|
635
|
+
pushWithToken,
|
|
636
|
+
invokePublishBuild,
|
|
637
|
+
};
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render a simple ASCII table.
|
|
5
|
+
*
|
|
6
|
+
* @param {string[][]} rows - Array of string arrays (one per row)
|
|
7
|
+
* @param {string[]} headers - Column header labels
|
|
8
|
+
*/
|
|
9
|
+
function formatTable(rows, headers) {
|
|
10
|
+
if (!rows.length) return;
|
|
11
|
+
const allRows = [headers, ...rows];
|
|
12
|
+
const widths = headers.map((_, i) =>
|
|
13
|
+
Math.max(...allRows.map((r) => String(r[i] || '-').length))
|
|
14
|
+
);
|
|
15
|
+
const line = (row) => widths.map((w, i) => String(row[i] || '-').padEnd(w)).join(' ');
|
|
16
|
+
const sep = widths.map((w) => '-'.repeat(w)).join(' ');
|
|
17
|
+
console.log(line(headers));
|
|
18
|
+
console.log(sep);
|
|
19
|
+
for (const row of rows) console.log(line(row));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Format an ISO date string to a human-readable form.
|
|
24
|
+
*/
|
|
25
|
+
function formatDate(iso) {
|
|
26
|
+
if (!iso) return '-';
|
|
27
|
+
try {
|
|
28
|
+
return new Date(iso).toLocaleString(undefined, {
|
|
29
|
+
year: 'numeric', month: 'short', day: 'numeric',
|
|
30
|
+
hour: '2-digit', minute: '2-digit',
|
|
31
|
+
});
|
|
32
|
+
} catch {
|
|
33
|
+
return iso;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Format bytes to a human-readable string.
|
|
39
|
+
*/
|
|
40
|
+
function formatBytes(bytes) {
|
|
41
|
+
if (!bytes && bytes !== 0) return '-';
|
|
42
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
43
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
44
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { formatTable, formatDate, formatBytes };
|