@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 +10 -4
- package/package.json +2 -2
- package/src/commands/forms.js +2 -1
- package/src/commands/posts.js +31 -10
- package/src/commands/submissions.js +50 -25
- package/src/index.js +5 -3
- package/src/supabase.js +17 -0
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
|
|
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.
|
|
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",
|
package/src/commands/forms.js
CHANGED
|
@@ -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);
|
package/src/commands/posts.js
CHANGED
|
@@ -9,7 +9,7 @@ const {
|
|
|
9
9
|
fn,
|
|
10
10
|
handleAuthError,
|
|
11
11
|
getValidAccessToken,
|
|
12
|
-
|
|
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
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
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
|
|
531
|
+
if (options.watch) {
|
|
519
532
|
try {
|
|
520
|
-
eventCursor = await
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
27
|
-
.from('form_submissions')
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
118
|
-
.from('form_submissions')
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
]
|
|
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
|
-
|
|
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,
|