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