@micropage-sh/cli 2.5.0 → 2.6.1

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 CHANGED
@@ -19,7 +19,7 @@ brew install micropage
19
19
  ### npm
20
20
 
21
21
  ```bash
22
- npm install -g micropage
22
+ npm install -g @micropage-sh/cli
23
23
  ```
24
24
 
25
25
  ## Authentication
@@ -175,9 +175,9 @@ When you create a new project, the CLI also scaffolds:
175
175
 
176
176
  | Command | Description |
177
177
  |---|---|
178
- | `micropage submissions list [--json]` | List form submissions for the project |
179
- | `micropage submissions show <id> [--json]` | Show a single submission in detail |
180
- | `micropage submissions export [--format csv|json] [-o file]` | Export all form submissions for the current project |
178
+ | `micropage submissions list [--json] [--spam]` | List form submissions for the project. Spam is excluded; `--spam` lists only submissions flagged as spam, with the reason |
179
+ | `micropage submissions show <id> [--json]` | Show a single submission in detail, including a `Spam:` line with the reason when it was flagged |
180
+ | `micropage submissions export [--format csv|json] [-o file] [--spam]` | Export form submissions for the current project. Spam is excluded; `--spam` exports only flagged submissions (default file `submissions-spam.<format>`, CSV gets a trailing `spam_reason` column) |
181
181
 
182
182
  **Examples:**
183
183
 
@@ -199,6 +199,12 @@ When you create a new project, the CLI also scaffolds:
199
199
  micropage submissions export --format json --output submissions.json
