@micropage-sh/cli 2.5.0 → 2.6.2
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 +12 -4
- package/package.json +2 -2
- package/src/commands/forms.js +2 -1
- package/src/commands/posts.js +31 -10
- package/src/commands/projects.js +1 -0
- package/src/commands/submissions.js +50 -25
- package/src/index.js +5 -3
- package/src/plan.js +26 -2
- package/src/supabase.js +39 -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
|
|
@@ -32,6 +32,8 @@ micropage whoami # show the currently logged-in user and subscription
|
|
|
32
32
|
micropage logout # clear the stored session
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
The CLI requires a paid plan (Pro or Pro+). Some features need a specific tier, such as deploy tokens (Pro+). The server enforces these limits. When a command needs a higher plan, the CLI prints the server's reason and an upgrade link.
|
|
36
|
+
|
|
35
37
|
## Project workflow
|
|
36
38
|
|
|
37
39
|
```bash
|
|
@@ -175,9 +177,9 @@ When you create a new project, the CLI also scaffolds:
|
|
|
175
177
|
|
|
176
178
|
| Command | Description |
|
|
177
179
|
|---|---|
|
|
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
|
|
180
|
+
| `micropage submissions list [--json] [--spam]` | List form submissions for the project. Spam is excluded; `--spam` lists only submissions flagged as spam, with the reason |
|
|
181
|
+
| `micropage submissions show <id> [--json]` | Show a single submission in detail, including a `Spam:` line with the reason when it was flagged |
|
|
182
|
+
| `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
183
|
|
|
182
184
|
**Examples:**
|
|
183
185
|
|
|
@@ -199,6 +201,12 @@ When you create a new project, the CLI also scaffolds:
|
|
|
199
201
|
micropage submissions export --format json --output submissions.json
|
|
200
202
|
```
|
|
201
203
|
|
|
204
|
+
- Export only submissions flagged as spam:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
micropage submissions export --spam
|
|
208
|
+
```
|
|
209
|
+
|
|
202
210
|
## Local content model
|
|
203
211
|
|
|
204
212
|
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.2",
|
|
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,
|
package/src/commands/projects.js
CHANGED
|
@@ -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/plan.js
CHANGED
|
@@ -2,13 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
const { SUPABASE_URL, SUPABASE_ANON_KEY } = require('./config');
|
|
4
4
|
|
|
5
|
+
const PRICING_URL = 'https://micropage.sh/pricing';
|
|
6
|
+
|
|
5
7
|
const UPGRADE_MESSAGE = [
|
|
6
8
|
'The micropage CLI is available on paid plans only.',
|
|
7
9
|
'Your account is currently on the free plan.',
|
|
8
10
|
'',
|
|
9
|
-
|
|
11
|
+
`Upgrade at: ${PRICING_URL}`,
|
|
10
12
|
].join('\n');
|
|
11
13
|
|
|
14
|
+
const TIER_LABELS = { pro: 'Pro', pro_plus: 'Pro+' };
|
|
15
|
+
|
|
16
|
+
// Message for a server refusal tagged PLAN_REQUIRED (see attachPlanRequired in
|
|
17
|
+
// supabase.js). Leads with the server's own explanation, then the upgrade hint.
|
|
18
|
+
function formatPlanRequiredMessage(err) {
|
|
19
|
+
const label = TIER_LABELS[err?.requiredTier];
|
|
20
|
+
const lines = [];
|
|
21
|
+
// Server messages written for the editor may end in a relative "Upgrade at /pricing.";
|
|
22
|
+
// drop it so the absolute link below is the only one.
|
|
23
|
+
const reason = (err?.message || '').replace(/\s*Upgrade at \S+$/i, '').trim();
|
|
24
|
+
if (reason) lines.push(reason, '');
|
|
25
|
+
lines.push(`This requires ${label ? `the ${label} plan` : 'a paid plan'}.`);
|
|
26
|
+
lines.push('');
|
|
27
|
+
lines.push(`Upgrade at: ${err?.upgradeUrl || PRICING_URL}`);
|
|
28
|
+
return lines.join('\n');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The login gate below (and in whoami) is a product gate, not a security
|
|
32
|
+
// boundary: anyone can call the Supabase API directly with an editor session.
|
|
33
|
+
// The server enforces the per-feature gates (project limits via RLS, storage
|
|
34
|
+
// quota, custom domains, archives, posts, deploy-token creation and exchange)
|
|
35
|
+
// and answers with `code: "plan_required"` when a tier is missing.
|
|
12
36
|
function isPaidTier(tier) {
|
|
13
37
|
return tier === 'pro' || tier === 'pro_plus';
|
|
14
38
|
}
|
|
@@ -37,4 +61,4 @@ async function getPlanTierWithToken(userId, accessToken) {
|
|
|
37
61
|
}
|
|
38
62
|
}
|
|
39
63
|
|
|
40
|
-
module.exports = { UPGRADE_MESSAGE, isPaidTier, getPlanTierWithToken };
|
|
64
|
+
module.exports = { UPGRADE_MESSAGE, isPaidTier, getPlanTierWithToken, formatPlanRequiredMessage };
|
package/src/supabase.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
const { getSession, setSession, clearSession } = require('./auth');
|
|
15
15
|
const { SUPABASE_URL, SUPABASE_ANON_KEY } = require('./config');
|
|
16
|
+
const { formatPlanRequiredMessage } = require('./plan');
|
|
16
17
|
|
|
17
18
|
// ---------------------------------------------------------------------------
|
|
18
19
|
// JWT helpers
|
|
@@ -97,6 +98,18 @@ async function getValidAccessToken() {
|
|
|
97
98
|
// Base request
|
|
98
99
|
// ---------------------------------------------------------------------------
|
|
99
100
|
|
|
101
|
+
// Server tier refusals carry `code: "plan_required"` in the JSON body, reusing
|
|
102
|
+
// 402/403. Tag the error so callers can tell "upgrade needed" apart from
|
|
103
|
+
// "not yours" / "invalid token", which share those statuses.
|
|
104
|
+
function attachPlanRequired(err, data) {
|
|
105
|
+
if (data && typeof data === 'object' && data.code === 'plan_required') {
|
|
106
|
+
err.code = 'PLAN_REQUIRED';
|
|
107
|
+
err.requiredTier = data.required_tier || null;
|
|
108
|
+
err.upgradeUrl = data.upgrade_url || null;
|
|
109
|
+
}
|
|
110
|
+
return err;
|
|
111
|
+
}
|
|
112
|
+
|
|
100
113
|
async function requestWithToken(method, url, body, accessToken, extraHeaders = {}) {
|
|
101
114
|
const headers = {
|
|
102
115
|
'apikey': SUPABASE_ANON_KEY,
|
|
@@ -123,6 +136,7 @@ async function requestWithToken(method, url, body, accessToken, extraHeaders = {
|
|
|
123
136
|
const err = new Error(msg);
|
|
124
137
|
err.status = res.status;
|
|
125
138
|
err.data = data;
|
|
139
|
+
attachPlanRequired(err, data);
|
|
126
140
|
throw err;
|
|
127
141
|
}
|
|
128
142
|
|
|
@@ -172,6 +186,7 @@ async function request(method, url, body, extraHeaders = {}) {
|
|
|
172
186
|
err.status = res.status;
|
|
173
187
|
err.data = data;
|
|
174
188
|
if (res.status === 401) err.code = 'SESSION_EXPIRED';
|
|
189
|
+
attachPlanRequired(err, data);
|
|
175
190
|
throw err;
|
|
176
191
|
}
|
|
177
192
|
|
|
@@ -207,6 +222,8 @@ function buildQuery(table) {
|
|
|
207
222
|
select(cols) { _select = cols; return q; },
|
|
208
223
|
eq(col, val) { filters.push(`${col}=eq.${val}`); return q; },
|
|
209
224
|
in(col, vals) { filters.push(`${col}=in.(${vals.join(',')})`); return q; },
|
|
225
|
+
is(col, val) { filters.push(`${col}=is.${val}`); return q; },
|
|
226
|
+
not(col, op, val) { filters.push(`${col}=not.${op}.${val}`); return q; },
|
|
210
227
|
order(col, dir = 'asc') { _order = `${col}.${dir}`; return q; },
|
|
211
228
|
limit(n) { _limit = n; return q; },
|
|
212
229
|
|
|
@@ -286,6 +303,10 @@ function handleAuthError(err) {
|
|
|
286
303
|
console.error(err.message);
|
|
287
304
|
process.exit(1);
|
|
288
305
|
}
|
|
306
|
+
if (err.code === 'PLAN_REQUIRED') {
|
|
307
|
+
console.error(formatPlanRequiredMessage(err));
|
|
308
|
+
process.exit(1);
|
|
309
|
+
}
|
|
289
310
|
}
|
|
290
311
|
|
|
291
312
|
// ---------------------------------------------------------------------------
|
|
@@ -363,6 +384,20 @@ async function getMaxDeployEventIdForBuild(buildId, accessToken = null) {
|
|
|
363
384
|
return Number.isFinite(n) ? n : 0;
|
|
364
385
|
}
|
|
365
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Highest build_deploy_events.id across the project, 0 when none. A cursor
|
|
389
|
+
* taken before a post change, when the build the server will rebuild is not
|
|
390
|
+
* known yet: event ids are global and the stream filters by build.
|
|
391
|
+
*/
|
|
392
|
+
async function getMaxDeployEventIdForProject(projectId) {
|
|
393
|
+
const url = `${SUPABASE_URL}/rest/v1/build_deploy_events?select=id&project_id=eq.${encodeURIComponent(String(projectId))}&order=id.desc&limit=1`;
|
|
394
|
+
const data = await request('GET', url, null);
|
|
395
|
+
const row = Array.isArray(data) ? data[0] : null;
|
|
396
|
+
if (!row || row.id == null) return 0;
|
|
397
|
+
const n = Number(row.id);
|
|
398
|
+
return Number.isFinite(n) ? n : 0;
|
|
399
|
+
}
|
|
400
|
+
|
|
366
401
|
/**
|
|
367
402
|
* Stream build/deploy/archive events until a terminal event or the connection closes.
|
|
368
403
|
* @param {string} bearerToken
|
|
@@ -654,6 +689,7 @@ async function exchangeDeployTokenForAccessToken(deployTokenPlaintext, projectUu
|
|
|
654
689
|
);
|
|
655
690
|
err.status = res.status;
|
|
656
691
|
err.data = data;
|
|
692
|
+
attachPlanRequired(err, data);
|
|
657
693
|
throw err;
|
|
658
694
|
}
|
|
659
695
|
if (!data?.access_token) {
|
|
@@ -693,6 +729,7 @@ async function invokePublishBuild(accessToken, projectId, buildId) {
|
|
|
693
729
|
);
|
|
694
730
|
err.status = res.status;
|
|
695
731
|
err.data = data;
|
|
732
|
+
attachPlanRequired(err, data);
|
|
696
733
|
throw err;
|
|
697
734
|
}
|
|
698
735
|
return data;
|
|
@@ -703,8 +740,10 @@ module.exports = {
|
|
|
703
740
|
fn,
|
|
704
741
|
getUserInfo,
|
|
705
742
|
handleAuthError,
|
|
743
|
+
attachPlanRequired,
|
|
706
744
|
getValidAccessToken,
|
|
707
745
|
getMaxDeployEventIdForBuild,
|
|
746
|
+
getMaxDeployEventIdForProject,
|
|
708
747
|
streamDeployEventsUntilDone,
|
|
709
748
|
exchangeDeployTokenForAccessToken,
|
|
710
749
|
pushWithToken,
|