create-memory-soda 0.2.0 → 0.4.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.
- package/README.md +22 -20
- package/index.js +379 -133
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,37 +1,39 @@
|
|
|
1
1
|
# create-memory-soda
|
|
2
2
|
|
|
3
3
|
Scaffold a self-hosted [Memory Soda](https://github.com/alagappan17/memory-soda)
|
|
4
|
-
instance — API, dashboard,
|
|
4
|
+
instance — API, dashboard, and a Postgres to point them at.
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
7
|
npm create memory-soda@latest
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
The installer checks each requirement before asking you to use it, creates
|
|
11
|
+
what it can, and ends with a checklist of anything it could not do for you.
|
|
12
|
+
|
|
13
|
+
1. **Preflight** — Node 20+ and git. Missing? Prints the install command for
|
|
14
|
+
your OS and stops. Nothing is written.
|
|
15
|
+
2. **Folder** — `memory-soda` by default.
|
|
16
|
+
3. **Postgres** — bring your own URL, let it start `pgvector/pgvector:pg16` in
|
|
17
|
+
Docker (offered when Docker is running), or skip.
|
|
18
|
+
4. **Database check** — connects, offers to create a missing database,
|
|
19
|
+
confirms pgvector and enables the extension. Failures name the fix and let
|
|
20
|
+
you retry, paste another URL, or skip.
|
|
21
|
+
5. **Gemini key** — checked live. Blank allowed; goes on the checklist.
|
|
22
|
+
6. **Ports** — checked for use. Dashboard login starts as `admin` /
|
|
23
|
+
`open-sesame`; change it after signing in.
|
|
24
|
+
7. **Clone, `.env`, `npm ci`, migrations** — migrations run now if the database
|
|
25
|
+
passed.
|
|
26
|
+
|
|
27
|
+
Then:
|
|
15
28
|
|
|
16
29
|
```bash
|
|
17
30
|
cd memory-soda
|
|
18
31
|
npm run dev
|
|
19
32
|
```
|
|
20
33
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
## Postgres
|
|
25
|
-
|
|
26
|
-
You bring the server; the installer creates the database. It asks for a
|
|
27
|
-
`DATABASE_URL`, probes the host and port while you are still at the prompt, and
|
|
28
|
-
after install connects for real. A missing database is created for you (the
|
|
29
|
-
role in the URL needs `CREATEDB`). Anything else — server down, bad password,
|
|
30
|
-
no pgvector — names the fix and lets you retry without starting over.
|
|
31
|
-
|
|
32
|
-
The role in the URL needs permission to `CREATE EXTENSION vector` — the first
|
|
33
|
-
migration creates the extension. On most installations that means a superuser.
|
|
34
|
+
Sign in to the dashboard (:3000) to create an API key. `npm run update` pulls
|
|
35
|
+
and reinstalls later. Pass `--verbose` to stream clone/install output.
|
|
34
36
|
|
|
35
37
|
## Requirements
|
|
36
38
|
|
|
37
|
-
Node 20+, git
|
|
39
|
+
Node 20+, git. Postgres 14+ with pgvector, or Docker.
|
package/index.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn, execFileSync } from 'node:child_process';
|
|
3
|
-
import { existsSync, readdirSync, writeFileSync,
|
|
4
|
-
import { resolve, basename
|
|
5
|
-
import { createRequire } from 'node:module';
|
|
3
|
+
import { existsSync, readdirSync, writeFileSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { resolve, basename } from 'node:path';
|
|
6
5
|
import { connect } from 'node:net';
|
|
7
|
-
import { exit } from 'node:process';
|
|
6
|
+
import { exit, platform } from 'node:process';
|
|
8
7
|
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
9
9
|
import * as p from '@clack/prompts';
|
|
10
10
|
import chalk from 'chalk';
|
|
11
|
+
import pg from 'pg';
|
|
11
12
|
|
|
12
13
|
const REPO = 'https://github.com/alagappan17/memory-soda.git';
|
|
13
14
|
const DEFAULT_DB = 'postgresql://localhost:5432/memory_db';
|
|
15
|
+
const DOCKER_IMAGE = 'pgvector/pgvector:pg16';
|
|
14
16
|
const VERBOSE = process.argv.includes('--verbose');
|
|
15
17
|
|
|
16
18
|
function die(msg) {
|
|
@@ -27,7 +29,11 @@ function guard(value) {
|
|
|
27
29
|
/** Run a command quietly, returning stdout — or null if it failed or is missing. */
|
|
28
30
|
function tryRun(cmd, args, opts = {}) {
|
|
29
31
|
try {
|
|
30
|
-
return execFileSync(cmd, args, {
|
|
32
|
+
return execFileSync(cmd, args, {
|
|
33
|
+
encoding: 'utf8',
|
|
34
|
+
stdio: 'pipe',
|
|
35
|
+
...opts,
|
|
36
|
+
});
|
|
31
37
|
} catch {
|
|
32
38
|
return null;
|
|
33
39
|
}
|
|
@@ -63,7 +69,11 @@ function runWithSpinner(title, cmd, args, { cwd } = {}) {
|
|
|
63
69
|
if (code === 0) return s.stop(title) ?? done();
|
|
64
70
|
s.stop(`${title} — failed`, 1);
|
|
65
71
|
p.log.error(log.trim().split('\n').slice(-20).join('\n'));
|
|
66
|
-
fail(
|
|
72
|
+
fail(
|
|
73
|
+
new Error(
|
|
74
|
+
`${cmd} ${args.join(' ')} exited with ${code}. Re-run with --verbose for the full log.`,
|
|
75
|
+
),
|
|
76
|
+
);
|
|
67
77
|
});
|
|
68
78
|
});
|
|
69
79
|
}
|
|
@@ -73,8 +83,8 @@ function runWithSpinner(title, cmd, args, { cwd } = {}) {
|
|
|
73
83
|
* explicitly — everything else stays on the defaults baked into config.ts, so
|
|
74
84
|
* this file does not drift every time a new option is added there.
|
|
75
85
|
*/
|
|
76
|
-
export function renderEnv({ databaseUrl, geminiKey,
|
|
77
|
-
|
|
86
|
+
export function renderEnv({ databaseUrl, geminiKey, apiPort, dashboardPort }) {
|
|
87
|
+
return [
|
|
78
88
|
'# Generated by create-memory-soda.',
|
|
79
89
|
'# Full reference: https://github.com/alagappan17/memory-soda',
|
|
80
90
|
'',
|
|
@@ -91,15 +101,11 @@ export function renderEnv({ databaseUrl, geminiKey, adminUser, adminPassword, ap
|
|
|
91
101
|
`CORS_ORIGIN=http://localhost:${dashboardPort}`,
|
|
92
102
|
`VITE_API_URL=http://localhost:${apiPort}`,
|
|
93
103
|
'',
|
|
94
|
-
'# Dashboard login
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
// answer omits the key entirely rather than writing an empty one.
|
|
100
|
-
if (adminPassword) lines.push(`ADMIN_PASSWORD=${adminPassword}`);
|
|
101
|
-
lines.push('');
|
|
102
|
-
return lines.join('\n');
|
|
104
|
+
'# Dashboard login. Defaults to admin / open-sesame — change it in the dashboard after first sign-in.',
|
|
105
|
+
'# ADMIN_USERNAME=admin',
|
|
106
|
+
'# ADMIN_PASSWORD=open-sesame',
|
|
107
|
+
'',
|
|
108
|
+
].join('\n');
|
|
103
109
|
}
|
|
104
110
|
|
|
105
111
|
/** True if the path is safe to scaffold into: missing, or an empty directory. */
|
|
@@ -137,62 +143,82 @@ function isReachable({ host, port }, timeout = 2000) {
|
|
|
137
143
|
});
|
|
138
144
|
}
|
|
139
145
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return require('pg').Client;
|
|
143
|
-
}
|
|
146
|
+
const dbName = (databaseUrl) =>
|
|
147
|
+
decodeURIComponent(new URL(databaseUrl).pathname.slice(1));
|
|
144
148
|
|
|
145
149
|
/** Create the database named in the URL by connecting to the maintenance db on the same server. */
|
|
146
|
-
async function createDatabase(
|
|
147
|
-
const Client = loadPg(target);
|
|
150
|
+
async function createDatabase(databaseUrl) {
|
|
148
151
|
const url = new URL(databaseUrl);
|
|
149
|
-
const db = decodeURIComponent(url.pathname.slice(1));
|
|
150
152
|
url.pathname = '/postgres';
|
|
151
|
-
const client = new Client({ connectionString: url.href });
|
|
153
|
+
const client = new pg.Client({ connectionString: url.href });
|
|
152
154
|
try {
|
|
153
155
|
await client.connect();
|
|
154
|
-
await client.query(
|
|
156
|
+
await client.query(
|
|
157
|
+
`CREATE DATABASE "${dbName(databaseUrl).replaceAll('"', '""')}"`,
|
|
158
|
+
);
|
|
155
159
|
} finally {
|
|
156
160
|
await client.end().catch(() => {});
|
|
157
161
|
}
|
|
158
162
|
}
|
|
159
163
|
|
|
164
|
+
/** OS-specific hint for a pgvector install, matched to the server's major version. */
|
|
165
|
+
function pgvectorHint(serverMajor) {
|
|
166
|
+
if (platform === 'darwin') return 'brew install pgvector';
|
|
167
|
+
if (platform === 'linux')
|
|
168
|
+
return `sudo apt install postgresql-${serverMajor}-pgvector`;
|
|
169
|
+
return 'see https://github.com/pgvector/pgvector#installation';
|
|
170
|
+
}
|
|
171
|
+
|
|
160
172
|
/**
|
|
161
|
-
* Connect for real
|
|
162
|
-
*
|
|
163
|
-
* driver of its own. Returns null on success, or an operator-readable problem.
|
|
173
|
+
* Connect for real, confirm pgvector is installable, and enable it. Returns
|
|
174
|
+
* null on success, or an operator-readable { problem, fix, ...flags }.
|
|
164
175
|
*/
|
|
165
|
-
export async function checkDatabase(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
Client = loadPg(target);
|
|
169
|
-
} catch {
|
|
170
|
-
return { skipped: 'could not load pg from the new project' };
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const client = new Client({ connectionString: databaseUrl });
|
|
176
|
+
export async function checkDatabase(databaseUrl) {
|
|
177
|
+
const addr = parsePostgresUrl(databaseUrl) ?? { host: '?', port: '?' };
|
|
178
|
+
const client = new pg.Client({ connectionString: databaseUrl });
|
|
174
179
|
try {
|
|
175
180
|
await client.connect();
|
|
176
|
-
const { rows } = await client.query(
|
|
181
|
+
const { rows } = await client.query(
|
|
182
|
+
"SELECT 1 FROM pg_available_extensions WHERE name = 'vector'",
|
|
183
|
+
);
|
|
177
184
|
if (rows.length === 0) {
|
|
185
|
+
const major = (
|
|
186
|
+
await client.query('SHOW server_version')
|
|
187
|
+
).rows[0].server_version.split('.')[0];
|
|
178
188
|
return {
|
|
179
189
|
problem: 'pgvector is not available on this server',
|
|
180
|
-
fix:
|
|
190
|
+
fix: `${pgvectorHint(major)}, restart Postgres, then retry. Or let the installer start a Docker Postgres that has it.`,
|
|
181
191
|
};
|
|
182
192
|
}
|
|
193
|
+
try {
|
|
194
|
+
await client.query('CREATE EXTENSION IF NOT EXISTS vector');
|
|
195
|
+
} catch (err) {
|
|
196
|
+
// Not fatal: the first migration retries this. Surface it as a to-do
|
|
197
|
+
// rather than blocking, since it usually just needs a superuser once.
|
|
198
|
+
if (err.code === '42501') return { extensionTodo: dbName(databaseUrl) };
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
183
201
|
return null;
|
|
184
202
|
} catch (err) {
|
|
185
203
|
if (err.code === '3D000') {
|
|
186
|
-
|
|
187
|
-
|
|
204
|
+
return {
|
|
205
|
+
problem: `database "${dbName(databaseUrl)}" does not exist`,
|
|
206
|
+
missingDb: dbName(databaseUrl),
|
|
207
|
+
};
|
|
188
208
|
}
|
|
189
209
|
if (err.code === '28P01' || err.code === '28000') {
|
|
190
|
-
return {
|
|
210
|
+
return {
|
|
211
|
+
problem: 'authentication failed',
|
|
212
|
+
fix: 'Check the user and password in the URL.',
|
|
213
|
+
};
|
|
191
214
|
}
|
|
192
|
-
if (err.code === 'ECONNREFUSED') {
|
|
215
|
+
if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND') {
|
|
193
216
|
return {
|
|
194
|
-
problem:
|
|
195
|
-
fix:
|
|
217
|
+
problem: `nothing is accepting connections at ${addr.host}:${addr.port}`,
|
|
218
|
+
fix:
|
|
219
|
+
platform === 'darwin'
|
|
220
|
+
? 'brew services start postgresql@16 (or start your Postgres however you installed it).'
|
|
221
|
+
: 'sudo systemctl start postgresql (or start your Postgres however you installed it).',
|
|
196
222
|
};
|
|
197
223
|
}
|
|
198
224
|
return { problem: err.message, fix: null };
|
|
@@ -201,119 +227,335 @@ export async function checkDatabase(target, databaseUrl) {
|
|
|
201
227
|
}
|
|
202
228
|
}
|
|
203
229
|
|
|
230
|
+
/** Start a pgvector-enabled Postgres in Docker. Returns the DATABASE_URL, or throws. */
|
|
231
|
+
async function startDockerPostgres() {
|
|
232
|
+
const password = randomBytes(12).toString('hex');
|
|
233
|
+
const name = 'memory-soda-pg';
|
|
234
|
+
// A stale container from a previous run would fail the `run` with a name
|
|
235
|
+
// clash; removing it is safe because it only ever held this installer's data.
|
|
236
|
+
tryRun('docker', ['rm', '-f', name]);
|
|
237
|
+
await runWithSpinner(
|
|
238
|
+
`Starting Postgres in Docker (${DOCKER_IMAGE})`,
|
|
239
|
+
'docker',
|
|
240
|
+
[
|
|
241
|
+
'run',
|
|
242
|
+
'-d',
|
|
243
|
+
'--name',
|
|
244
|
+
name,
|
|
245
|
+
'--restart',
|
|
246
|
+
'unless-stopped',
|
|
247
|
+
'-e',
|
|
248
|
+
`POSTGRES_PASSWORD=${password}`,
|
|
249
|
+
'-e',
|
|
250
|
+
'POSTGRES_DB=memory_db',
|
|
251
|
+
'-p',
|
|
252
|
+
'5432:5432',
|
|
253
|
+
DOCKER_IMAGE,
|
|
254
|
+
],
|
|
255
|
+
);
|
|
256
|
+
const addr = { host: 'localhost', port: 5432 };
|
|
257
|
+
// Postgres takes a moment to accept connections after the container is up.
|
|
258
|
+
for (let i = 0; i < 20 && !(await isReachable(addr, 500)); i++)
|
|
259
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
260
|
+
return `postgresql://postgres:${password}@localhost:5432/memory_db`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** A cheap live check that the Gemini key is accepted. null = ok, string = why not. */
|
|
264
|
+
export async function checkGeminiKey(key) {
|
|
265
|
+
try {
|
|
266
|
+
const res = await fetch(
|
|
267
|
+
'https://generativelanguage.googleapis.com/v1beta/models?pageSize=1',
|
|
268
|
+
{
|
|
269
|
+
headers: { 'x-goog-api-key': key },
|
|
270
|
+
signal: AbortSignal.timeout(8000),
|
|
271
|
+
},
|
|
272
|
+
);
|
|
273
|
+
if (res.ok) return null;
|
|
274
|
+
if (res.status === 400 || res.status === 403)
|
|
275
|
+
return 'Gemini rejected that key.';
|
|
276
|
+
return `Gemini answered ${res.status}.`;
|
|
277
|
+
} catch {
|
|
278
|
+
return 'could not reach Gemini (offline?)';
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
204
282
|
const validPort = (v) => {
|
|
205
283
|
if (!v) return; // blank takes the default
|
|
206
284
|
const n = Number(v);
|
|
207
|
-
if (!Number.isInteger(n) || n < 1 || n > 65535)
|
|
285
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535)
|
|
286
|
+
return 'Enter a port between 1 and 65535.';
|
|
208
287
|
};
|
|
209
288
|
|
|
289
|
+
async function askPort(message, defaultValue) {
|
|
290
|
+
for (;;) {
|
|
291
|
+
const port = Number(
|
|
292
|
+
guard(
|
|
293
|
+
await p.text({
|
|
294
|
+
message,
|
|
295
|
+
placeholder: defaultValue,
|
|
296
|
+
defaultValue,
|
|
297
|
+
validate: validPort,
|
|
298
|
+
}),
|
|
299
|
+
),
|
|
300
|
+
);
|
|
301
|
+
if (!(await isReachable({ host: '127.0.0.1', port }, 300))) return port;
|
|
302
|
+
p.log.warn(`Port ${port} is already in use.`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
210
306
|
async function main() {
|
|
307
|
+
// ── 0. Preflight: check and tell, never install. System packages are the
|
|
308
|
+
// user's call; we only ever run things inside the folder we create.
|
|
211
309
|
if (Number(process.versions.node.split('.')[0]) < 20) {
|
|
212
|
-
die(
|
|
310
|
+
die(
|
|
311
|
+
`Node 20 or newer is required — you are on ${process.versions.node}.\n ` +
|
|
312
|
+
(tryRun('nvm', ['--version'])
|
|
313
|
+
? 'nvm install 22 && nvm use 22'
|
|
314
|
+
: 'https://nodejs.org or brew install node') +
|
|
315
|
+
'\n then re-run: npm create memory-soda@latest',
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
if (!tryRun('git', ['--version'])) {
|
|
319
|
+
die(
|
|
320
|
+
'git is required but was not found.\n ' +
|
|
321
|
+
(platform === 'darwin'
|
|
322
|
+
? 'xcode-select --install'
|
|
323
|
+
: 'sudo apt install git') +
|
|
324
|
+
'\n then re-run: npm create memory-soda@latest',
|
|
325
|
+
);
|
|
213
326
|
}
|
|
214
|
-
|
|
327
|
+
const hasDocker = Boolean(tryRun('docker', ['info']));
|
|
215
328
|
|
|
216
|
-
|
|
217
|
-
|
|
329
|
+
p.intro(chalk.bold('create-memory-soda'));
|
|
330
|
+
const todo = []; // things the user must do themselves before the app runs
|
|
218
331
|
|
|
219
|
-
|
|
332
|
+
// ── 1. Folder
|
|
220
333
|
const dirName =
|
|
221
|
-
|
|
334
|
+
process.argv.slice(2).find((a) => !a.startsWith('-')) ??
|
|
222
335
|
guard(
|
|
223
336
|
await p.text({
|
|
224
|
-
message: '
|
|
337
|
+
message: 'Project folder',
|
|
225
338
|
placeholder: 'memory-soda',
|
|
226
339
|
defaultValue: 'memory-soda',
|
|
227
340
|
validate: (v) =>
|
|
228
|
-
isUsableTarget(resolve(process.cwd(), v || 'memory-soda'))
|
|
341
|
+
isUsableTarget(resolve(process.cwd(), v || 'memory-soda'))
|
|
342
|
+
? undefined
|
|
343
|
+
: 'That folder exists and is not empty.',
|
|
229
344
|
}),
|
|
230
345
|
);
|
|
231
346
|
const target = resolve(process.cwd(), dirName);
|
|
232
|
-
if (!isUsableTarget(target))
|
|
347
|
+
if (!isUsableTarget(target))
|
|
348
|
+
die(`${target} already exists and is not empty.`);
|
|
233
349
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
350
|
+
// ── 2 + 3. Postgres: where is it, then verify it until it works or is skipped.
|
|
351
|
+
let databaseUrl = DEFAULT_DB;
|
|
352
|
+
let dbReady = false;
|
|
353
|
+
const where = guard(
|
|
354
|
+
await p.select({
|
|
355
|
+
message: 'Where is Postgres?',
|
|
356
|
+
options: [
|
|
357
|
+
{
|
|
358
|
+
value: 'own',
|
|
359
|
+
label: 'I have one',
|
|
360
|
+
hint: 'you give the URL; pgvector must be installed on it',
|
|
361
|
+
},
|
|
362
|
+
...(hasDocker
|
|
363
|
+
? [
|
|
364
|
+
{
|
|
365
|
+
value: 'docker',
|
|
366
|
+
label: 'Start one in Docker for me',
|
|
367
|
+
hint: DOCKER_IMAGE,
|
|
368
|
+
},
|
|
369
|
+
]
|
|
370
|
+
: []),
|
|
371
|
+
{
|
|
372
|
+
value: 'skip',
|
|
373
|
+
label: 'Skip',
|
|
374
|
+
hint: 'set DATABASE_URL in .env yourself later',
|
|
375
|
+
},
|
|
376
|
+
],
|
|
248
377
|
}),
|
|
249
378
|
);
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
379
|
+
if (where === 'docker') {
|
|
380
|
+
try {
|
|
381
|
+
databaseUrl = await startDockerPostgres();
|
|
382
|
+
} catch (err) {
|
|
383
|
+
p.log.error(err.message);
|
|
384
|
+
}
|
|
385
|
+
} else if (where === 'own') {
|
|
386
|
+
databaseUrl = guard(
|
|
387
|
+
await p.text({
|
|
388
|
+
message: 'Postgres URL',
|
|
389
|
+
placeholder: DEFAULT_DB,
|
|
390
|
+
defaultValue: DEFAULT_DB,
|
|
391
|
+
validate: (v) =>
|
|
392
|
+
v && !parsePostgresUrl(v) ? 'Expected a postgres:// URL.' : undefined,
|
|
393
|
+
}),
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
if (where !== 'skip') {
|
|
397
|
+
for (;;) {
|
|
398
|
+
// A clack spinner cannot be restarted once stopped (its timer leaks and
|
|
399
|
+
// the process never exits), so every phase gets a fresh one.
|
|
400
|
+
let s = p.spinner();
|
|
401
|
+
s.start('Checking the database');
|
|
402
|
+
let result = await checkDatabase(databaseUrl);
|
|
403
|
+
if (result?.missingDb) {
|
|
404
|
+
s.stop(`Database "${result.missingDb}" does not exist`);
|
|
405
|
+
if (
|
|
406
|
+
!guard(
|
|
407
|
+
await p.confirm({
|
|
408
|
+
message: `Create database "${result.missingDb}"?`,
|
|
409
|
+
initialValue: true,
|
|
410
|
+
}),
|
|
411
|
+
)
|
|
412
|
+
) {
|
|
413
|
+
todo.push(`createdb ${result.missingDb}`);
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
s = p.spinner();
|
|
417
|
+
s.start(`Creating database ${result.missingDb}`);
|
|
418
|
+
try {
|
|
419
|
+
await createDatabase(databaseUrl);
|
|
420
|
+
result = await checkDatabase(databaseUrl);
|
|
421
|
+
} catch (err) {
|
|
422
|
+
result = {
|
|
423
|
+
problem: `could not create database "${result.missingDb}": ${err.message}`,
|
|
424
|
+
fix: `createdb ${result.missingDb} (as a role with CREATEDB)`,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (result === null) {
|
|
429
|
+
s.stop('Database ready — connected, pgvector enabled');
|
|
430
|
+
dbReady = true;
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
if (result.extensionTodo) {
|
|
434
|
+
s.stop('Database connected, but this role cannot enable pgvector', 1);
|
|
435
|
+
todo.push(
|
|
436
|
+
`psql -d ${result.extensionTodo} -c "CREATE EXTENSION vector" (run as a Postgres superuser)`,
|
|
437
|
+
);
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
s.stop(`Database: ${result.problem}`, 1);
|
|
441
|
+
if (result.fix) p.log.info(result.fix);
|
|
442
|
+
const retry =
|
|
443
|
+
guard(
|
|
444
|
+
await p.text({
|
|
445
|
+
message:
|
|
446
|
+
'Fix it and press enter to retry, paste a new DATABASE_URL, or type s to skip',
|
|
447
|
+
placeholder: 'enter to retry',
|
|
448
|
+
}),
|
|
449
|
+
) ?? '';
|
|
450
|
+
if (retry.toLowerCase() === 's') break;
|
|
451
|
+
if (retry) databaseUrl = retry;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (!dbReady && todo.length === 0) {
|
|
455
|
+
todo.push(
|
|
456
|
+
`Point DATABASE_URL in .env at a Postgres with pgvector, and create the database (createdb ${dbName(databaseUrl)})`,
|
|
457
|
+
);
|
|
255
458
|
}
|
|
256
459
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
guard(await p.text({ message: 'Dashboard port', placeholder: '3000', defaultValue: '3000', validate: validPort })),
|
|
260
|
-
);
|
|
261
|
-
const adminUser = guard(await p.text({ message: 'Dashboard admin username', placeholder: 'admin', defaultValue: 'admin' }));
|
|
262
|
-
const adminPassword =
|
|
263
|
-
guard(await p.password({ message: `Dashboard admin password ${chalk.dim('(leave blank to generate one at first boot)')}`, mask: '•' })) ?? '';
|
|
264
|
-
|
|
265
|
-
await runWithSpinner(`Cloning into ${chalk.bold(dirName)}`, 'git', ['clone', '--depth', '1', REPO, target]);
|
|
266
|
-
rmSync(resolve(target, '.git'), { recursive: true, force: true });
|
|
267
|
-
|
|
268
|
-
const writeEnv = () =>
|
|
269
|
-
writeFileSync(resolve(target, '.env'), renderEnv({ databaseUrl, geminiKey, adminUser, adminPassword, apiPort, dashboardPort }));
|
|
270
|
-
writeEnv();
|
|
271
|
-
p.log.success('Wrote .env');
|
|
272
|
-
|
|
273
|
-
await runWithSpinner('Installing dependencies', 'npm', ['ci', '--no-audit', '--no-fund'], { cwd: target });
|
|
274
|
-
|
|
275
|
-
// Now that pg exists in the project, check the database for real. Looping
|
|
276
|
-
// here beats failing at first boot: the fix is usually one command away and
|
|
277
|
-
// the answer is still in front of the user.
|
|
460
|
+
// ── 4. Gemini
|
|
461
|
+
let geminiKey = '';
|
|
278
462
|
for (;;) {
|
|
463
|
+
geminiKey =
|
|
464
|
+
guard(
|
|
465
|
+
await p.password({
|
|
466
|
+
message: `Gemini API key ${chalk.dim('(aistudio.google.com — leave blank to add later)')}`,
|
|
467
|
+
mask: '•',
|
|
468
|
+
}),
|
|
469
|
+
) ?? '';
|
|
470
|
+
if (!geminiKey) break;
|
|
279
471
|
const s = p.spinner();
|
|
280
|
-
s.start('Checking the
|
|
281
|
-
|
|
282
|
-
if (
|
|
283
|
-
s.
|
|
284
|
-
try {
|
|
285
|
-
await createDatabase(target, databaseUrl);
|
|
286
|
-
result = await checkDatabase(target, databaseUrl);
|
|
287
|
-
} catch (err) {
|
|
288
|
-
result = { problem: `could not create database "${result.missingDb}": ${err.message}`, fix: result.fix };
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
if (result === null) {
|
|
292
|
-
s.stop('Database ready — connected, pgvector available');
|
|
472
|
+
s.start('Checking the key');
|
|
473
|
+
const problem = await checkGeminiKey(geminiKey);
|
|
474
|
+
if (!problem) {
|
|
475
|
+
s.stop('Gemini key accepted');
|
|
293
476
|
break;
|
|
294
477
|
}
|
|
295
|
-
|
|
296
|
-
|
|
478
|
+
s.stop(problem, 1);
|
|
479
|
+
if (
|
|
480
|
+
guard(
|
|
481
|
+
await p.confirm({ message: 'Keep it anyway?', initialValue: false }),
|
|
482
|
+
)
|
|
483
|
+
)
|
|
297
484
|
break;
|
|
485
|
+
}
|
|
486
|
+
if (!geminiKey)
|
|
487
|
+
todo.push(
|
|
488
|
+
'Set GOOGLE_GENERATIVE_AI_API_KEY in .env — the API refuses to start without it',
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
// ── 5. Ports
|
|
492
|
+
const apiPort = await askPort('API port', '3004');
|
|
493
|
+
const dashboardPort = await askPort('Dashboard port', '3000');
|
|
494
|
+
// ── 7. Install
|
|
495
|
+
await runWithSpinner(`Cloning into ${chalk.bold(dirName)}`, 'git', [
|
|
496
|
+
'clone',
|
|
497
|
+
'--depth',
|
|
498
|
+
'1',
|
|
499
|
+
REPO,
|
|
500
|
+
target,
|
|
501
|
+
]);
|
|
502
|
+
writeFileSync(
|
|
503
|
+
resolve(target, '.env'),
|
|
504
|
+
renderEnv({
|
|
505
|
+
databaseUrl,
|
|
506
|
+
geminiKey,
|
|
507
|
+
apiPort,
|
|
508
|
+
dashboardPort,
|
|
509
|
+
}),
|
|
510
|
+
);
|
|
511
|
+
p.log.success('Wrote .env');
|
|
512
|
+
await runWithSpinner(
|
|
513
|
+
'Installing dependencies',
|
|
514
|
+
'npm',
|
|
515
|
+
['ci', '--no-audit', '--no-fund'],
|
|
516
|
+
{ cwd: target },
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
let migrated = false;
|
|
520
|
+
if (dbReady) {
|
|
521
|
+
try {
|
|
522
|
+
await runWithSpinner(
|
|
523
|
+
'Applying migrations',
|
|
524
|
+
'npm',
|
|
525
|
+
['run', 'db:migrate'],
|
|
526
|
+
{ cwd: target },
|
|
527
|
+
);
|
|
528
|
+
migrated = true;
|
|
529
|
+
} catch {
|
|
530
|
+
todo.push(
|
|
531
|
+
'npm run db:migrate (failed during install — see the log above)',
|
|
532
|
+
);
|
|
298
533
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const retry = guard(
|
|
302
|
-
await p.text({
|
|
303
|
-
message: 'Fix it and press enter to retry, paste a new DATABASE_URL, or type s to skip',
|
|
304
|
-
placeholder: 'enter to retry',
|
|
305
|
-
}),
|
|
306
|
-
) ?? '';
|
|
307
|
-
if (retry.toLowerCase() === 's') {
|
|
308
|
-
p.log.warn('Skipped — the API will fail on boot until this is resolved.');
|
|
309
|
-
break;
|
|
310
|
-
}
|
|
311
|
-
if (retry) {
|
|
312
|
-
databaseUrl = retry;
|
|
313
|
-
writeEnv();
|
|
314
|
-
}
|
|
534
|
+
} else {
|
|
535
|
+
todo.push('npm run db:migrate (once the database is ready)');
|
|
315
536
|
}
|
|
316
537
|
|
|
538
|
+
// ── 8. Summary
|
|
539
|
+
const tick = (ok, text) =>
|
|
540
|
+
`${ok ? chalk.green('✓') : chalk.yellow('✗')} ${text}`;
|
|
541
|
+
p.note(
|
|
542
|
+
[
|
|
543
|
+
tick(
|
|
544
|
+
dbReady,
|
|
545
|
+
dbReady
|
|
546
|
+
? `Postgres ${databaseUrl.replace(/:[^:@/]+@/, ':•••@')}`
|
|
547
|
+
: 'Postgres not verified',
|
|
548
|
+
),
|
|
549
|
+
tick(
|
|
550
|
+
Boolean(geminiKey),
|
|
551
|
+
geminiKey ? 'Gemini key set' : 'Gemini key missing',
|
|
552
|
+
),
|
|
553
|
+
tick(migrated, migrated ? 'Migrations applied' : 'Migrations pending'),
|
|
554
|
+
].join('\n'),
|
|
555
|
+
'Status',
|
|
556
|
+
);
|
|
557
|
+
if (todo.length)
|
|
558
|
+
p.note(todo.map((t, i) => `${i + 1}. ${t}`).join('\n'), 'Before you run');
|
|
317
559
|
p.note(
|
|
318
560
|
[
|
|
319
561
|
`cd ${dirName}`,
|
|
@@ -321,14 +563,16 @@ async function main() {
|
|
|
321
563
|
'',
|
|
322
564
|
`Dashboard ${chalk.underline(`http://localhost:${dashboardPort}`)}`,
|
|
323
565
|
`API ${chalk.underline(`http://localhost:${apiPort}`)}`,
|
|
324
|
-
`Login ${
|
|
566
|
+
`Login ${chalk.bold('admin')} / ${chalk.bold('open-sesame')} ${chalk.dim('← change it under Settings after signing in')}`,
|
|
325
567
|
'',
|
|
326
|
-
'
|
|
327
|
-
...(geminiKey ? [] : ['Set GOOGLE_GENERATIVE_AI_API_KEY in .env before starting.']),
|
|
568
|
+
'Then sign in, create an API key, and: npm install @memory-soda/sdk',
|
|
328
569
|
].join('\n'),
|
|
329
|
-
'
|
|
570
|
+
'Run',
|
|
330
571
|
);
|
|
331
|
-
p.outro(
|
|
572
|
+
p.outro(
|
|
573
|
+
`Thanks for trying Memory Soda — ${chalk.bold(basename(target))} is ready.`,
|
|
574
|
+
);
|
|
575
|
+
exit(0);
|
|
332
576
|
}
|
|
333
577
|
|
|
334
578
|
/**
|
|
@@ -339,7 +583,9 @@ async function main() {
|
|
|
339
583
|
function isEntryPoint() {
|
|
340
584
|
if (!process.argv[1]) return false;
|
|
341
585
|
try {
|
|
342
|
-
return
|
|
586
|
+
return (
|
|
587
|
+
import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href
|
|
588
|
+
);
|
|
343
589
|
} catch {
|
|
344
590
|
return false;
|
|
345
591
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-memory-soda",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Scaffold a self-hosted Memory Soda instance — API, dashboard, and Postgres",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@clack/prompts": "^1.7.0",
|
|
40
|
-
"chalk": "^5.6.2"
|
|
40
|
+
"chalk": "^5.6.2",
|
|
41
|
+
"pg": "^8.23.0"
|
|
41
42
|
}
|
|
42
43
|
}
|