@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.
@@ -0,0 +1,496 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const https = require('https');
6
+ const http = require('http');
7
+ const {
8
+ db,
9
+ fn,
10
+ handleAuthError,
11
+ getValidAccessToken,
12
+ getMaxDeployEventIdForBuild,
13
+ streamDeployEventsUntilDone,
14
+ } = require('../supabase');
15
+ const { getProjectConfig, setProjectConfig } = require('../auth');
16
+ const { readPageFilesFromDir } = require('../parser');
17
+ const { BUILD_COMPILER_URL } = require('../config');
18
+ const { formatTable, formatDate } = require('../utils');
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /** CLI build version args: `16`, `v16`, ` v16 ` → build number; undefined if omitted. */
25
+ function parseOptionalBuildNumber(version) {
26
+ if (version === undefined || version === null) return undefined;
27
+ if (typeof version === 'number') {
28
+ if (!Number.isFinite(version)) {
29
+ console.error('Invalid build version. Use a number or vN (e.g. 16 or v16).');
30
+ process.exit(1);
31
+ }
32
+ return version;
33
+ }
34
+ const s = String(version).trim();
35
+ if (s === '') return undefined;
36
+ const n = Number(s.replace(/^v\s*/i, ''));
37
+ if (!Number.isFinite(n)) {
38
+ console.error('Invalid build version. Use a number or vN (e.g. 16 or v16).');
39
+ process.exit(1);
40
+ }
41
+ return n;
42
+ }
43
+
44
+ async function parseContent(rawContent, projectId, buildId) {
45
+ const accessToken = await getValidAccessToken();
46
+
47
+ const res = await fetch(`${BUILD_COMPILER_URL}/parse`, {
48
+ method: 'POST',
49
+ headers: {
50
+ 'Content-Type': 'application/json',
51
+ Authorization: `Bearer ${accessToken}`,
52
+ },
53
+ body: JSON.stringify({
54
+ text: rawContent,
55
+ project_id: projectId,
56
+ build_id: buildId || null,
57
+ version: '2',
58
+ }),
59
+ });
60
+ if (!res.ok) {
61
+ const text = await res.text();
62
+ throw new Error(`Parser error: ${text}`);
63
+ }
64
+ return res.json();
65
+ }
66
+
67
+ function requireProjectConfig(cwd) {
68
+ const config = getProjectConfig(cwd);
69
+ if (!config?.projectId) {
70
+ console.error('Not in a project folder. Run from a folder with .micropage/project.json');
71
+ process.exit(1);
72
+ }
73
+ return config;
74
+ }
75
+
76
+ async function setActiveBuildId(projectId, buildId) {
77
+ await db.from('projects').eq('id', projectId).update({ active_build_id: buildId });
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // build list
82
+ // ---------------------------------------------------------------------------
83
+
84
+ async function list(options = {}) {
85
+ const cwd = process.cwd();
86
+ const config = requireProjectConfig(cwd);
87
+
88
+ let builds;
89
+ try {
90
+ builds = await db
91
+ .from('builds')
92
+ .select('id,number,status,updated_at,failure_reason,archive_url,archive_size_bytes')
93
+ .eq('project_id', config.projectId)
94
+ .order('number', 'desc')
95
+ .get();
96
+ } catch (err) {
97
+ handleAuthError(err);
98
+ console.error('Failed to list builds:', err.message);
99
+ process.exit(1);
100
+ }
101
+
102
+ if (!Array.isArray(builds) || builds.length === 0) {
103
+ console.log('No builds for this project.');
104
+ return;
105
+ }
106
+
107
+ if (options.json) {
108
+ console.log(JSON.stringify(builds, null, 2));
109
+ return;
110
+ }
111
+
112
+ const rows = builds.map((b) => [
113
+ `v${b.number}`,
114
+ b.status,
115
+ formatDate(b.updated_at),
116
+ b.failure_reason ? b.failure_reason.slice(0, 40) : '',
117
+ ]);
118
+ formatTable(rows, ['Version', 'Status', 'Updated', 'Failure reason']);
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // push (merge local files → update/create draft build)
123
+ // ---------------------------------------------------------------------------
124
+
125
+ async function push(options = {}) {
126
+ const cwd = process.cwd();
127
+ const config = requireProjectConfig(cwd);
128
+
129
+ let rawContent;
130
+ try {
131
+ rawContent = readPageFilesFromDir(cwd);
132
+ } catch (err) {
133
+ console.error(err.message);
134
+ process.exit(1);
135
+ }
136
+
137
+ if (!rawContent.trim()) {
138
+ console.error('No content found in .page files.');
139
+ process.exit(1);
140
+ }
141
+
142
+ // Parse content via parser API
143
+ console.log('Parsing content…');
144
+ let jsonContent;
145
+ try {
146
+ jsonContent = await parseContent(rawContent, config.projectId, config.buildId);
147
+ } catch (err) {
148
+ console.error('Parse failed:', err.message);
149
+ process.exit(1);
150
+ }
151
+
152
+ // Decide whether to update existing draft or create new build
153
+ let existingBuild = null;
154
+ if (config.buildId) {
155
+ try {
156
+ existingBuild = await db.from('builds').select('id,number,status').eq('id', config.buildId).single();
157
+ } catch {
158
+ // build not found – will create new
159
+ }
160
+ }
161
+
162
+ const isDraft = existingBuild && (existingBuild.status === 'draft' || existingBuild.status === 'failed');
163
+
164
+ let build;
165
+ try {
166
+ if (isDraft) {
167
+ // Update existing draft
168
+ const results = await db
169
+ .from('builds')
170
+ .eq('id', existingBuild.id)
171
+ .update({ raw_content: rawContent, json_content: jsonContent });
172
+ build = Array.isArray(results) ? results[0] : existingBuild;
173
+ console.log(`Updated build v${existingBuild.number}.`);
174
+ } else {
175
+ // Create new draft build
176
+ const payload = {
177
+ project_id: config.projectId,
178
+ raw_content: rawContent,
179
+ json_content: jsonContent,
180
+ status: 'draft',
181
+ parser_version: '2',
182
+ };
183
+ const results = await db.from('builds').insert(payload);
184
+ build = Array.isArray(results) ? results[0] : results;
185
+ console.log(`Created build v${build.number}.`);
186
+ }
187
+ } catch (err) {
188
+ handleAuthError(err);
189
+ console.error('Push failed:', err.message);
190
+ process.exit(1);
191
+ }
192
+
193
+ // Persist buildId and align project.active_build_id (editor / publish default)
194
+ setProjectConfig(cwd, { ...config, buildId: build.id });
195
+ try {
196
+ await setActiveBuildId(config.projectId, build.id);
197
+ } catch (err) {
198
+ handleAuthError(err);
199
+ console.error('Failed to set active build:', err.message);
200
+ process.exit(1);
201
+ }
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // publish (push if needed, then trigger publish)
206
+ // ---------------------------------------------------------------------------
207
+
208
+ async function publish(options = {}) {
209
+ const cwd = process.cwd();
210
+ const config = requireProjectConfig(cwd);
211
+
212
+ // Always push first to upload latest local content
213
+ if (!options.noPush) {
214
+ console.log('Pushing local content…');
215
+ await push({ ...options, skipPublish: true });
216
+ }
217
+
218
+ // Re-read config after push (buildId may have changed)
219
+ const updatedConfig = getProjectConfig(cwd);
220
+
221
+ if (!updatedConfig.buildId) {
222
+ console.error('No build ID found after push. Cannot publish.');
223
+ process.exit(1);
224
+ }
225
+
226
+ // Fetch live project record to pick up custom_domain (not stored in local config).
227
+ let customDomain = null;
228
+ try {
229
+ const proj = await db
230
+ .from('projects')
231
+ .select('custom_domain')
232
+ .eq('id', updatedConfig.projectId)
233
+ .single();
234
+ customDomain = proj?.custom_domain || null;
235
+ } catch {
236
+ // best-effort; fall back to default domain
237
+ }
238
+
239
+ console.log('Publishing…');
240
+ try {
241
+ const eventCursor = await getMaxDeployEventIdForBuild(updatedConfig.buildId);
242
+ await fn.invoke('publish-build', {
243
+ buildId: updatedConfig.buildId,
244
+ projectId: updatedConfig.projectId,
245
+ });
246
+ console.log('Publish triggered. The build will be deployed in a few moments.');
247
+ const viewUrl = customDomain
248
+ ? `https://${customDomain}`
249
+ : `https://${updatedConfig.domain}.micropage.sh`;
250
+ console.log(`View at: ${viewUrl}`);
251
+ if (options.watch) {
252
+ console.log('');
253
+ console.log('Build / deploy events:');
254
+ try {
255
+ const accessToken = await getValidAccessToken();
256
+ const { terminalEvent } = await streamDeployEventsUntilDone(
257
+ accessToken,
258
+ updatedConfig.projectId,
259
+ updatedConfig.buildId,
260
+ { afterId: eventCursor },
261
+ );
262
+ if (terminalEvent?.event_type === 'build.failed') {
263
+ const msg =
264
+ (terminalEvent.payload && (terminalEvent.payload.error || terminalEvent.payload.message)) ||
265
+ 'Build failed';
266
+ console.error(msg);
267
+ process.exit(1);
268
+ }
269
+ } catch (streamErr) {
270
+ console.error('Event stream failed:', streamErr.message);
271
+ process.exit(1);
272
+ }
273
+ }
274
+ } catch (err) {
275
+ handleAuthError(err);
276
+ console.error('Publish failed:', err.message);
277
+ process.exit(1);
278
+ }
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // build redeploy (set active build + republish that snapshot, no new row)
283
+ // ---------------------------------------------------------------------------
284
+
285
+ async function redeploy(versionArg, options = {}) {
286
+ const cwd = process.cwd();
287
+ const config = requireProjectConfig(cwd);
288
+ const buildNumber = parseOptionalBuildNumber(versionArg);
289
+
290
+ // Find the build by number
291
+ let sourceBuild;
292
+ try {
293
+ if (buildNumber !== undefined) {
294
+ sourceBuild = await db
295
+ .from('builds')
296
+ .select('id,number,status')
297
+ .eq('project_id', config.projectId)
298
+ .eq('number', buildNumber)
299
+ .single();
300
+ } else {
301
+ // Default: use current build
302
+ if (!config.buildId) {
303
+ console.error('No build number specified and no current build ID found.');
304
+ process.exit(1);
305
+ }
306
+ sourceBuild = await db
307
+ .from('builds')
308
+ .select('id,number,status')
309
+ .eq('id', config.buildId)
310
+ .single();
311
+ }
312
+ } catch (err) {
313
+ handleAuthError(err);
314
+ console.error('Failed to find build:', err.message);
315
+ process.exit(1);
316
+ }
317
+
318
+ if (!sourceBuild) {
319
+ console.error('Build not found.');
320
+ process.exit(1);
321
+ }
322
+
323
+ console.log(`Republishing v${sourceBuild.number} (setting active build)…`);
324
+
325
+ try {
326
+ await setActiveBuildId(config.projectId, sourceBuild.id);
327
+ } catch (err) {
328
+ handleAuthError(err);
329
+ console.error('Failed to set active build:', err.message);
330
+ process.exit(1);
331
+ }
332
+
333
+ setProjectConfig(cwd, { ...config, buildId: sourceBuild.id });
334
+
335
+ try {
336
+ await fn.invoke('publish-build', {
337
+ buildId: sourceBuild.id,
338
+ projectId: config.projectId,
339
+ });
340
+ console.log(`Publish triggered for v${sourceBuild.number}.`);
341
+ } catch (err) {
342
+ handleAuthError(err);
343
+ console.error('Publish failed:', err.message);
344
+ process.exit(1);
345
+ }
346
+ }
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // build download
350
+ // ---------------------------------------------------------------------------
351
+
352
+ async function download(versionArg, options = {}) {
353
+ const cwd = process.cwd();
354
+ const config = requireProjectConfig(cwd);
355
+ const buildNumber = parseOptionalBuildNumber(versionArg);
356
+
357
+ let build;
358
+ try {
359
+ if (buildNumber !== undefined) {
360
+ build = await db
361
+ .from('builds')
362
+ .select('id,number,status,archive_url,project_id')
363
+ .eq('project_id', config.projectId)
364
+ .eq('number', buildNumber)
365
+ .single();
366
+ } else {
367
+ // Download latest deployed build
368
+ const builds = await db
369
+ .from('builds')
370
+ .select('id,number,status,archive_url,project_id')
371
+ .eq('project_id', config.projectId)
372
+ .eq('status', 'deployed')
373
+ .order('number', 'desc')
374
+ .limit(1)
375
+ .get();
376
+ build = builds?.[0] || null;
377
+ }
378
+ } catch (err) {
379
+ handleAuthError(err);
380
+ console.error('Failed to find build:', err.message);
381
+ process.exit(1);
382
+ }
383
+
384
+ if (!build) {
385
+ console.error('No matching build found.');
386
+ process.exit(1);
387
+ }
388
+
389
+ if (build.status !== 'deployed') {
390
+ console.error(`Build v${build.number} is not deployed; archives are only available for deployed builds.`);
391
+ process.exit(1);
392
+ }
393
+
394
+ const projectIdForArchive = build.project_id != null ? build.project_id : config.projectId;
395
+ const archiveEventCursor = await getMaxDeployEventIdForBuild(build.id);
396
+
397
+ // Invoke async archive creation; publisher job uploads zip and sets archive_url; events stream progress.
398
+ try {
399
+ const data = await fn.invoke('request-build-archive', {
400
+ buildId: String(build.id),
401
+ projectId: String(projectIdForArchive),
402
+ });
403
+ if (data?.already_ready && data.archive_url) {
404
+ build.archive_url = data.archive_url;
405
+ } else if (data?.queued || data?.pending) {
406
+ console.log('Archive generation queued — streaming progress (zip may take several minutes)…');
407
+ try {
408
+ const accessToken = await getValidAccessToken();
409
+ const { terminalEvent } = await streamDeployEventsUntilDone(
410
+ accessToken,
411
+ projectIdForArchive,
412
+ build.id,
413
+ { afterId: archiveEventCursor },
414
+ );
415
+ if (terminalEvent?.event_type === 'archive.failed') {
416
+ const msg =
417
+ (terminalEvent.payload && (terminalEvent.payload.error || terminalEvent.payload.message)) ||
418
+ 'Archive generation failed';
419
+ console.error(msg);
420
+ process.exit(1);
421
+ }
422
+ if (terminalEvent?.event_type === 'archive.completed' && terminalEvent.payload?.archive_url) {
423
+ build.archive_url = terminalEvent.payload.archive_url;
424
+ }
425
+ } catch (streamErr) {
426
+ handleAuthError(streamErr);
427
+ console.error('Event stream failed:', streamErr.message);
428
+ process.exit(1);
429
+ }
430
+ } else {
431
+ console.warn('Archive response did not include ready/queued/pending; polling builds table.');
432
+ }
433
+ } catch (err) {
434
+ handleAuthError(err);
435
+ console.error('Failed to invoke request-build-archive:', err.message);
436
+ process.exit(1);
437
+ }
438
+
439
+ // Poll builds table until archive_url is populated or timeout.
440
+ const started = Date.now();
441
+ const timeoutMs = 2 * 60 * 1000; // 2 minutes
442
+ const pollIntervalMs = 5000;
443
+ while (!build.archive_url && Date.now() - started < timeoutMs) {
444
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
445
+ const rows = await db
446
+ .from('builds')
447
+ .select('id,number,status,archive_url')
448
+ .eq('id', build.id)
449
+ .get();
450
+ const refreshed = rows?.[0];
451
+ if (!refreshed) {
452
+ console.error('Build disappeared while waiting for archive.');
453
+ process.exit(1);
454
+ }
455
+ if (refreshed.status !== 'deployed') {
456
+ console.error(`Build changed status to ${refreshed.status} while waiting for archive; aborting.`);
457
+ process.exit(1);
458
+ }
459
+ build = refreshed;
460
+ }
461
+
462
+ if (!build.archive_url) {
463
+ console.error('Timed out waiting for archive to become available.');
464
+ process.exit(1);
465
+ }
466
+
467
+ const outFile = options.output || path.join(cwd, `build-v${build.number}.zip`);
468
+ console.log(`Downloading build v${build.number} → ${outFile}`);
469
+
470
+ await downloadUrl(build.archive_url, outFile);
471
+ console.log('Download complete.');
472
+ }
473
+
474
+ function downloadUrl(url, dest) {
475
+ return new Promise((resolve, reject) => {
476
+ const proto = url.startsWith('https') ? https : http;
477
+ const file = fs.createWriteStream(dest);
478
+ proto.get(url, (res) => {
479
+ if (res.statusCode === 301 || res.statusCode === 302) {
480
+ file.close();
481
+ fs.unlinkSync(dest);
482
+ downloadUrl(res.headers.location, dest).then(resolve).catch(reject);
483
+ return;
484
+ }
485
+ if (res.statusCode !== 200) {
486
+ reject(new Error(`Download failed: HTTP ${res.statusCode}`));
487
+ return;
488
+ }
489
+ res.pipe(file);
490
+ file.on('finish', () => file.close(resolve));
491
+ file.on('error', reject);
492
+ }).on('error', reject);
493
+ });
494
+ }
495
+
496
+ module.exports = { list, push, publish, redeploy, download };