@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,573 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const {
6
+ db,
7
+ fn,
8
+ handleAuthError,
9
+ streamDeployEventsUntilDone,
10
+ getMaxDeployEventIdForBuild,
11
+ exchangeDeployTokenForAccessToken,
12
+ uploadAssetsWithToken,
13
+ pushWithToken,
14
+ invokePublishBuild,
15
+ } = require('../supabase');
16
+ const { getProjectConfig, setProjectConfig } = require('../auth');
17
+ const { fetchProjectByUserRef, fetchProjectFromConfig } = require('../project-ref');
18
+ const { writePageFile } = require('../parser');
19
+ const { formatTable, formatDate } = require('../utils');
20
+ const { syncAssets } = require('./files');
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Helpers
24
+ // ---------------------------------------------------------------------------
25
+
26
+ function projectUrl(host) {
27
+ if (!host) return '-';
28
+ if (host.startsWith('http://') || host.startsWith('https://')) return host;
29
+ return `https://${host}`;
30
+ }
31
+
32
+ function copyIfExists(src, dest) {
33
+ try {
34
+ if (fs.existsSync(src)) {
35
+ fs.copyFileSync(src, dest);
36
+ }
37
+ } catch {
38
+ // best-effort; ignore copy errors for templates
39
+ }
40
+ }
41
+
42
+ function copyExamplesAndAssetsIntoProject(dir) {
43
+ const templatesRoot = path.join(__dirname, '..', '..', 'templates');
44
+ const examplesSrc = path.join(templatesRoot, 'examples');
45
+ const assetsSrc = path.join(templatesRoot, 'assets');
46
+
47
+ const examplesDest = path.join(dir, 'examples');
48
+ if (!fs.existsSync(examplesDest)) {
49
+ fs.mkdirSync(examplesDest, { recursive: true });
50
+ }
51
+
52
+ // Copy example .page files
53
+ try {
54
+ const files = fs.readdirSync(examplesSrc);
55
+ for (const file of files) {
56
+ if (!file.endsWith('.page')) continue;
57
+ const src = path.join(examplesSrc, file);
58
+ const dest = path.join(examplesDest, file);
59
+ copyIfExists(src, dest);
60
+ }
61
+ } catch {
62
+ // ignore if templates/examples missing in this build
63
+ }
64
+
65
+ // Default logo / favicon in assets/ (uploaded on push as logo.svg / favicon.svg; examples use `logo: <- logo.svg`)
66
+ const destAssets = path.join(dir, 'assets');
67
+ if (!fs.existsSync(destAssets)) {
68
+ fs.mkdirSync(destAssets, { recursive: true });
69
+ }
70
+ copyIfExists(path.join(assetsSrc, 'logo.svg'), path.join(destAssets, 'logo.svg'));
71
+ copyIfExists(path.join(assetsSrc, 'favicon.svg'), path.join(destAssets, 'favicon.svg'));
72
+ }
73
+
74
+ async function getLatestBuild(projectId) {
75
+ return db
76
+ .from('builds')
77
+ .select('id,number,status,updated_at,failure_reason,archive_url,raw_content')
78
+ .eq('project_id', projectId)
79
+ .order('number', 'desc')
80
+ .limit(1)
81
+ .single();
82
+ }
83
+
84
+ async function pullStorageSync(cwd) {
85
+ const r = await syncAssets(cwd, { quiet: true, prune: true });
86
+ if (r.errors.length > 0) {
87
+ for (const e of r.errors) {
88
+ console.error(`Asset sync failed (${e.name}): ${e.message}`);
89
+ }
90
+ process.exit(1);
91
+ }
92
+ if (r.downloaded > 0 || r.removed > 0) {
93
+ console.log(
94
+ `Synced storage → assets/ (${r.downloaded} file(s) written, ${r.removed} removed locally).`,
95
+ );
96
+ }
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // project list
101
+ // ---------------------------------------------------------------------------
102
+
103
+ async function list(options = {}) {
104
+ let projects;
105
+ try {
106
+ projects = await db
107
+ .from('projects')
108
+ .select('id,uuid,name,domain,custom_domain,status,created_at')
109
+ .order('id', 'desc')
110
+ .get();
111
+ } catch (err) {
112
+ handleAuthError(err);
113
+ console.error('Failed to list projects:', err.message);
114
+ process.exit(1);
115
+ }
116
+
117
+ if (!Array.isArray(projects) || projects.length === 0) {
118
+ console.log('No projects.');
119
+ return;
120
+ }
121
+
122
+ if (options.json) {
123
+ console.log(JSON.stringify(projects, null, 2));
124
+ return;
125
+ }
126
+
127
+ // Fetch latest build for each project in one query
128
+ const ids = projects.map((p) => p.id);
129
+ let allBuilds = [];
130
+ try {
131
+ allBuilds = await db
132
+ .from('builds')
133
+ .select('project_id,number,status,updated_at')
134
+ .in('project_id', ids)
135
+ .order('number', 'desc')
136
+ .get();
137
+ } catch {
138
+ // builds query is best-effort
139
+ }
140
+
141
+ const latestBuild = {};
142
+ for (const b of allBuilds || []) {
143
+ if (!latestBuild[b.project_id]) latestBuild[b.project_id] = b;
144
+ }
145
+
146
+ const rows = projects.map((p) => {
147
+ const build = latestBuild[p.id];
148
+ const url = projectUrl(p.custom_domain || p.domain);
149
+ return [
150
+ p.uuid || '-',
151
+ p.name || '-',
152
+ url,
153
+ build ? `v${build.number} ${build.status}` : '-',
154
+ ];
155
+ });
156
+
157
+ formatTable(rows, ['UUID', 'Name', 'URL', 'Latest build']);
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // project show / info
162
+ // ---------------------------------------------------------------------------
163
+
164
+ async function show(uuidOrDomain, options = {}) {
165
+ const cwd = process.cwd();
166
+ let project;
167
+ try {
168
+ if (uuidOrDomain != null && String(uuidOrDomain).trim() !== '') {
169
+ project = await fetchProjectByUserRef(db, uuidOrDomain);
170
+ } else {
171
+ const config = getProjectConfig(cwd);
172
+ if (!config?.projectId && !config?.projectUuid) {
173
+ console.error(
174
+ 'Not in a project folder and no argument given. Run from a folder with .micropage/project.json or pass a project UUID or domain.',
175
+ );
176
+ process.exit(1);
177
+ }
178
+ project = await fetchProjectFromConfig(db, config);
179
+ }
180
+ } catch (err) {
181
+ handleAuthError(err);
182
+ console.error('Failed to fetch project:', err.message);
183
+ process.exit(1);
184
+ }
185
+
186
+ if (!project) {
187
+ console.error('Project not found:', uuidOrDomain != null && String(uuidOrDomain).trim() !== '' ? uuidOrDomain : '(from .micropage/project.json)');
188
+ process.exit(1);
189
+ }
190
+
191
+ const build = await getLatestBuild(project.id).catch(() => null);
192
+
193
+ if (options.json) {
194
+ console.log(JSON.stringify({ ...project, latest_build: build }, null, 2));
195
+ return;
196
+ }
197
+
198
+ const url = projectUrl(project.custom_domain || project.domain);
199
+
200
+ console.log('UUID: ', project.uuid || '-');
201
+ console.log('Name: ', project.name || '-');
202
+ console.log('Domain: ', project.domain || '-');
203
+ console.log('Custom domain:', project.custom_domain || '-');
204
+ console.log('URL: ', url);
205
+ console.log('Created: ', formatDate(project.created_at));
206
+ if (build) {
207
+ console.log('Build: ', `v${build.number}`);
208
+ console.log('Build status: ', build.status);
209
+ console.log('Updated: ', formatDate(build.updated_at));
210
+ if (build.failure_reason) {
211
+ console.log('Failure: ', build.failure_reason);
212
+ }
213
+ }
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // project create
218
+ // ---------------------------------------------------------------------------
219
+
220
+ async function create(name, options = {}) {
221
+ const payload = { name };
222
+ if (options.domain) {
223
+ payload.domain = options.domain;
224
+ }
225
+
226
+ let project;
227
+ try {
228
+ const result = await db.from('projects').insert(payload);
229
+ project = Array.isArray(result) ? result[0] : result;
230
+ } catch (err) {
231
+ handleAuthError(err);
232
+ console.error('Failed to create project:', err.message);
233
+ if (err.data) console.error(JSON.stringify(err.data));
234
+ process.exit(1);
235
+ }
236
+
237
+ const cwd = process.cwd();
238
+ const dir = path.join(cwd, name);
239
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
240
+
241
+ setProjectConfig(dir, {
242
+ projectId: project.id,
243
+ ...(project.uuid ? { projectUuid: project.uuid } : {}),
244
+ domain: project.domain,
245
+ name: project.name || name,
246
+ });
247
+ fs.mkdirSync(path.join(dir, 'assets'), { recursive: true });
248
+
249
+ copyExamplesAndAssetsIntoProject(dir);
250
+
251
+ // PROJECT_AGENT metadata for local agents and tools
252
+ try {
253
+ const agentTemplate = path.join(__dirname, '..', '..', 'templates', 'PROJECT_AGENT.template.md');
254
+ if (fs.existsSync(agentTemplate)) {
255
+ const contents = fs.readFileSync(agentTemplate, 'utf8');
256
+ fs.writeFileSync(path.join(dir, 'PROJECT_AGENT.md'), contents, 'utf8');
257
+ }
258
+ } catch {
259
+ // best-effort; safe to continue if agent metadata cannot be written
260
+ }
261
+
262
+ console.log('Created project:', project.name || name);
263
+ if (project.uuid) console.log('UUID:', project.uuid);
264
+ console.log('URL:', projectUrl(project.domain));
265
+ console.log('Folder:', dir);
266
+ console.log(`\nNext: cd ${name} && touch landing.page && micropage push`);
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // project pull (remote latest build -> local landing.page)
271
+ // ---------------------------------------------------------------------------
272
+
273
+ async function pull(options = {}) {
274
+ const cwd = process.cwd();
275
+ const config = getProjectConfig(cwd);
276
+ if (!config?.projectId) {
277
+ console.error('Not in a project folder. Run from a folder with .micropage/project.json');
278
+ process.exit(1);
279
+ }
280
+
281
+ let build;
282
+ try {
283
+ build = await getLatestBuild(config.projectId);
284
+ } catch (err) {
285
+ handleAuthError(err);
286
+ console.error('Failed to fetch build:', err.message);
287
+ process.exit(1);
288
+ }
289
+
290
+ if (!build) {
291
+ console.log('No builds found for this project. Create content locally and run: micropage push');
292
+ try {
293
+ await pullStorageSync(cwd);
294
+ } catch (err) {
295
+ handleAuthError(err);
296
+ console.error('Asset sync failed:', err.message);
297
+ process.exit(1);
298
+ }
299
+ return;
300
+ }
301
+
302
+ if (!build.raw_content) {
303
+ console.log(`Build v${build.number} has no raw content to pull.`);
304
+ try {
305
+ await pullStorageSync(cwd);
306
+ } catch (err) {
307
+ handleAuthError(err);
308
+ console.error('Asset sync failed:', err.message);
309
+ process.exit(1);
310
+ }
311
+ return;
312
+ }
313
+
314
+ const dest = writePageFile(cwd, build.raw_content);
315
+ setProjectConfig(cwd, { ...config, buildId: build.id });
316
+
317
+ console.log(`Pulled build v${build.number} (${build.status}) → ${dest}`);
318
+ try {
319
+ await pullStorageSync(cwd);
320
+ } catch (err) {
321
+ handleAuthError(err);
322
+ console.error('Asset sync failed:', err.message);
323
+ process.exit(1);
324
+ }
325
+ }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // project fetch (legacy: clone remote project to local folder)
329
+ // ---------------------------------------------------------------------------
330
+
331
+ async function fetch(uuidOrDomain) {
332
+ if (!uuidOrDomain) {
333
+ console.error('Usage: micropage projects fetch <uuid|domain>');
334
+ process.exit(1);
335
+ }
336
+
337
+ let project;
338
+ try {
339
+ project = await fetchProjectByUserRef(db, uuidOrDomain);
340
+ } catch (err) {
341
+ handleAuthError(err);
342
+ if (err.status === 404 || !project) {
343
+ console.error('Project not found:', uuidOrDomain);
344
+ } else {
345
+ console.error('Failed to fetch project:', err.message);
346
+ }
347
+ process.exit(1);
348
+ }
349
+
350
+ if (!project) {
351
+ console.error('Project not found:', uuidOrDomain);
352
+ process.exit(1);
353
+ }
354
+
355
+ const name = project.name || project.domain || project.uuid || String(project.id);
356
+ const cwd = process.cwd();
357
+ const dir = path.join(cwd, name);
358
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
359
+
360
+ setProjectConfig(dir, {
361
+ projectId: project.id,
362
+ ...(project.uuid ? { projectUuid: project.uuid } : {}),
363
+ domain: project.domain,
364
+ name: project.name || name,
365
+ });
366
+ fs.mkdirSync(path.join(dir, 'assets'), { recursive: true });
367
+
368
+ // Optionally pull latest build content
369
+ const build = await getLatestBuild(project.id).catch(() => null);
370
+ if (build?.raw_content) {
371
+ writePageFile(dir, build.raw_content);
372
+ setProjectConfig(dir, {
373
+ projectId: project.id,
374
+ ...(project.uuid ? { projectUuid: project.uuid } : {}),
375
+ domain: project.domain,
376
+ name: project.name || name,
377
+ buildId: build.id,
378
+ });
379
+ console.log(`Fetched project: ${name} (build v${build.number}) → ${dir}`);
380
+ if (project.uuid) console.log('UUID:', project.uuid);
381
+ } else {
382
+ console.log('Fetched project:', name);
383
+ console.log('Folder:', dir);
384
+ if (project.uuid) console.log('UUID:', project.uuid);
385
+ }
386
+
387
+ try {
388
+ await pullStorageSync(dir);
389
+ } catch (err) {
390
+ handleAuthError(err);
391
+ console.error('Asset sync failed:', err.message);
392
+ process.exit(1);
393
+ }
394
+ }
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // project delete
398
+ // ---------------------------------------------------------------------------
399
+
400
+ async function deleteProject(options = {}) {
401
+ const readline = require('readline');
402
+ const cwd = process.cwd();
403
+ const config = getProjectConfig(cwd);
404
+ if (!config?.projectId && !config?.projectUuid) {
405
+ console.error('Not in a project folder. Run from a folder with .micropage/project.json');
406
+ process.exit(1);
407
+ }
408
+
409
+ let remoteProjectId = config.projectId;
410
+ if (remoteProjectId == null && config.projectUuid) {
411
+ try {
412
+ const row = await db
413
+ .from('projects')
414
+ .select('id')
415
+ .eq('uuid', String(config.projectUuid).trim())
416
+ .single();
417
+ remoteProjectId = row?.id;
418
+ } catch (err) {
419
+ handleAuthError(err);
420
+ console.error('Failed to resolve project:', err.message);
421
+ process.exit(1);
422
+ }
423
+ }
424
+ if (remoteProjectId == null) {
425
+ console.error('Could not determine project id for deletion. Re-fetch or recreate .micropage/project.json.');
426
+ process.exit(1);
427
+ }
428
+
429
+ if (!options.yes) {
430
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
431
+ const label = config.name || config.projectUuid || config.projectId;
432
+ const answer = await new Promise((resolve) => {
433
+ rl.question(
434
+ `Delete project "${label}" (remote + local .micropage)? [y/N] `,
435
+ (a) => { rl.close(); resolve((a || '').trim().toLowerCase()); }
436
+ );
437
+ });
438
+ if (answer !== 'y' && answer !== 'yes') {
439
+ console.log('Aborted.');
440
+ return;
441
+ }
442
+ }
443
+
444
+ try {
445
+ await fn.invoke('delete-project', { projectId: remoteProjectId });
446
+ console.log('Project deletion initiated on server (cleanup may continue in the background).');
447
+ } catch (err) {
448
+ handleAuthError(err);
449
+ if (err.status === 404) {
450
+ console.log('Project already removed on server.');
451
+ } else {
452
+ console.error('Delete failed:', err.message);
453
+ process.exit(1);
454
+ }
455
+ }
456
+
457
+ const configPath = path.join(cwd, '.micropage');
458
+ if (fs.existsSync(configPath)) {
459
+ fs.rmSync(configPath, { recursive: true });
460
+ console.log('Removed local .micropage/');
461
+ }
462
+ }
463
+
464
+ /**
465
+ * CI deploy using a project deploy token. No Micropage login required.
466
+ *
467
+ * Without --build: reads .page files from the current directory, parses them,
468
+ * creates a new build, then publishes. This is the standard CI flow.
469
+ *
470
+ * With --build <id>: publishes an existing build by id (e.g. to redeploy a
471
+ * specific snapshot without touching files).
472
+ */
473
+ async function deploy(projectUuid, deployToken, options = {}) {
474
+ if (!deployToken) {
475
+ console.error('A deploy token is required. Usage: micropage projects deploy [projectUuid] <token>');
476
+ process.exit(1);
477
+ }
478
+
479
+ if (!projectUuid) {
480
+ const config = getProjectConfig(process.cwd());
481
+ if (!config?.projectUuid) {
482
+ console.error('No project UUID found. Either pass it as the first argument or run from a project directory.');
483
+ process.exit(1);
484
+ }
485
+ projectUuid = config.projectUuid;
486
+ }
487
+
488
+ try {
489
+ const exchanged = await exchangeDeployTokenForAccessToken(
490
+ deployToken,
491
+ projectUuid,
492
+ options.exchangeTtl,
493
+ );
494
+ const accessToken = exchanged.access_token;
495
+ const internalProjectId = exchanged.project_id;
496
+
497
+ let buildId = options.build;
498
+
499
+ if (buildId === undefined || buildId === null || String(buildId).trim() === '') {
500
+ // No --build supplied: read .page files from CWD and create a new build.
501
+ const cwd = process.cwd();
502
+ const { readPageFilesFromDir } = require('../parser');
503
+
504
+ let rawContent;
505
+ try {
506
+ rawContent = readPageFilesFromDir(cwd);
507
+ } catch (err) {
508
+ console.error('Failed to read .page files:', err.message);
509
+ process.exit(1);
510
+ }
511
+
512
+ if (!rawContent || !rawContent.trim()) {
513
+ console.error('No content found in .page files. Run from a folder with .page source files.');
514
+ process.exit(1);
515
+ }
516
+
517
+ // Upload new assets from assets/ before parsing
518
+ try {
519
+ const uploaded = await uploadAssetsWithToken(
520
+ accessToken,
521
+ internalProjectId,
522
+ cwd,
523
+ (filename) => console.log(`Uploaded asset: ${filename}`),
524
+ );
525
+ if (uploaded > 0) console.log(`Uploaded ${uploaded} asset(s).`);
526
+ } catch (err) {
527
+ console.error('Asset upload failed:', err.message);
528
+ process.exit(1);
529
+ }
530
+
531
+ console.log('Parsing content and creating build…');
532
+ let build;
533
+ try {
534
+ build = await pushWithToken(accessToken, internalProjectId, rawContent);
535
+ } catch (err) {
536
+ console.error('Build creation failed:', err.message);
537
+ process.exit(1);
538
+ }
539
+
540
+ buildId = build.id;
541
+ console.log(`Build v${build.number} created (id: ${buildId}).`);
542
+ }
543
+
544
+ console.log('Publishing…');
545
+ const deployEventCursor = await getMaxDeployEventIdForBuild(buildId, accessToken);
546
+ await invokePublishBuild(accessToken, internalProjectId, buildId);
547
+ console.log('Deploy triggered for build', buildId);
548
+
549
+ if (options.watch) {
550
+ console.log('');
551
+ console.log('Build / deploy events:');
552
+ const streamToken = options.watchWithDeployToken ? deployToken : accessToken;
553
+ const { terminalEvent } = await streamDeployEventsUntilDone(
554
+ streamToken,
555
+ internalProjectId,
556
+ buildId,
557
+ { afterId: deployEventCursor },
558
+ );
559
+ if (terminalEvent?.event_type === 'build.failed') {
560
+ const msg =
561
+ (terminalEvent.payload && (terminalEvent.payload.error || terminalEvent.payload.message)) ||
562
+ 'Build failed';
563
+ console.error(msg);
564
+ process.exit(1);
565
+ }
566
+ }
567
+ } catch (err) {
568
+ console.error('Deploy failed:', err.message);
569
+ process.exit(1);
570
+ }
571
+ }
572
+
573
+ module.exports = { list, show, create, pull, fetch, deleteProject, deploy };
@@ -0,0 +1,10 @@
1
+ 'use strict';
2
+
3
+ // Thin wrapper – delegates to builds.publish
4
+ const builds = require('./builds');
5
+
6
+ async function run() {
7
+ await builds.publish();
8
+ }
9
+
10
+ module.exports = { run };