@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,225 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { fn, handleAuthError } = require('../supabase');
6
+ const { getProjectConfig } = require('../auth');
7
+ const { formatTable, formatDate, formatBytes } = require('../utils');
8
+
9
+ function safeAssetFilename(filename) {
10
+ if (filename == null || typeof filename !== 'string') return null;
11
+ const base = path.basename(String(filename).replace(/\\/g, '/'));
12
+ if (!base || base === '.' || base === '..') return null;
13
+ return base;
14
+ }
15
+
16
+ async function downloadFileBytes(fileId) {
17
+ const urlData = await fn.invokeGet('get-file-url', { file_id: fileId });
18
+ const fileUrl = urlData?.url;
19
+ if (!fileUrl) throw new Error('No URL in response');
20
+ const res = await fetch(fileUrl);
21
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
22
+ return Buffer.from(await res.arrayBuffer());
23
+ }
24
+
25
+ /**
26
+ * Sync remote project files to ./assets (1:1 with list-files: every row is written;
27
+ * optional prune removes local files not present remotely).
28
+ *
29
+ * @returns {{ downloaded: number, removed: number, skipped: number, errors: { name: string, message: string }[], totalRemote: number }}
30
+ */
31
+ async function syncAssets(cwd, options = {}) {
32
+ const projectId = options.projectId ?? getProjectConfig(cwd)?.projectId;
33
+ if (projectId == null) {
34
+ const err = new Error(
35
+ 'Not in a project folder. Run from a folder with .micropage/project.json',
36
+ );
37
+ err.code = 'NO_PROJECT';
38
+ throw err;
39
+ }
40
+
41
+ const data = await fn.invokeGet('list-files', { project_id: projectId });
42
+ const files = data?.files || [];
43
+ const assetsDir = path.join(cwd, 'assets');
44
+ fs.mkdirSync(assetsDir, { recursive: true });
45
+
46
+ const remoteBasenames = new Set();
47
+ let downloaded = 0;
48
+ let skipped = 0;
49
+ const errors = [];
50
+ const seenNames = new Set();
51
+
52
+ for (const f of files) {
53
+ const name = safeAssetFilename(f.filename);
54
+ if (!name || f.id == null) {
55
+ skipped += 1;
56
+ continue;
57
+ }
58
+ if (seenNames.has(name) && !options.quiet) {
59
+ console.warn(`Warning: duplicate filename in storage "${name}" — last row wins locally.`);
60
+ }
61
+ seenNames.add(name);
62
+ remoteBasenames.add(name);
63
+ try {
64
+ const buf = await downloadFileBytes(f.id);
65
+ fs.writeFileSync(path.join(assetsDir, name), buf);
66
+ downloaded += 1;
67
+ if (!options.quiet) console.log(` ${name}`);
68
+ } catch (e) {
69
+ errors.push({ name, message: e.message || String(e) });
70
+ }
71
+ }
72
+
73
+ let removed = 0;
74
+ if (options.prune !== false && fs.existsSync(assetsDir)) {
75
+ for (const e of fs.readdirSync(assetsDir, { withFileTypes: true })) {
76
+ if (!e.isFile()) continue;
77
+ if (!remoteBasenames.has(e.name)) {
78
+ fs.unlinkSync(path.join(assetsDir, e.name));
79
+ removed += 1;
80
+ }
81
+ }
82
+ }
83
+
84
+ return {
85
+ downloaded,
86
+ removed,
87
+ skipped,
88
+ errors,
89
+ totalRemote: files.length,
90
+ };
91
+ }
92
+
93
+ async function syncCli(options = {}) {
94
+ const cwd = process.cwd();
95
+ try {
96
+ const r = await syncAssets(cwd, { quiet: options.quiet, prune: true });
97
+ if (r.errors.length > 0) {
98
+ for (const e of r.errors) {
99
+ console.error(`Failed to download ${e.name}: ${e.message}`);
100
+ }
101
+ process.exit(1);
102
+ }
103
+ if (!options.quiet) {
104
+ if (r.skipped > 0) {
105
+ console.warn(`Skipped ${r.skipped} remote row(s) with missing id or filename.`);
106
+ }
107
+ const parts = [`${r.downloaded} file(s) match storage`];
108
+ if (r.removed > 0) parts.push(`${r.removed} extra local file(s) removed`);
109
+ console.log(parts.join('; ') + '.');
110
+ }
111
+ } catch (err) {
112
+ handleAuthError(err);
113
+ if (err.code === 'NO_PROJECT') console.error(err.message);
114
+ else console.error('Sync failed:', err.message);
115
+ process.exit(1);
116
+ }
117
+ }
118
+
119
+ async function list(options = {}) {
120
+ const cwd = process.cwd();
121
+ const config = getProjectConfig(cwd);
122
+ if (!config?.projectId) {
123
+ console.error('Not in a project folder. Run from a folder with .micropage/project.json');
124
+ process.exit(1);
125
+ }
126
+
127
+ let data;
128
+ try {
129
+ data = await fn.invokeGet('list-files', { project_id: config.projectId });
130
+ } catch (err) {
131
+ handleAuthError(err);
132
+ console.error('Failed to list files:', err.message);
133
+ process.exit(1);
134
+ }
135
+
136
+ const files = data?.files || [];
137
+ if (files.length === 0) {
138
+ console.log('No files uploaded for this project.');
139
+ return;
140
+ }
141
+
142
+ if (options.json) {
143
+ console.log(JSON.stringify(files, null, 2));
144
+ return;
145
+ }
146
+
147
+ console.log(
148
+ `Storage: ${formatBytes(data.total_bytes || 0)} / ${data.space_available_mb || 100} MB used\n`
149
+ );
150
+
151
+ const rows = files.map((f) => [
152
+ f.filename || '-',
153
+ f.mime_type || '-',
154
+ formatBytes(f.size_bytes),
155
+ formatDate(f.created_at),
156
+ ]);
157
+ formatTable(rows, ['Filename', 'Type', 'Size', 'Uploaded']);
158
+ }
159
+
160
+ async function url(filename, options = {}) {
161
+ const cwd = process.cwd();
162
+ const config = getProjectConfig(cwd);
163
+ if (!config?.projectId) {
164
+ console.error('Not in a project folder. Run from a folder with .micropage/project.json');
165
+ process.exit(1);
166
+ }
167
+
168
+ // Find the file ID by listing files and matching filename
169
+ let data;
170
+ try {
171
+ data = await fn.invokeGet('list-files', { project_id: config.projectId });
172
+ } catch (err) {
173
+ handleAuthError(err);
174
+ console.error('Failed to list files:', err.message);
175
+ process.exit(1);
176
+ }
177
+
178
+ const files = data?.files || [];
179
+ const file = files.find((f) => f.filename === filename);
180
+ if (!file) {
181
+ console.error(`File not found: ${filename}`);
182
+ process.exit(1);
183
+ }
184
+
185
+ let urlData;
186
+ try {
187
+ urlData = await fn.invokeGet('get-file-url', { file_id: file.id });
188
+ } catch (err) {
189
+ handleAuthError(err);
190
+ console.error('Failed to get file URL:', err.message);
191
+ process.exit(1);
192
+ }
193
+
194
+ const fileUrl = urlData?.url;
195
+ if (!fileUrl) {
196
+ console.error('Could not retrieve URL for this file.');
197
+ process.exit(1);
198
+ }
199
+
200
+ console.log(fileUrl);
201
+
202
+ if (options.copy) {
203
+ try {
204
+ const { execSync } = require('child_process');
205
+ // Try xclip, xsel, wl-copy, pbcopy in order
206
+ const cmds = ['xclip -selection clipboard', 'xsel --clipboard --input', 'wl-copy', 'pbcopy'];
207
+ for (const cmd of cmds) {
208
+ try {
209
+ const [prog] = cmd.split(' ');
210
+ execSync(`which ${prog}`, { stdio: 'ignore' });
211
+ execSync(cmd, { input: fileUrl });
212
+ console.log('URL copied to clipboard.');
213
+ return;
214
+ } catch {
215
+ // try next
216
+ }
217
+ }
218
+ console.log('(Could not copy to clipboard – copy manually above)');
219
+ } catch {
220
+ // ignore
221
+ }
222
+ }
223
+ }
224
+
225
+ module.exports = { list, url, syncAssets, sync: syncCli };
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ const { db, handleAuthError } = require('../supabase');
4
+ const { getProjectConfig } = require('../auth');
5
+ const { formatTable, formatDate } = require('../utils');
6
+
7
+ async function list(options = {}) {
8
+ const cwd = process.cwd();
9
+ const config = getProjectConfig(cwd);
10
+ if (!config?.projectId) {
11
+ console.error('Not in a project folder. Run from a folder with .micropage/project.json');
12
+ process.exit(1);
13
+ }
14
+
15
+ let forms;
16
+ try {
17
+ forms = await db
18
+ .from('forms')
19
+ .select('id,form_name,page_url,is_footer,created_at')
20
+ .eq('project_id', config.projectId)
21
+ .order('created_at', 'asc')
22
+ .get();
23
+ } catch (err) {
24
+ handleAuthError(err);
25
+ console.error('Failed to list forms:', err.message);
26
+ process.exit(1);
27
+ }
28
+
29
+ if (!Array.isArray(forms) || forms.length === 0) {
30
+ console.log('No forms found for this project.');
31
+ return;
32
+ }
33
+
34
+ // Count submissions per form_id
35
+ let submissions;
36
+ try {
37
+ submissions = await db
38
+ .from('form_submissions')
39
+ .select('form_id')
40
+ .eq('project_id', config.projectId)
41
+ .get();
42
+ } catch (err) {
43
+ handleAuthError(err);
44
+ console.error('Failed to fetch submission counts:', err.message);
45
+ process.exit(1);
46
+ }
47
+
48
+ const counts = {};
49
+ if (Array.isArray(submissions)) {
50
+ for (const s of submissions) {
51
+ if (s.form_id) counts[s.form_id] = (counts[s.form_id] || 0) + 1;
52
+ }
53
+ }
54
+
55
+ if (options.json) {
56
+ const out = forms.map((f) => ({ ...f, submission_count: counts[f.id] || 0 }));
57
+ console.log(JSON.stringify(out, null, 2));
58
+ return;
59
+ }
60
+
61
+ const rows = forms.map((f) => [
62
+ f.form_name,
63
+ f.page_url,
64
+ f.is_footer ? 'footer' : 'inline',
65
+ String(counts[f.id] || 0),
66
+ formatDate(f.created_at),
67
+ ]);
68
+ formatTable(rows, ['Form', 'Page URL', 'Type', 'Submissions', 'Created']);
69
+ }
70
+
71
+ module.exports = { list };
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+
3
+ const { execSync } = require('child_process');
4
+ const { db, handleAuthError } = require('../supabase');
5
+ const { getProjectConfig } = require('../auth');
6
+ const { APP_URL } = require('../config');
7
+
8
+ function projectUrl(domain) {
9
+ if (!domain) return null;
10
+ if (domain.startsWith('http')) return domain;
11
+ return `https://${domain}.pages.dev`;
12
+ }
13
+
14
+ function copyToClipboard(text) {
15
+ const cmds = [
16
+ 'xclip -selection clipboard',
17
+ 'xsel --clipboard --input',
18
+ 'wl-copy',
19
+ 'pbcopy',
20
+ ];
21
+ for (const cmd of cmds) {
22
+ const [prog] = cmd.split(' ');
23
+ try {
24
+ execSync(`which ${prog}`, { stdio: 'ignore' });
25
+ execSync(cmd, { input: text });
26
+ return true;
27
+ } catch {
28
+ // try next
29
+ }
30
+ }
31
+ return false;
32
+ }
33
+
34
+ async function getProject(cwd) {
35
+ const config = getProjectConfig(cwd);
36
+ if (!config?.projectId) {
37
+ console.error('Not in a project folder. Run from a folder with .micropage/project.json');
38
+ process.exit(1);
39
+ }
40
+
41
+ try {
42
+ const project = await db
43
+ .from('projects')
44
+ .select('id,name,domain')
45
+ .eq('id', config.projectId)
46
+ .single();
47
+ return project || config;
48
+ } catch (err) {
49
+ handleAuthError(err);
50
+ // Fall back to cached config
51
+ return config;
52
+ }
53
+ }
54
+
55
+ // micropage preview – open the live site in the browser
56
+ async function preview() {
57
+ const cwd = process.cwd();
58
+ const project = await getProject(cwd);
59
+ const url = projectUrl(project.domain);
60
+
61
+ if (!url) {
62
+ console.error('No domain set for this project. Publish first.');
63
+ process.exit(1);
64
+ }
65
+
66
+ console.log(url);
67
+ try {
68
+ const { default: open } = await import('open');
69
+ await open(url);
70
+ } catch {
71
+ console.log('(Could not open browser automatically)');
72
+ }
73
+ }
74
+
75
+ // micropage copy-link – copy project URL to clipboard
76
+ async function copyLink() {
77
+ const cwd = process.cwd();
78
+ const project = await getProject(cwd);
79
+ const url = projectUrl(project.domain);
80
+
81
+ if (!url) {
82
+ console.error('No domain set for this project. Publish first.');
83
+ process.exit(1);
84
+ }
85
+
86
+ console.log(url);
87
+ const copied = copyToClipboard(url);
88
+ if (copied) {
89
+ console.log('URL copied to clipboard.');
90
+ } else {
91
+ console.log('(Could not copy to clipboard – copy manually above)');
92
+ }
93
+ }
94
+
95
+ // micropage open-pricing – open the pricing page in the browser
96
+ async function openPricing() {
97
+ const url = `${APP_URL}/pricing`;
98
+ console.log(url);
99
+ try {
100
+ const { default: open } = await import('open');
101
+ await open(url);
102
+ } catch {
103
+ console.log('(Could not open browser automatically)');
104
+ }
105
+ }
106
+
107
+ module.exports = { preview, copyLink, openPricing };
@@ -0,0 +1,255 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const { randomBytes } = require('crypto');
5
+ const { setSession, getSession } = require('../auth');
6
+ const { getUserInfo, getValidAccessToken } = require('../supabase');
7
+ const { APP_URL, SUPABASE_URL, SUPABASE_ANON_KEY } = require('../config');
8
+
9
+ // Find an available ephemeral port by briefly binding to port 0.
10
+ function getFreePort() {
11
+ return new Promise((resolve, reject) => {
12
+ const server = http.createServer();
13
+ server.listen(0, '127.0.0.1', () => {
14
+ const { port } = server.address();
15
+ server.close(() => resolve(port));
16
+ });
17
+ server.on('error', reject);
18
+ });
19
+ }
20
+
21
+ const PAGE_SHELL = (title, body) => `<!DOCTYPE html>
22
+ <html lang="en">
23
+ <head>
24
+ <meta charset="UTF-8">
25
+ <meta name="viewport" content="width=device-width, initial-scale=1">
26
+ <title>${title}</title>
27
+ <link rel="preconnect" href="https://fonts.googleapis.com">
28
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
29
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
30
+ <style>
31
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
32
+
33
+ :root {
34
+ --login-bg: linear-gradient(160deg, #f0f2f5 0%, #e4e8ec 100%);
35
+ --login-card-bg: #ffffff;
36
+ --login-card-border: rgba(0, 0, 0, 0.06);
37
+ --login-card-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
38
+ --login-heading-color: #1a1a1a;
39
+ --login-text-muted: #6b7280;
40
+ }
41
+
42
+ @media (prefers-color-scheme: dark) {
43
+ :root {
44
+ --login-bg: linear-gradient(160deg, #1f2937 0%, #111827 100%);
45
+ --login-card-bg: #374151;
46
+ --login-card-border: rgba(255, 255, 255, 0.08);
47
+ --login-card-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
48
+ --login-heading-color: #f9fafb;
49
+ --login-text-muted: #9ca3af;
50
+ }
51
+ }
52
+
53
+ html, body {
54
+ height: 100%;
55
+ font-family: "Inter", system-ui, sans-serif;
56
+ background: var(--login-bg);
57
+ color: var(--login-heading-color);
58
+ }
59
+
60
+ .login-container {
61
+ min-height: 100%;
62
+ display: flex;
63
+ justify-content: center;
64
+ align-items: flex-start;
65
+ padding: 3rem 1rem;
66
+ }
67
+
68
+ .login-box {
69
+ background: var(--login-card-bg);
70
+ padding: 2.5rem 3rem;
71
+ border-radius: 12px;
72
+ box-shadow: var(--login-card-shadow);
73
+ border: 1px solid var(--login-card-border);
74
+ text-align: center;
75
+ min-width: 280px;
76
+ max-width: 400px;
77
+ width: 100%;
78
+ }
79
+
80
+ .app-brand {
81
+ font-size: 1.8rem;
82
+ margin-bottom: 1.5rem;
83
+ color: var(--login-heading-color);
84
+ font-weight: 300;
85
+ }
86
+ .app-brand strong { font-weight: 600; }
87
+
88
+ .login-subtitle {
89
+ color: var(--login-text-muted);
90
+ margin-bottom: 1.5rem;
91
+ font-size: 1rem;
92
+ line-height: 1.5;
93
+ }
94
+
95
+ .status-icon {
96
+ font-size: 2.5rem;
97
+ margin-bottom: 1rem;
98
+ line-height: 1;
99
+ }
100
+
101
+ .alert {
102
+ border-radius: 8px;
103
+ padding: 0.9rem 1.1rem;
104
+ font-size: 0.9rem;
105
+ margin-bottom: 1rem;
106
+ }
107
+ .alert-success {
108
+ background: #d1fae5;
109
+ color: #065f46;
110
+ border: 1px solid #6ee7b7;
111
+ }
112
+ .alert-danger {
113
+ background: #fee2e2;
114
+ color: #991b1b;
115
+ border: 1px solid #fca5a5;
116
+ }
117
+ @media (prefers-color-scheme: dark) {
118
+ .alert-success { background: #064e3b; color: #6ee7b7; border-color: #065f46; }
119
+ .alert-danger { background: #7f1d1d; color: #fca5a5; border-color: #991b1b; }
120
+ }
121
+
122
+ .hint {
123
+ color: var(--login-text-muted);
124
+ font-size: 0.85rem;
125
+ margin-top: 1rem;
126
+ }
127
+ .hint code {
128
+ background: rgba(0,0,0,0.06);
129
+ border-radius: 4px;
130
+ padding: 0.1em 0.35em;
131
+ font-size: 0.9em;
132
+ }
133
+ @media (prefers-color-scheme: dark) {
134
+ .hint code { background: rgba(255,255,255,0.1); }
135
+ }
136
+ </style>
137
+ </head>
138
+ <body>
139
+ <div class="login-container">
140
+ <div class="login-box">
141
+ <div class="app-brand"><strong>micro</strong>page</div>
142
+ ${body}
143
+ </div>
144
+ </div>
145
+ </body>
146
+ </html>`;
147
+
148
+ const SUCCESS_HTML = PAGE_SHELL(
149
+ 'micropage CLI – authenticated',
150
+ `<div class="status-icon">✓</div>
151
+ <div class="alert alert-success">You're logged in!</div>
152
+ <p class="hint">Return to your terminal. You can close this tab.</p>`
153
+ );
154
+
155
+ const ERROR_HTML = (msg) => PAGE_SHELL(
156
+ 'micropage CLI – error',
157
+ `<div class="status-icon">✕</div>
158
+ <div class="alert alert-danger">${msg}</div>
159
+ <p class="hint">Close this tab and run <code>micropage login</code> again.</p>`
160
+ );
161
+
162
+ async function run(options = {}) {
163
+ if (!options.force) {
164
+ const existing = getSession();
165
+ if (existing) {
166
+ try {
167
+ await getValidAccessToken();
168
+ const email = existing.user?.email || 'unknown';
169
+ console.log(`Already logged in as ${email}.`);
170
+ console.log('Use --force to re-authenticate.');
171
+ return;
172
+ } catch {
173
+ // Token expired or refresh failed — fall through to re-login
174
+ }
175
+ }
176
+ }
177
+
178
+ const state = randomBytes(16).toString('hex');
179
+ const port = await getFreePort();
180
+ const loginUrl = `${APP_URL}/cli-auth?state=${state}&port=${port}`;
181
+
182
+ let resolveLogin, rejectLogin;
183
+ const loginPromise = new Promise((res, rej) => {
184
+ resolveLogin = res;
185
+ rejectLogin = rej;
186
+ });
187
+
188
+ const server = http.createServer((req, res) => {
189
+ const url = new URL(req.url, `http://127.0.0.1:${port}`);
190
+
191
+ if (url.pathname !== '/callback') {
192
+ res.writeHead(404);
193
+ res.end();
194
+ return;
195
+ }
196
+
197
+ const receivedState = url.searchParams.get('state');
198
+ const accessToken = url.searchParams.get('access_token');
199
+ const refreshToken = url.searchParams.get('refresh_token');
200
+
201
+ if (!receivedState || receivedState !== state) {
202
+ res.writeHead(400, { 'Content-Type': 'text/html' });
203
+ res.end(ERROR_HTML('Invalid state parameter.'));
204
+ rejectLogin(new Error('State mismatch – possible security issue'));
205
+ return;
206
+ }
207
+
208
+ if (!accessToken || !refreshToken) {
209
+ res.writeHead(400, { 'Content-Type': 'text/html' });
210
+ res.end(ERROR_HTML('Missing authentication tokens.'));
211
+ rejectLogin(new Error('Missing tokens in callback'));
212
+ return;
213
+ }
214
+
215
+ res.writeHead(200, { 'Content-Type': 'text/html' });
216
+ res.end(SUCCESS_HTML);
217
+ resolveLogin({ access_token: accessToken, refresh_token: refreshToken });
218
+ });
219
+
220
+ server.listen(port, '127.0.0.1', async () => {
221
+ console.log('Opening browser for authentication…');
222
+ console.log(`If the browser does not open, visit:\n ${loginUrl}\n`);
223
+
224
+ try {
225
+ const { default: open } = await import('open');
226
+ await open(loginUrl);
227
+ } catch {
228
+ // Browser open is best-effort; user can navigate manually.
229
+ }
230
+ });
231
+
232
+ const timeout = setTimeout(() => {
233
+ server.close();
234
+ rejectLogin(new Error('Login timed out after 5 minutes. Try again.'));
235
+ }, 5 * 60 * 1000);
236
+
237
+ try {
238
+ const { access_token, refresh_token } = await loginPromise;
239
+ clearTimeout(timeout);
240
+ server.close();
241
+
242
+ const user = await getUserInfo(access_token);
243
+ setSession({ access_token, refresh_token, user });
244
+
245
+ const displayName = user?.email || user?.user_metadata?.user_name || 'unknown';
246
+ console.log(`Logged in as ${displayName}`);
247
+ } catch (err) {
248
+ clearTimeout(timeout);
249
+ server.close();
250
+ console.error('Login failed:', err.message);
251
+ process.exit(1);
252
+ }
253
+ }
254
+
255
+ module.exports = { run };
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+
3
+ const { clearSession, getSession } = require('../auth');
4
+
5
+ function run() {
6
+ const session = getSession();
7
+ if (!session) {
8
+ console.log('Not currently logged in.');
9
+ return;
10
+ }
11
+ clearSession();
12
+ const email = session.user?.email || 'unknown';
13
+ console.log(`Logged out (was: ${email}).`);
14
+ }
15
+
16
+ module.exports = { run };