@motherbase/cli 0.2.1 → 0.2.3
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 +24 -0
- package/bin/motherbase.js +1 -1
- package/package.json +3 -2
- package/src/api.js +35 -6
- package/src/commands/functions.js +274 -112
- package/src/commands/gen.js +126 -0
- package/src/commands/index.js +4 -0
- package/src/commands/rls.js +93 -2
- package/src/main.js +5 -0
- package/src/prompt.js +61 -14
package/README.md
CHANGED
|
@@ -84,9 +84,33 @@ Global flags: `--json`, `--yes/-y`, `--project/-p <id>`, `--url/-u <consoleUrl>`
|
|
|
84
84
|
- **Cron**: `cron list|create|delete|toggle`
|
|
85
85
|
- **Config**: `config list|get|set`
|
|
86
86
|
- **Inspect**: `inspect logs|stats|performance|requests|advisor|shield`
|
|
87
|
+
- **Codegen**: `gen types typescript [--schema public] [--local] [--output <file>] [--check]`
|
|
87
88
|
|
|
88
89
|
Run `motherbase <group> -h` for per-group usage.
|
|
89
90
|
|
|
91
|
+
## Type generation
|
|
92
|
+
|
|
93
|
+
Generate TypeScript definitions for the live schema, in the shape supabase-js
|
|
94
|
+
emits, so `createClient<Database>(url, key)` types every row, rejects unknown
|
|
95
|
+
columns and types `.rpc()` calls:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
motherbase gen types typescript > database.types.ts
|
|
99
|
+
motherbase gen types typescript --schema shop --output src/shop.types.ts
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
With no `--output` the file goes to stdout and nothing else does, so the
|
|
103
|
+
redirect above produces a compiling file. `--local` skips the Console proxy and
|
|
104
|
+
talks to a project API on this machine (`MOTHERBASE_URL`, default
|
|
105
|
+
`http://localhost:5001`, with `MOTHERBASE_ADMIN_TOKEN`).
|
|
106
|
+
|
|
107
|
+
Gate CI on schema drift with `--check`, which never writes and exits non-zero
|
|
108
|
+
when the committed file differs from a fresh generation:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
motherbase gen types typescript --check --output database.types.ts
|
|
112
|
+
```
|
|
113
|
+
|
|
90
114
|
## Supabase migration
|
|
91
115
|
|
|
92
116
|
Preview a Supabase import without writing to Motherbase:
|
package/bin/motherbase.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motherbase/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Motherbase operator CLI — drives the Platform Console and linked projects.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
|
@@ -20,5 +20,6 @@
|
|
|
20
20
|
"publishConfig": {
|
|
21
21
|
"access": "public"
|
|
22
22
|
},
|
|
23
|
-
"license": "MIT"
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"dependencies": {}
|
|
24
25
|
}
|
package/src/api.js
CHANGED
|
@@ -36,6 +36,14 @@ function unwrap(body) {
|
|
|
36
36
|
return body;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Non-browser clients send no Origin header. Better Auth (and reverse proxies
|
|
40
|
+
// that inject cookies) then treat the request as CSRF-suspect and reject it with
|
|
41
|
+
// "Missing or null Origin". Sending the target's own origin — which the server
|
|
42
|
+
// keeps in its trustedOrigins (baseURL) — satisfies that check for every route.
|
|
43
|
+
function originFor(url) {
|
|
44
|
+
try { return new URL(url).origin; } catch { return undefined; }
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
function errorMessage(status, body, text) {
|
|
40
48
|
const snippet = (body && (body.message || body.error)) || (text ? text.slice(0, 300) : '');
|
|
41
49
|
if (status === 401) {
|
|
@@ -65,6 +73,10 @@ async function parseResponse(res) {
|
|
|
65
73
|
async function doFetch(url, { method = 'GET', token, body, headers = {} } = {}) {
|
|
66
74
|
const finalHeaders = { ...headers };
|
|
67
75
|
if (token) finalHeaders['authorization'] = `Bearer ${token}`;
|
|
76
|
+
if (!finalHeaders['origin']) {
|
|
77
|
+
const origin = originFor(url);
|
|
78
|
+
if (origin) finalHeaders['origin'] = origin;
|
|
79
|
+
}
|
|
68
80
|
let payload;
|
|
69
81
|
if (body !== undefined && body !== null) {
|
|
70
82
|
finalHeaders['content-type'] = 'application/json';
|
|
@@ -94,9 +106,12 @@ async function loginRequest(consoleUrl, email, password) {
|
|
|
94
106
|
const url = `${consoleUrl}/api/auth/sign-in/email`;
|
|
95
107
|
let res;
|
|
96
108
|
try {
|
|
109
|
+
const headers = { 'content-type': 'application/json' };
|
|
110
|
+
const origin = originFor(url);
|
|
111
|
+
if (origin) headers['origin'] = origin;
|
|
97
112
|
res = await fetch(url, {
|
|
98
113
|
method: 'POST',
|
|
99
|
-
headers
|
|
114
|
+
headers,
|
|
100
115
|
body: JSON.stringify({ email, password }),
|
|
101
116
|
});
|
|
102
117
|
} catch (err) {
|
|
@@ -128,12 +143,19 @@ function adminRequest(ctx, method, apiPath, { body, query } = {}) {
|
|
|
128
143
|
return doFetch(url, { method, token, body });
|
|
129
144
|
}
|
|
130
145
|
|
|
146
|
+
// The public URL of a project path (`/edge/<route>`, `/rest/v1/<table>`), for
|
|
147
|
+
// printing. Mirrors how the proxy addresses a project in each mode.
|
|
148
|
+
function projectUrl(ctx, projectPath) {
|
|
149
|
+
const target = ctx.requireProject();
|
|
150
|
+
return target.mode === 'direct'
|
|
151
|
+
? `${target.baseUrl}/api${projectPath}`
|
|
152
|
+
: `${target.consoleUrl}/api/projects/${target.projectId}${projectPath}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
131
155
|
// Edge invoke goes through the un-authenticated edge route, not /admin.
|
|
132
156
|
function edgeInvokeRequest(ctx, route, body) {
|
|
157
|
+
const url = projectUrl(ctx, `/edge/${encodeURIComponent(route)}`);
|
|
133
158
|
const target = ctx.requireProject();
|
|
134
|
-
const url = target.mode === 'direct'
|
|
135
|
-
? `${target.baseUrl}/api/edge/${encodeURIComponent(route)}`
|
|
136
|
-
: `${target.consoleUrl}/api/projects/${target.projectId}/edge/${encodeURIComponent(route)}`;
|
|
137
159
|
return doFetch(url, { method: 'POST', token: target.token, body: body || {} });
|
|
138
160
|
}
|
|
139
161
|
|
|
@@ -149,11 +171,14 @@ async function uploadFile(ctx, apiPath, filePath, fields = {}) {
|
|
|
149
171
|
for (const [k, v] of Object.entries(fields)) {
|
|
150
172
|
if (v !== undefined && v !== null) form.append(k, String(v));
|
|
151
173
|
}
|
|
174
|
+
const uploadHeaders = token ? { authorization: `Bearer ${token}` } : {};
|
|
175
|
+
const uploadOrigin = originFor(url);
|
|
176
|
+
if (uploadOrigin) uploadHeaders['origin'] = uploadOrigin;
|
|
152
177
|
let res;
|
|
153
178
|
try {
|
|
154
179
|
res = await fetch(url, {
|
|
155
180
|
method: 'POST',
|
|
156
|
-
headers:
|
|
181
|
+
headers: uploadHeaders,
|
|
157
182
|
body: form,
|
|
158
183
|
});
|
|
159
184
|
} catch (err) {
|
|
@@ -168,9 +193,12 @@ async function downloadFile(ctx, apiPath, destPath, { query } = {}) {
|
|
|
168
193
|
const target = ctx.requireProject();
|
|
169
194
|
const { base, token } = adminBase(target);
|
|
170
195
|
const url = `${base}${apiPath}${buildQuery(query)}`;
|
|
196
|
+
const dlHeaders = token ? { authorization: `Bearer ${token}` } : {};
|
|
197
|
+
const dlOrigin = originFor(url);
|
|
198
|
+
if (dlOrigin) dlHeaders['origin'] = dlOrigin;
|
|
171
199
|
let res;
|
|
172
200
|
try {
|
|
173
|
-
res = await fetch(url, { headers:
|
|
201
|
+
res = await fetch(url, { headers: dlHeaders });
|
|
174
202
|
} catch (err) {
|
|
175
203
|
throw new CliError(`Cannot reach ${url}: ${err.message}`);
|
|
176
204
|
}
|
|
@@ -193,6 +221,7 @@ module.exports = {
|
|
|
193
221
|
loginRequest,
|
|
194
222
|
adminRequest,
|
|
195
223
|
edgeInvokeRequest,
|
|
224
|
+
projectUrl,
|
|
196
225
|
uploadFile,
|
|
197
226
|
downloadFile,
|
|
198
227
|
buildQuery,
|
|
@@ -1,112 +1,274 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const api = require('../api');
|
|
6
|
-
const ui = require('../ui');
|
|
7
|
-
const { output, requireArg } = require('./_util');
|
|
8
|
-
const { CliError } = require('../context');
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const api = require('../api');
|
|
6
|
+
const ui = require('../ui');
|
|
7
|
+
const { output, requireArg } = require('./_util');
|
|
8
|
+
const { CliError } = require('../context');
|
|
9
|
+
|
|
10
|
+
// Functions live as repo files, one directory per function, the layout Supabase
|
|
11
|
+
// projects already use:
|
|
12
|
+
//
|
|
13
|
+
// functions/
|
|
14
|
+
// stripe-webhook/
|
|
15
|
+
// index.ts
|
|
16
|
+
//
|
|
17
|
+
// The directory name is both the function name and its route, so deploying is
|
|
18
|
+
// `motherbase functions deploy` with nothing to remember.
|
|
19
|
+
const DEFAULT_DIR = 'functions';
|
|
20
|
+
const LEGACY_DIR = path.join('motherbase', 'functions');
|
|
21
|
+
const ENTRY_NAMES = ['index.ts', 'index.js', 'index.mjs'];
|
|
22
|
+
|
|
23
|
+
const TEMPLATE = `// Edge function: %NAME%
|
|
24
|
+
//
|
|
25
|
+
// Runs in a V8 isolate with the Fetch API available: Request, Response,
|
|
26
|
+
// Headers, URL, TextEncoder/TextDecoder and crypto (including subtle.sign, for
|
|
27
|
+
// webhook signature checks). npm imports are resolved at deploy time:
|
|
28
|
+
//
|
|
29
|
+
// import { z } from 'zod';
|
|
30
|
+
//
|
|
31
|
+
// \`env\` holds this project's edge secrets.
|
|
32
|
+
|
|
33
|
+
export default async (req: Request) => {
|
|
34
|
+
const body = req.method === 'GET' ? {} : await req.json().catch(() => ({}));
|
|
35
|
+
return Response.json({ hello: '%NAME%', received: body, at: new Date().toISOString() });
|
|
36
|
+
};
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
function functionsDir(ctx) {
|
|
40
|
+
for (const candidate of [DEFAULT_DIR, LEGACY_DIR]) {
|
|
41
|
+
if (fs.existsSync(path.join(ctx.cwd, candidate))) return path.join(ctx.cwd, candidate);
|
|
42
|
+
}
|
|
43
|
+
return path.join(ctx.cwd, DEFAULT_DIR);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Entry file of a function directory, or null when it has none. */
|
|
47
|
+
function entryFile(dir) {
|
|
48
|
+
for (const name of ENTRY_NAMES) {
|
|
49
|
+
const file = path.join(dir, name);
|
|
50
|
+
if (fs.existsSync(file)) return file;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Every locally defined function: `{ name, dir, entry }`. */
|
|
56
|
+
function localFunctions(ctx) {
|
|
57
|
+
const root = functionsDir(ctx);
|
|
58
|
+
if (!fs.existsSync(root)) return [];
|
|
59
|
+
return fs
|
|
60
|
+
.readdirSync(root, { withFileTypes: true })
|
|
61
|
+
.filter(item => item.isDirectory())
|
|
62
|
+
.map(item => ({ name: item.name, dir: path.join(root, item.name), entry: entryFile(path.join(root, item.name)) }))
|
|
63
|
+
.filter(fn => fn.entry);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveLocal(ctx, name) {
|
|
67
|
+
const all = localFunctions(ctx);
|
|
68
|
+
if (!name) return all;
|
|
69
|
+
const hit = all.find(fn => fn.name === name);
|
|
70
|
+
if (!hit) {
|
|
71
|
+
const expected = path.relative(ctx.cwd, path.join(functionsDir(ctx), name, 'index.ts'));
|
|
72
|
+
throw new CliError(`No local function "${name}". Expected ${expected}.`);
|
|
73
|
+
}
|
|
74
|
+
return [hit];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Upsert by route, so deploying twice updates instead of conflicting. */
|
|
78
|
+
async function deployOne(ctx, fn, flags) {
|
|
79
|
+
const code = fs.readFileSync(fn.entry, 'utf8');
|
|
80
|
+
const body = { name: fn.name, route: fn.name, code };
|
|
81
|
+
if (flags && flags.memory) body.memoryLimitMb = Number(flags.memory);
|
|
82
|
+
if (flags && flags.timeout) body.timeoutMs = Number(flags.timeout);
|
|
83
|
+
|
|
84
|
+
const listing = await api.adminRequest(ctx, 'GET', '/edge-functions');
|
|
85
|
+
const existing = ((listing && listing.functions) || []).find(f => f.route === fn.name);
|
|
86
|
+
if (existing) return api.adminRequest(ctx, 'PATCH', `/edge-functions/${existing.id}`, { body });
|
|
87
|
+
return api.adminRequest(ctx, 'POST', '/edge-functions', { body: { ...body, enabled: true } });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const subcommands = {
|
|
91
|
+
list: {
|
|
92
|
+
summary: 'List edge functions',
|
|
93
|
+
usage: 'motherbase functions list',
|
|
94
|
+
async run(ctx) {
|
|
95
|
+
const data = await api.adminRequest(ctx, 'GET', '/edge-functions');
|
|
96
|
+
const rows = (data && data.functions) || [];
|
|
97
|
+
output(ctx, data, (_d, u) =>
|
|
98
|
+
u.table(rows, [
|
|
99
|
+
{ key: 'id', label: 'ID' },
|
|
100
|
+
{ key: 'name', label: 'NAME' },
|
|
101
|
+
{ key: 'route', label: 'ROUTE' },
|
|
102
|
+
{ key: 'enabled', label: 'ENABLED' },
|
|
103
|
+
]),
|
|
104
|
+
);
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
get: {
|
|
108
|
+
summary: 'Get an edge function by id',
|
|
109
|
+
usage: 'motherbase functions get <id>',
|
|
110
|
+
async run(ctx, args) {
|
|
111
|
+
const id = requireArg(args[0], 'id');
|
|
112
|
+
const data = await api.adminRequest(ctx, 'GET', `/edge-functions/${id}`);
|
|
113
|
+
output(ctx, data, (d, u) => u.keyValue((d && d.function) || d || {}));
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
new: {
|
|
117
|
+
summary: 'Scaffold functions/<name>/index.ts',
|
|
118
|
+
usage: 'motherbase functions new <name>',
|
|
119
|
+
async run(ctx, args) {
|
|
120
|
+
const name = requireArg(args[0], 'name');
|
|
121
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
|
|
122
|
+
throw new CliError('Function names are lowercase letters, digits and hyphens (the name is also the route).');
|
|
123
|
+
}
|
|
124
|
+
const dir = path.join(functionsDir(ctx), name);
|
|
125
|
+
if (entryFile(dir)) throw new CliError(`${path.relative(ctx.cwd, dir)} already has an entry file.`);
|
|
126
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
127
|
+
const file = path.join(dir, 'index.ts');
|
|
128
|
+
fs.writeFileSync(file, TEMPLATE.replace(/%NAME%/g, name));
|
|
129
|
+
ui.success(`Created ${path.relative(ctx.cwd, file)}`);
|
|
130
|
+
ui.info(`Deploy it with: motherbase functions deploy ${name}`);
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
deploy: {
|
|
134
|
+
summary: 'Deploy local functions (all of them, or one by name)',
|
|
135
|
+
usage: 'motherbase functions deploy [name] [--memory <mb>] [--timeout <ms>] [--file <f> --route <r>]',
|
|
136
|
+
async run(ctx, args, flags) {
|
|
137
|
+
// The single-file form predates the directory layout and stays supported:
|
|
138
|
+
// scripts and older docs pass --file.
|
|
139
|
+
if (flags.file) {
|
|
140
|
+
const route = flags.route || args[0];
|
|
141
|
+
if (!route) throw new CliError('--route is required with --file');
|
|
142
|
+
const code = fs.readFileSync(path.resolve(ctx.cwd, flags.file), 'utf8');
|
|
143
|
+
const name = flags.name || route;
|
|
144
|
+
const listing = await api.adminRequest(ctx, 'GET', '/edge-functions');
|
|
145
|
+
const existing = ((listing && listing.functions) || []).find(f => f.route === route);
|
|
146
|
+
const data = existing
|
|
147
|
+
? await api.adminRequest(ctx, 'PATCH', `/edge-functions/${existing.id}`, { body: { name, route, code } })
|
|
148
|
+
: await api.adminRequest(ctx, 'POST', '/edge-functions', { body: { name, route, code, enabled: true } });
|
|
149
|
+
return output(ctx, data, (_d, u) => u.success(`Deployed function ${name} (${route})`));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const targets = resolveLocal(ctx, args[0]);
|
|
153
|
+
if (!targets.length) {
|
|
154
|
+
const where = path.relative(ctx.cwd, functionsDir(ctx));
|
|
155
|
+
throw new CliError(`No functions in ${where}. Create one with: motherbase functions new <name>`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const deployed = [];
|
|
159
|
+
for (const fn of targets) {
|
|
160
|
+
await deployOne(ctx, fn, flags);
|
|
161
|
+
deployed.push(fn.name);
|
|
162
|
+
}
|
|
163
|
+
output(ctx, { deployed }, (_d, u) => u.success(`Deployed ${deployed.join(', ')}`));
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
pull: {
|
|
167
|
+
summary: 'Write deployed function source back to disk',
|
|
168
|
+
usage: 'motherbase functions pull [name]',
|
|
169
|
+
async run(ctx, args) {
|
|
170
|
+
const listing = await api.adminRequest(ctx, 'GET', '/edge-functions');
|
|
171
|
+
const all = (listing && listing.functions) || [];
|
|
172
|
+
const wanted = args[0] ? all.filter(f => f.route === args[0] || f.name === args[0]) : all;
|
|
173
|
+
if (!wanted.length) throw new CliError(args[0] ? `No deployed function "${args[0]}"` : 'No deployed functions');
|
|
174
|
+
|
|
175
|
+
const written = [];
|
|
176
|
+
for (const fn of wanted) {
|
|
177
|
+
// The list endpoint may omit code, so read the function itself.
|
|
178
|
+
const detail = await api.adminRequest(ctx, 'GET', `/edge-functions/${fn.id}`);
|
|
179
|
+
const code = (detail && detail.function ? detail.function.code : detail && detail.code) || '';
|
|
180
|
+
const dir = path.join(functionsDir(ctx), fn.route || fn.name);
|
|
181
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
182
|
+
const file = path.join(dir, 'index.ts');
|
|
183
|
+
fs.writeFileSync(file, code);
|
|
184
|
+
written.push(path.relative(ctx.cwd, file));
|
|
185
|
+
}
|
|
186
|
+
output(ctx, { written }, (_d, u) => u.success(`Wrote ${written.join(', ')}`));
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
serve: {
|
|
190
|
+
summary: 'Watch local functions and redeploy on every save',
|
|
191
|
+
usage: 'motherbase functions serve [name]',
|
|
192
|
+
async run(ctx, args) {
|
|
193
|
+
// Deliberately not a second local runtime. The isolate, the secrets and
|
|
194
|
+
// the bundler all live in the project API, and a local re-implementation
|
|
195
|
+
// would differ from production in exactly the ways that matter. This
|
|
196
|
+
// watches and redeploys instead, so the edit-invoke loop is one save long.
|
|
197
|
+
const targets = resolveLocal(ctx, args[0]);
|
|
198
|
+
if (!targets.length) {
|
|
199
|
+
throw new CliError('Nothing to serve. Create a function with: motherbase functions new <name>');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const fn of targets) {
|
|
203
|
+
await deployOne(ctx, fn);
|
|
204
|
+
ui.success(`Deployed ${fn.name}`);
|
|
205
|
+
ui.info(` POST ${api.projectUrl(ctx, `/edge/${fn.name}`)}`);
|
|
206
|
+
}
|
|
207
|
+
ui.info('Watching for changes. Ctrl-C to stop.');
|
|
208
|
+
|
|
209
|
+
const pending = new Map();
|
|
210
|
+
for (const fn of targets) {
|
|
211
|
+
fs.watch(fn.dir, { recursive: true }, () => {
|
|
212
|
+
// Editors save in bursts (write, rename, truncate). Coalescing them
|
|
213
|
+
// keeps one save from becoming three deploys.
|
|
214
|
+
clearTimeout(pending.get(fn.name));
|
|
215
|
+
pending.set(
|
|
216
|
+
fn.name,
|
|
217
|
+
setTimeout(async () => {
|
|
218
|
+
try {
|
|
219
|
+
await deployOne(ctx, fn);
|
|
220
|
+
ui.success(`Redeployed ${fn.name} at ${new Date().toLocaleTimeString()}`);
|
|
221
|
+
} catch (error) {
|
|
222
|
+
ui.warn(`${fn.name}: ${error.message}`);
|
|
223
|
+
}
|
|
224
|
+
}, 150),
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Hold the process open; the watchers are the work.
|
|
230
|
+
await new Promise(() => {});
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
delete: {
|
|
234
|
+
summary: 'Delete an edge function by id',
|
|
235
|
+
usage: 'motherbase functions delete <id>',
|
|
236
|
+
async run(ctx, args) {
|
|
237
|
+
const id = requireArg(args[0], 'id');
|
|
238
|
+
const data = await api.adminRequest(ctx, 'DELETE', `/edge-functions/${id}`);
|
|
239
|
+
output(ctx, data, (d, u) => u.success(`Deleted function ${id}`));
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
invoke: {
|
|
243
|
+
summary: 'Invoke an edge function by route',
|
|
244
|
+
usage: 'motherbase functions invoke <route> [--body <json>]',
|
|
245
|
+
async run(ctx, args, flags) {
|
|
246
|
+
const route = requireArg(args[0], 'route');
|
|
247
|
+
let body = {};
|
|
248
|
+
if (flags.body) {
|
|
249
|
+
try { body = JSON.parse(flags.body); } catch { throw new CliError('--body must be valid JSON'); }
|
|
250
|
+
}
|
|
251
|
+
const data = await api.edgeInvokeRequest(ctx, route, body);
|
|
252
|
+
output(ctx, data, (d, u) => u.json(d));
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
logs: {
|
|
256
|
+
summary: 'Show logs for an edge function',
|
|
257
|
+
usage: 'motherbase functions logs <id>',
|
|
258
|
+
async run(ctx, args) {
|
|
259
|
+
const id = requireArg(args[0], 'id');
|
|
260
|
+
const data = await api.adminRequest(ctx, 'GET', `/edge-functions/${id}/logs`);
|
|
261
|
+
output(ctx, data, (d, u) => {
|
|
262
|
+
const logs = (d && d.logs) || d || [];
|
|
263
|
+
if (Array.isArray(logs)) logs.forEach(l => u.print(typeof l === 'string' ? l : JSON.stringify(l)));
|
|
264
|
+
else u.json(d);
|
|
265
|
+
});
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
module.exports = {
|
|
271
|
+
summary: 'Edge functions (repo files, deploy, invoke, logs)',
|
|
272
|
+
usage: 'motherbase functions <list|get|new|deploy|pull|serve|delete|invoke|logs> ...',
|
|
273
|
+
subcommands,
|
|
274
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Code generation from the live schema. Today: TypeScript database types.
|
|
4
|
+
//
|
|
5
|
+
// The server owns the emitter (apps/server/services/admin/typegen.service.ts).
|
|
6
|
+
// This command is a transport: it asks `GET /admin/schema/types` for the
|
|
7
|
+
// rendered file and writes it. Re-implementing the emitter here would guarantee
|
|
8
|
+
// drift between the CLI, the Studio download and the MCP tool within a release.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const api = require('../api');
|
|
13
|
+
const config = require('../config');
|
|
14
|
+
const { output } = require('./_util');
|
|
15
|
+
const { CliError } = require('../context');
|
|
16
|
+
|
|
17
|
+
const DEFAULT_OUTPUT = 'database.types.ts';
|
|
18
|
+
const DEFAULT_LOCAL_URL = 'http://localhost:5001';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `--local` bypasses the Console proxy and talks straight to a project API on
|
|
22
|
+
* this machine, which is how someone iterating on a local stack works: there is
|
|
23
|
+
* no console account, no link file, just a server on a port. Everything else
|
|
24
|
+
* about the context (flags, cwd, json mode) is preserved.
|
|
25
|
+
*/
|
|
26
|
+
function resolveTarget(ctx, flags) {
|
|
27
|
+
if (!flags.local) return ctx;
|
|
28
|
+
const baseUrl = config.normalizeUrl(flags.url || ctx.env.MOTHERBASE_URL || DEFAULT_LOCAL_URL);
|
|
29
|
+
const token = flags.token || ctx.env.MOTHERBASE_ADMIN_TOKEN || ctx.token;
|
|
30
|
+
return Object.assign({}, ctx, {
|
|
31
|
+
mode: 'direct',
|
|
32
|
+
requireProject: () => ({ mode: 'direct', baseUrl, token }),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Git checkouts on Windows may rewrite line endings. The generator always emits
|
|
37
|
+
// LF, so compare on normalised text: a CRLF working copy is not schema drift.
|
|
38
|
+
function normalize(text) {
|
|
39
|
+
return String(text).replace(/\r\n/g, '\n');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** First line where the two files diverge, 1-based, for a useful error. */
|
|
43
|
+
function firstDifference(a, b) {
|
|
44
|
+
const left = normalize(a).split('\n');
|
|
45
|
+
const right = normalize(b).split('\n');
|
|
46
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
47
|
+
if (left[i] !== right[i]) {
|
|
48
|
+
return { line: i + 1, expected: right[i], actual: left[i] };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const typescript = {
|
|
55
|
+
summary: 'Generate TypeScript types for the database schema',
|
|
56
|
+
usage:
|
|
57
|
+
'motherbase gen types typescript [--schema public] [--local] [--output database.types.ts] [--check]',
|
|
58
|
+
async run(ctx, args, flags) {
|
|
59
|
+
const schema = flags.schema || 'public';
|
|
60
|
+
const data = await api.adminRequest(resolveTarget(ctx, flags), 'GET', '/schema/types', {
|
|
61
|
+
query: { schema },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const types = data && typeof data.types === 'string' ? data.types : null;
|
|
65
|
+
if (!types) {
|
|
66
|
+
throw new CliError(
|
|
67
|
+
'The server returned no generated types. Check that the project runs a build with GET /admin/schema/types.',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const outputPath = flags.output && flags.output !== true ? String(flags.output) : null;
|
|
72
|
+
|
|
73
|
+
// --check is the CI drift gate: compare the committed file with a fresh
|
|
74
|
+
// generation and fail loudly, without ever writing.
|
|
75
|
+
if (flags.check) {
|
|
76
|
+
const target = path.resolve(ctx.cwd, outputPath || DEFAULT_OUTPUT);
|
|
77
|
+
if (!fs.existsSync(target)) {
|
|
78
|
+
throw new CliError(`${target} does not exist. Run \`motherbase gen types typescript --output ${outputPath || DEFAULT_OUTPUT}\`.`);
|
|
79
|
+
}
|
|
80
|
+
const onDisk = fs.readFileSync(target, 'utf8');
|
|
81
|
+
const diff = firstDifference(onDisk, types);
|
|
82
|
+
if (diff) {
|
|
83
|
+
throw new CliError(
|
|
84
|
+
`${target} is out of date with the ${schema} schema (first difference at line ${diff.line}).\n` +
|
|
85
|
+
` on disk: ${diff.actual === undefined ? '(end of file)' : diff.actual}\n` +
|
|
86
|
+
` generated: ${diff.expected === undefined ? '(end of file)' : diff.expected}\n` +
|
|
87
|
+
'Re-run without --check to update it.',
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
output(ctx, { schema, output: target, upToDate: true }, (d, ui) =>
|
|
91
|
+
ui.success(`${target} is up to date`),
|
|
92
|
+
);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// No --output: write the file to stdout so `... > database.types.ts` works.
|
|
97
|
+
// Nothing else may go to stdout on this path or it lands in the file.
|
|
98
|
+
if (!outputPath) {
|
|
99
|
+
if (ctx.flags.json) {
|
|
100
|
+
output(ctx, { schema, types }, () => {});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
process.stdout.write(types);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const target = path.resolve(ctx.cwd, outputPath);
|
|
108
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
109
|
+
fs.writeFileSync(target, types, 'utf8');
|
|
110
|
+
output(ctx, { schema, output: target, bytes: Buffer.byteLength(types) }, (d, ui) =>
|
|
111
|
+
ui.success(`Wrote ${target} (${schema} schema)`),
|
|
112
|
+
);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const types = {
|
|
117
|
+
summary: 'Generate types from the database schema',
|
|
118
|
+
usage: 'motherbase gen types typescript [flags]',
|
|
119
|
+
subcommands: { typescript },
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
module.exports = {
|
|
123
|
+
summary: 'Generate code from the live schema',
|
|
124
|
+
usage: 'motherbase gen types typescript [--schema public] [--local] [--output <file>] [--check]',
|
|
125
|
+
subcommands: { types },
|
|
126
|
+
};
|
package/src/commands/index.js
CHANGED
|
@@ -41,6 +41,9 @@ const registry = {
|
|
|
41
41
|
cron: require('./cron'),
|
|
42
42
|
config: require('./config'),
|
|
43
43
|
inspect: require('./inspect'),
|
|
44
|
+
|
|
45
|
+
// ── Codegen ────────────────────────────────────────────────────────────────
|
|
46
|
+
gen: require('./gen'),
|
|
44
47
|
};
|
|
45
48
|
|
|
46
49
|
// Grouping used only to render a tidy top-level help listing.
|
|
@@ -50,6 +53,7 @@ const groups = [
|
|
|
50
53
|
{ title: 'Local self-host', names: ['init', 'start', 'stop', 'deploy', 'logs'] },
|
|
51
54
|
{ title: 'Database', names: ['db', 'migrations', 'migration', 'import'] },
|
|
52
55
|
{ title: 'Resources', names: ['rls', 'users', 'storage', 'functions', 'secrets', 'realtime', 'webhooks', 'cron', 'config', 'inspect'] },
|
|
56
|
+
{ title: 'Codegen', names: ['gen'] },
|
|
53
57
|
];
|
|
54
58
|
|
|
55
59
|
module.exports = { registry, groups };
|
package/src/commands/rls.js
CHANGED
|
@@ -76,10 +76,101 @@ const subcommands = {
|
|
|
76
76
|
},
|
|
77
77
|
},
|
|
78
78
|
policy,
|
|
79
|
+
preset: {
|
|
80
|
+
summary: 'Apply a policy preset to a table',
|
|
81
|
+
usage:
|
|
82
|
+
'motherbase rls preset <table> <starter|public_crud|authenticated_crud|admin_only|blocked|owner_only|org_scoped|public_read_owner_write|soft_delete> [--owner-column c] [--org-column c] [--org-claim c] [--deleted-column c] [--replace]',
|
|
83
|
+
async run(ctx, args, flags) {
|
|
84
|
+
const table = requireArg(args[0], 'table');
|
|
85
|
+
const preset = requireArg(args[1], 'preset');
|
|
86
|
+
const body = { preset };
|
|
87
|
+
if (flags['owner-column']) body.ownerColumn = flags['owner-column'];
|
|
88
|
+
if (flags['org-column']) body.orgColumn = flags['org-column'];
|
|
89
|
+
if (flags['org-claim']) body.orgClaim = flags['org-claim'];
|
|
90
|
+
if (flags['deleted-column']) body.deletedColumn = flags['deleted-column'];
|
|
91
|
+
if (flags.replace) body.replace = true;
|
|
92
|
+
const data = await api.adminRequest(ctx, 'POST', `/rls/tables/${table}/preset`, { body });
|
|
93
|
+
output(ctx, data, (d, ui) =>
|
|
94
|
+
ui.success(`Applied ${preset} to ${table}: ${(d && d.policies ? d.policies : []).join(', ')}`)
|
|
95
|
+
);
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
simulate: {
|
|
99
|
+
summary: 'Run a query as a given caller and show what RLS lets through',
|
|
100
|
+
usage:
|
|
101
|
+
'motherbase rls simulate --sql "select * from t" [--as anon|authenticated|<userId>] [--claims \'{"org_id":"x"}\'] [--limit n]',
|
|
102
|
+
async run(ctx, args, flags) {
|
|
103
|
+
const sql = flags.sql || args.join(' ');
|
|
104
|
+
if (!sql) throw new CliError('--sql is required');
|
|
105
|
+
|
|
106
|
+
// `--as` takes a role or a user id. A value that is not one of the two
|
|
107
|
+
// roles is treated as a user id, which is what makes "as this person"
|
|
108
|
+
// spellable without looking their claims up by hand.
|
|
109
|
+
const as = flags.as || 'anon';
|
|
110
|
+
const body = { sql };
|
|
111
|
+
if (as === 'anon' || as === 'authenticated') body.role = as;
|
|
112
|
+
else body.userId = as;
|
|
113
|
+
if (flags.claims) {
|
|
114
|
+
try {
|
|
115
|
+
body.claims = JSON.parse(flags.claims);
|
|
116
|
+
} catch {
|
|
117
|
+
throw new CliError('--claims must be a JSON object');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (flags.limit) body.limit = Number(flags.limit);
|
|
121
|
+
|
|
122
|
+
const data = await api.adminRequest(ctx, 'POST', '/rls/simulate', { body });
|
|
123
|
+
output(ctx, data, (d, ui) => {
|
|
124
|
+
if (d.denied) {
|
|
125
|
+
ui.warn(`Refused by Postgres: ${d.denied.message}`);
|
|
126
|
+
}
|
|
127
|
+
if (d.rows && d.rows.length) {
|
|
128
|
+
ui.table(d.rows, (d.columns || []).map(key => ({ key, label: key.toUpperCase() })));
|
|
129
|
+
} else {
|
|
130
|
+
ui.info('0 rows');
|
|
131
|
+
}
|
|
132
|
+
if (d.truncated) ui.info(`Truncated at ${d.rowCount} rows.`);
|
|
133
|
+
for (const t of d.tables || []) {
|
|
134
|
+
const reason =
|
|
135
|
+
t.deniedBy === 'grant'
|
|
136
|
+
? 'no privilege for this role'
|
|
137
|
+
: t.deniedBy === 'policy'
|
|
138
|
+
? 'RLS on, no policy grants this role read access'
|
|
139
|
+
: null;
|
|
140
|
+
ui.info(`${t.schema}.${t.table}: RLS ${t.rlsEnabled ? 'on' : 'off'}${reason ? ` (${reason})` : ''}`);
|
|
141
|
+
for (const p of t.policies || []) {
|
|
142
|
+
ui.info(` ${p.applies ? '*' : ' '} ${p.name} ${p.command} to ${p.roles.join(',')}: ${p.using || 'true'}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
lint: {
|
|
149
|
+
summary: 'Run the security and performance advisor',
|
|
150
|
+
usage: 'motherbase rls lint [--level critical|warning|info]',
|
|
151
|
+
async run(ctx, args, flags) {
|
|
152
|
+
const data = await api.adminRequest(ctx, 'GET', '/advisor');
|
|
153
|
+
const findings = (data && data.findings ? data.findings : []).filter(
|
|
154
|
+
f => !flags.level || f.level === flags.level
|
|
155
|
+
);
|
|
156
|
+
output(ctx, { ...data, findings }, (d, ui) => {
|
|
157
|
+
if (!findings.length) return ui.success('No findings.');
|
|
158
|
+
ui.table(
|
|
159
|
+
findings.map(f => ({ level: f.level, id: f.id, target: f.target, title: f.title })),
|
|
160
|
+
[
|
|
161
|
+
{ key: 'level', label: 'LEVEL' },
|
|
162
|
+
{ key: 'id', label: 'RULE' },
|
|
163
|
+
{ key: 'target', label: 'TARGET' },
|
|
164
|
+
{ key: 'title', label: 'FINDING' },
|
|
165
|
+
]
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
},
|
|
169
|
+
},
|
|
79
170
|
};
|
|
80
171
|
|
|
81
172
|
module.exports = {
|
|
82
|
-
summary: 'Row-level security (tables
|
|
83
|
-
usage: 'motherbase rls <list|enable|disable|policy> ...',
|
|
173
|
+
summary: 'Row-level security (tables, policies, presets, simulator, lint)',
|
|
174
|
+
usage: 'motherbase rls <list|enable|disable|policy|preset|simulate|lint> ...',
|
|
84
175
|
subcommands,
|
|
85
176
|
};
|
package/src/main.js
CHANGED
|
@@ -8,6 +8,11 @@ const ui = require('./ui');
|
|
|
8
8
|
// Flags that never consume a following value.
|
|
9
9
|
const PURE_BOOLEAN = new Set([
|
|
10
10
|
'json', 'yes', 'help', 'version', 'force', 'preview', 'app', 'no-data', 'no-auth', 'f',
|
|
11
|
+
// Codegen: `--check` and `--local` never take a value, so a trailing
|
|
12
|
+
// `--check database.types.ts` must not silently swallow the path.
|
|
13
|
+
'check', 'local',
|
|
14
|
+
// `rls preset <table> <preset> --replace` must not swallow a following word.
|
|
15
|
+
'replace',
|
|
11
16
|
]);
|
|
12
17
|
// Short flag aliases → canonical (with whether they take a value).
|
|
13
18
|
const SHORT = {
|
package/src/prompt.js
CHANGED
|
@@ -14,23 +14,70 @@ function question(query) {
|
|
|
14
14
|
});
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
// Hidden input
|
|
18
|
-
//
|
|
17
|
+
// Hidden input. Reads the TTY in raw mode so nothing echoes, masking each typed
|
|
18
|
+
// character with '*'. Handles backspace, Enter, Ctrl+C, Ctrl+D and swallows
|
|
19
|
+
// escape sequences (arrow keys etc.) so they never land in the value. When
|
|
20
|
+
// stdin is not a TTY (pipes, CI) we fall back to a plain, unmasked line read.
|
|
19
21
|
function password(query = 'Password: ') {
|
|
22
|
+
const stdin = process.stdin;
|
|
23
|
+
const stdout = process.stdout;
|
|
24
|
+
if (!stdin.isTTY) return question(query);
|
|
25
|
+
|
|
20
26
|
return new Promise(resolve => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
let value = '';
|
|
28
|
+
let inEscape = false;
|
|
29
|
+
stdout.write(query);
|
|
30
|
+
stdin.setRawMode(true);
|
|
31
|
+
stdin.resume();
|
|
32
|
+
stdin.setEncoding('utf8');
|
|
33
|
+
|
|
34
|
+
const done = result => {
|
|
35
|
+
stdin.removeListener('data', onData);
|
|
36
|
+
stdin.setRawMode(false);
|
|
37
|
+
stdin.pause();
|
|
38
|
+
stdout.write('\n');
|
|
39
|
+
resolve(result);
|
|
25
40
|
};
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
|
|
42
|
+
const onData = chunk => {
|
|
43
|
+
for (const ch of chunk) {
|
|
44
|
+
if (inEscape) {
|
|
45
|
+
// Terminators of a CSI/escape sequence (e.g. arrow keys): stop swallowing.
|
|
46
|
+
if (/[a-zA-Z~]/.test(ch)) inEscape = false;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
switch (ch) {
|
|
50
|
+
case '\r':
|
|
51
|
+
case '\n':
|
|
52
|
+
case '\u0004': // Ctrl-D
|
|
53
|
+
return done(value);
|
|
54
|
+
case '\u0003': // Ctrl-C
|
|
55
|
+
stdin.setRawMode(false);
|
|
56
|
+
stdin.pause();
|
|
57
|
+
stdout.write('\n');
|
|
58
|
+
process.exit(130);
|
|
59
|
+
return;
|
|
60
|
+
case '\u007f': // Backspace / Delete
|
|
61
|
+
case '\b':
|
|
62
|
+
if (value.length > 0) {
|
|
63
|
+
value = value.slice(0, -1);
|
|
64
|
+
stdout.write('\b \b');
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
case '\u001b': // ESC — start of an escape sequence
|
|
68
|
+
inEscape = true;
|
|
69
|
+
break;
|
|
70
|
+
default:
|
|
71
|
+
// Ignore remaining control characters; mask printable ones.
|
|
72
|
+
if (ch >= ' ') {
|
|
73
|
+
value += ch;
|
|
74
|
+
stdout.write('*');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
stdin.on('data', onData);
|
|
34
81
|
});
|
|
35
82
|
}
|
|
36
83
|
|