200
200
  ```
201
201
 
202
+ - Export only submissions flagged as spam:
203
+
204
+ ```bash
205
+ micropage submissions export --spam
206
+ ```
207
+
202
208
  ## Local content model
203
209
 
204
210
  The CLI uses one or more `*.page` files in the working directory as the source of truth for project content.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micropage-sh/cli",
3
- "version": "2.5.0",
3
+ "version": "2.6.1",
4
4
  "description": "CLI for micropage.sh - create, sync, and publish microsites",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  "README.md"
14
14
  ],
15
15
  "scripts": {
16
- "test": "node -e \"require('./src/index.js')\"; node --test test/",
16
+ "test": "node -e \"require('./src/index.js')\"; node --test test/*.test.js test/*_test.mjs",
17
17
  "build:bin": "pkg . --targets node18-linux-x64,node18-macos-x64,node18-macos-arm64 --output dist/micropage",
18
18
  "build:linux": "pkg . --target node18-linux-x64 --output dist/micropage-linux-amd64",
19
19
  "build:macos-amd64": "pkg . --target node18-macos-x64 --output dist/micropage-darwin-amd64",
@@ -52,13 +52,14 @@ async function list(options = {}) {
52
52
  return;
53
53
  }
54
54
 
55
- // Count submissions per form_id
55
+ // Count non-spam submissions per form_id
56
56
  let submissions;
57
57
  try {
58
58
  submissions = await db
59
59
  .from('form_submissions')
60
60
  .select('form_id')
61
61
  .eq('project_id', config.projectId)
62
+ .is('flagged_at', 'null')
62
63
  .get();
63
64
  } catch (err) {
64
65
  handleAuthError(err);
@@ -9,7 +9,7 @@ const {
9
9
  fn,
10
10
  handleAuthError,
11
11
  getValidAccessToken,
12
- getMaxDeployEventIdForBuild,
12
+ getMaxDeployEventIdForProject,
13
13
  streamDeployEventsUntilDone,
14
14
  } = require('../supabase');
15
15
  const { getProjectConfig } = require('../auth');
@@ -478,6 +478,18 @@ function localSlugsFromPostsDir(postsDir) {
478
478
  return slugs;
479
479
  }
480
480
 
481
+ /**
482
+ * The build the publish-post rebuilds redeploy. Servers that report
483
+ * rebuild_build_id rebuild the live build, never a page draft; older ones
484
+ * leave it out and rebuild the project's active build.
485
+ */
486
+ function rebuildTarget(responses, activeBuildId) {
487
+ const reported = responses.filter((r) => r && Object.prototype.hasOwnProperty.call(r, 'rebuild_build_id'));
488
+ if (reported.length === 0) return { buildId: activeBuildId || null, serverReported: false };
489
+ const ids = reported.map((r) => r.rebuild_build_id).filter((id) => id != null);
490
+ return { buildId: ids.length > 0 ? ids[ids.length - 1] : null, serverReported: true };
491
+ }
492
+
481
493
  async function publish(slugArg, options = {}) {
482
494
  const cwd = process.cwd();
483
495
  const config = requireProjectConfig(cwd);
@@ -498,10 +510,11 @@ async function publish(slugArg, options = {}) {
498
510
  'Publishing sends (or re-sends) email to the active subscriber list for any email-configured post.',
499
511
  );
500
512
 
501
- // Publishing a post makes the publisher auto-rebuild the site's active build so
502
- // the /content archive picks it up — no separate `micropage publish` needed.
503
- // Resolve that build up front so we can report accurately and (with --watch)
504
- // capture an event cursor before the rebuild is queued.
513
+ // Publishing a post makes the publisher auto-rebuild the live site so the
514
+ // /content archive picks it up — no separate `micropage publish` needed.
515
+ // publish-post reports which build it rebuilds; active_build_id is only the
516
+ // fallback for servers that don't. The --watch cursor is project-wide and
517
+ // taken before the rebuild is queued, since the build isn't known yet.
505
518
  let activeBuildId = null;
506
519
  try {
507
520
  const proj = await db
@@ -515,9 +528,9 @@ async function publish(slugArg, options = {}) {
515
528
  }
516
529
 
517
530
  let eventCursor = 0;
518
- if (options.watch && activeBuildId) {
531
+ if (options.watch) {
519
532
  try {
520
- eventCursor = await getMaxDeployEventIdForBuild(activeBuildId);
533
+ eventCursor = await getMaxDeployEventIdForProject(config.projectId);
521
534
  } catch {
522
535
  eventCursor = 0;
523
536
  }
@@ -525,10 +538,12 @@ async function publish(slugArg, options = {}) {
525
538
 
526
539
  let hadError = false;
527
540
  let publishedCount = 0;
541
+ const responses = [];
528
542
 
529
543
  for (const slug of targetSlugs) {
530
544
  try {
531
545
  const result = await fn.invoke('publish-post', { project_id: config.projectId, slug });
546
+ responses.push(result);
532
547
  const bits = [`published_at ${result.published_at}`];
533
548
  bits.push(result.emailed ? `emailed ${result.recipient_count} recipient(s)` : 'no email');
534
549
  console.log(`"${slug}": ${bits.join(', ')}`);
@@ -545,17 +560,22 @@ async function publish(slugArg, options = {}) {
545
560
  console.log(`Published ${publishedCount}/${targetSlugs.length} post(s).`);
546
561
 
547
562
  if (publishedCount > 0) {
548
- if (activeBuildId) {
563
+ const { buildId: rebuildBuildId, serverReported } = rebuildTarget(responses, activeBuildId);
564
+ if (rebuildBuildId) {
549
565
  console.log(
550
566
  'A site rebuild was queued automatically; the /content archive updates once it deploys (usually a minute or two).',
551
567
  );
568
+ } else if (serverReported) {
569
+ console.log(
570
+ "No site rebuild was queued: the project has no deployed build yet (run 'micropage publish'), or the post is email-only.",
571
+ );
552
572
  } else {
553
573
  console.log(
554
574
  "Note: this project hasn't been published yet, so the post won't appear until you run 'micropage publish'.",
555
575
  );
556
576
  }
557
577
 
558
- if (options.watch && activeBuildId) {
578
+ if (options.watch && rebuildBuildId) {
559
579
  console.log('');
560
580
  console.log('Build / deploy events:');
561
581
  try {
@@ -563,7 +583,7 @@ async function publish(slugArg, options = {}) {
563
583
  const { terminalEvent } = await streamDeployEventsUntilDone(
564
584
  accessToken,
565
585
  config.projectId,
566
- activeBuildId,
586
+ rebuildBuildId,
567
587
  { afterId: eventCursor },
568
588
  );
569
589
  if (terminalEvent?.event_type === 'build.failed') {
@@ -619,6 +639,7 @@ module.exports = {
619
639
  rm,
620
640
  publish,
621
641
  unpublish,
642
+ rebuildTarget,
622
643
  slugify,
623
644
  defaultSlugFromFilename,
624
645
  frontMatterFromPost,
@@ -13,6 +13,11 @@ function formatCliFieldValue(value) {
13
13
  return String(value);
14
14
  }
15
15
 
16
+ /** Spam is `flagged_at IS NOT NULL`; the inbox is everything else. */
17
+ function applySpamFilter(query, spam) {
18
+ return spam ? query.not('flagged_at', 'is', 'null') : query.is('flagged_at', 'null');
19
+ }
20
+
16
21
  async function list(options = {}) {
17
22
  const cwd = process.cwd();
18
23
  const config = getProjectConfig(cwd);
@@ -21,12 +26,17 @@ async function list(options = {}) {
21
26
  process.exit(1);
22
27
  }
23
28
 
29
+ const spam = Boolean(options.spam);
30
+ const columns = spam
31
+ ? 'id,form_id,form_name,page_url,created_at,form_index,spam_reason,flagged_at,flagged_by'
32
+ : 'id,form_id,form_name,page_url,created_at,form_index';
33
+
24
34
  let submissions;
25
35
  try {
26
- submissions = await db
27
- .from('form_submissions')
28
- .select('id,form_id,form_name,page_url,created_at,form_index')
29
- .eq('project_id', config.projectId)
36
+ submissions = await applySpamFilter(
37
+ db.from('form_submissions').select(columns).eq('project_id', config.projectId),
38
+ spam,
39
+ )
30
40
  .order('created_at', 'desc')
31
41
  .get();
32
42
  } catch (err) {
@@ -36,7 +46,7 @@ async function list(options = {}) {
36
46
  }
37
47
 
38
48
  if (!Array.isArray(submissions) || submissions.length === 0) {
39
- console.log('No form submissions.');
49
+ console.log(spam ? 'No spam submissions.' : 'No form submissions.');
40
50
  return;
41
51
  }
42
52
 
@@ -45,14 +55,20 @@ async function list(options = {}) {
45
55
  return;
46
56
  }
47
57
 
48
- const rows = submissions.map((s) => [
49
- s.id.slice(0, 8) + '…',
50
- s.form_id ? s.form_id.slice(0, 8) + '…' : '-',
51
- s.form_name || '-',
52
- s.page_url || '-',
53
- formatDate(s.created_at),
54
- ]);
55
- formatTable(rows, ['ID (short)', 'Form ID', 'Form', 'Page URL', 'Submitted']);
58
+ const rows = submissions.map((s) => {
59
+ const row = [
60
+ s.id.slice(0, 8) + '…',
61
+ s.form_id ? s.form_id.slice(0, 8) + '…' : '-',
62
+ s.form_name || '-',
63
+ s.page_url || '-',
64
+ formatDate(s.created_at),
65
+ ];
66
+ if (spam) row.push(s.spam_reason || '-');
67
+ return row;
68
+ });
69
+ const headers = ['ID (short)', 'Form ID', 'Form', 'Page URL', 'Submitted'];
70
+ if (spam) headers.push('Spam reason');
71
+ formatTable(rows, headers);
56
72
  }
57
73
 
58
74
  async function show(id, options = {}) {
@@ -85,6 +101,9 @@ async function show(id, options = {}) {
85
101
  }
86
102
 
87
103
  console.log('ID: ', submission.id);
104
+ if (submission.flagged_at) {
105
+ console.log('Spam: ', submission.spam_reason || 'yes');
106
+ }
88
107
  console.log('Form ID: ', submission.form_id || '-');
89
108
  console.log('Form: ', submission.form_name || '-');
90
109
  console.log('Page URL: ', submission.page_url || '-');
@@ -112,12 +131,14 @@ async function exportSubmissions(options = {}) {
112
131
  process.exit(1);
113
132
  }
114
133
 
134
+ const spam = Boolean(options.spam);
135
+
115
136
  let submissions;
116
137
  try {
117
- submissions = await db
118
- .from('form_submissions')
119
- .select('*')
120
- .eq('project_id', config.projectId)
138
+ submissions = await applySpamFilter(
139
+ db.from('form_submissions').select('*').eq('project_id', config.projectId),
140
+ spam,
141
+ )
121
142
  .order('created_at', 'desc')
122
143
  .get();
123
144
  } catch (err) {
@@ -127,7 +148,7 @@ async function exportSubmissions(options = {}) {
127
148
  }
128
149
 
129
150
  if (!Array.isArray(submissions) || submissions.length === 0) {
130
- console.log('No form submissions to export.');
151
+ console.log(spam ? 'No spam submissions to export.' : 'No form submissions to export.');
131
152
  return;
132
153
  }
133
154
 
@@ -137,7 +158,7 @@ async function exportSubmissions(options = {}) {
137
158
  process.exit(1);
138
159
  }
139
160
 
140
- const defaultName = `submissions.${format}`;
161
+ const defaultName = spam ? `submissions-spam.${format}` : `submissions.${format}`;
141
162
  const outFile = options.output
142
163
  ? path.isAbsolute(options.output)
143
164
  ? options.output
@@ -158,6 +179,7 @@ async function exportSubmissions(options = {}) {
158
179
  'build_id',
159
180
  'payload_json',
160
181
  ];
182
+ if (spam) header.push('spam_reason');
161
183
  const escapeCell = (value) => {
162
184
  if (value === null || value === undefined) return '';
163
185
  const str = String(value);
@@ -168,8 +190,8 @@ async function exportSubmissions(options = {}) {
168
190
  };
169
191
  const lines = [
170
192
  header.join(','),
171
- ...submissions.map((s) =>
172
- [
193
+ ...submissions.map((s) => {
194
+ const cells = [
173
195
  s.id,
174
196
  s.created_at,
175
197
  s.page_url || '',
@@ -178,8 +200,10 @@ async function exportSubmissions(options = {}) {
178
200
  s.form_index ?? '',
179
201
  s.build_id || '',
180
202
  JSON.stringify(s.payload || {}),
181
- ].map(escapeCell).join(','),
182
- ),
203
+ ];
204
+ if (spam) cells.push(s.spam_reason || '');
205
+ return cells.map(escapeCell).join(',');
206
+ }),
183
207
  ];
184
208
  fs.writeFileSync(outFile, lines.join('\n'), 'utf8');
185
209
  }
@@ -188,7 +212,8 @@ async function exportSubmissions(options = {}) {
188
212
  process.exit(1);
189
213
  }
190
214
 
191
- console.log(`Exported ${submissions.length} submission(s) → ${outFile}`);
215
+ const noun = spam ? 'spam submission(s)' : 'submission(s)';
216
+ console.log(`Exported ${submissions.length} ${noun} → ${outFile}`);
192
217
  }
193
218
 
194
- module.exports = { list, show, exportSubmissions };
219
+ module.exports = { list, show, exportSubmissions, applySpamFilter };
package/src/index.js CHANGED
@@ -219,8 +219,9 @@ const subCmd = program.command('submissions').description('View form submissions
219
219
 
220
220
  subCmd
221
221
  .command('list')
222
- .description('List form submissions for the current project')
222
+ .description('List form submissions for the current project (spam excluded)')
223
223
  .option('--json', 'Output as JSON')
224
+ .option('--spam', 'List only submissions flagged as spam')
224
225
  .action((opts) => submissions.list(opts));
225
226
 
226
227
  subCmd
@@ -231,14 +232,15 @@ subCmd
231
232
 
232
233
  subCmd
233
234
  .command('export')
234
- .description('Export form submissions for the current project to a file')
235
+ .description('Export form submissions for the current project to a file (spam excluded)')
236
+ .option('--spam', 'Export only submissions flagged as spam')
235
237
  .option(
236
238
  '-f, --format <format>',
237
239
  'Output format: csv or json (default: csv)',
238
240
  )
239
241
  .option(
240
242
  '-o, --output <file>',
241
- 'Output file path (default: submissions.<format>)',
243
+ 'Output file path (default: submissions.<format>, or submissions-spam.<format> with --spam)',
242
244
  )
243
245
  .action((opts) => submissions.exportSubmissions(opts));
244
246
 
package/src/supabase.js CHANGED
@@ -207,6 +207,8 @@ function buildQuery(table) {
207
207
  select(cols) { _select = cols; return q; },
208
208
  eq(col, val) { filters.push(`${col}=eq.${val}`); return q; },
209
209
  in(col, vals) { filters.push(`${col}=in.(${vals.join(',')})`); return q; },
210
+ is(col, val) { filters.push(`${col}=is.${val}`); return q; },
211
+ not(col, op, val) { filters.push(`${col}=not.${op}.${val}`); return q; },
210
212
  order(col, dir = 'asc') { _order = `${col}.${dir}`; return q; },
211
213
  limit(n) { _limit = n; return q; },
212
214
 
@@ -363,6 +365,20 @@ async function getMaxDeployEventIdForBuild(buildId, accessToken = null) {
363
365
  return Number.isFinite(n) ? n : 0;
364
366
  }
365
367
 
368
+ /**
369
+ * Highest build_deploy_events.id across the project, 0 when none. A cursor
370
+ * taken before a post change, when the build the server will rebuild is not
371
+ * known yet: event ids are global and the stream filters by build.
372
+ */
373
+ async function getMaxDeployEventIdForProject(projectId) {
374
+ const url = `${SUPABASE_URL}/rest/v1/build_deploy_events?select=id&project_id=eq.${encodeURIComponent(String(projectId))}&order=id.desc&limit=1`;
375
+ const data = await request('GET', url, null);
376
+ const row = Array.isArray(data) ? data[0] : null;
377
+ if (!row || row.id == null) return 0;
378
+ const n = Number(row.id);
379
+ return Number.isFinite(n) ? n : 0;
380
+ }
381
+
366
382
  /**
367
383
  * Stream build/deploy/archive events until a terminal event or the connection closes.
368
384
  * @param {string} bearerToken
@@ -705,6 +721,7 @@ module.exports = {
705
721
  handleAuthError,
706
722
  getValidAccessToken,
707
723
  getMaxDeployEventIdForBuild,
724
+ getMaxDeployEventIdForProject,
708
725
  streamDeployEventsUntilDone,
709
726
  exchangeDeployTokenForAccessToken,
710
727
  pushWithToken,