create-memory-soda 0.2.0 → 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.
Files changed (3) hide show
  1. package/README.md +22 -20
  2. package/index.js +395 -119
  3. 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, SDK, and a Postgres to point them at.
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
- It asks for a folder name, a Gemini API key, your Postgres connection string,
11
- which ports to use, and the dashboard admin login. Then it clones the repo,
12
- writes `.env`, installs dependencies, creates the database if it is missing,
13
- and verifies pgvector is available. Clone and install run behind a spinner;
14
- pass `--verbose` to stream their full output instead.
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:
15
28
 
16
29
  ```bash
17
30
  cd memory-soda
18
31
  npm run dev
19
32
  ```
20
33
 
21
- First boot applies migrations, creates your admin user, and prints an API key
22
- once. Dashboard on :3000, API on :3004.
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, and a Postgres 14+ with the pgvector extension available.
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, rmSync, realpathSync } from 'node:fs';
4
- import { resolve, basename, join } from 'node:path';
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, { encoding: 'utf8', stdio: 'pipe', ...opts });
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(new Error(`${cmd} ${args.join(' ')} exited with ${code}. Re-run with --verbose for the full log.`));
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,7 +83,14 @@ 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, adminUser, adminPassword, apiPort, dashboardPort }) {
86
+ export function renderEnv({
87
+ databaseUrl,
88
+ geminiKey,
89
+ adminUser,
90
+ adminPassword,
91
+ apiPort,
92
+ dashboardPort,
93
+ }) {
77
94
  const lines = [
78
95
  '# Generated by create-memory-soda.',
79
96
  '# Full reference: https://github.com/alagappan17/memory-soda',
@@ -137,62 +154,82 @@ function isReachable({ host, port }, timeout = 2000) {
137
154
  });
138
155
  }
139
156
 
140
- function loadPg(target) {
141
- const require = createRequire(pathToFileURL(join(target, 'package.json')));
142
- return require('pg').Client;
143
- }
157
+ const dbName = (databaseUrl) =>
158
+ decodeURIComponent(new URL(databaseUrl).pathname.slice(1));
144
159
 
145
160
  /** Create the database named in the URL by connecting to the maintenance db on the same server. */
146
- async function createDatabase(target, databaseUrl) {
147
- const Client = loadPg(target);
161
+ async function createDatabase(databaseUrl) {
148
162
  const url = new URL(databaseUrl);
149
- const db = decodeURIComponent(url.pathname.slice(1));
150
163
  url.pathname = '/postgres';
151
- const client = new Client({ connectionString: url.href });
164
+ const client = new pg.Client({ connectionString: url.href });
152
165
  try {
153
166
  await client.connect();
154
- await client.query(`CREATE DATABASE "${db.replaceAll('"', '""')}"`);
167
+ await client.query(
168
+ `CREATE DATABASE "${dbName(databaseUrl).replaceAll('"', '""')}"`,
169
+ );
155
170
  } finally {
156
171
  await client.end().catch(() => {});
157
172
  }
158
173
  }
159
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
+ }
182
+
160
183
  /**
161
- * Connect for real and confirm pgvector is installable, using the `pg` copy
162
- * inside the freshly installed project so the installer needs no database
163
- * driver of its own. Returns null on success, or an operator-readable problem.
184
+ * Connect for real, confirm pgvector is installable, and enable it. Returns
185
+ * null on success, or an operator-readable { problem, fix, ...flags }.
164
186
  */
165
- export async function checkDatabase(target, databaseUrl) {
166
- let Client;
167
- try {
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 });
187
+ export async function checkDatabase(databaseUrl) {
188
+ const addr = parsePostgresUrl(databaseUrl) ?? { host: '?', port: '?' };
189
+ const client = new pg.Client({ connectionString: databaseUrl });
174
190
  try {
175
191
  await client.connect();
176
- const { rows } = await client.query("SELECT 1 FROM pg_available_extensions WHERE name = 'vector'");
192
+ const { rows } = await client.query(
193
+ "SELECT 1 FROM pg_available_extensions WHERE name = 'vector'",
194
+ );
177
195
  if (rows.length === 0) {
196
+ const major = (
197
+ await client.query('SHOW server_version')
198
+ ).rows[0].server_version.split('.')[0];
178
199
  return {
179
200
  problem: 'pgvector is not available on this server',
180
- fix: 'Install it brew install pgvector, or apt install postgresql-16-pgvector then retry.',
201
+ fix: `${pgvectorHint(major)}, restart Postgres, then retry. Or let the installer start a Docker Postgres that has it.`,
181
202
  };
182
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
+ }
183
212
  return null;
184
213
  } catch (err) {
185
214
  if (err.code === '3D000') {
186
- const db = decodeURIComponent(new URL(databaseUrl).pathname.slice(1));
187
- return { problem: `database "${db}" does not exist`, fix: `createdb ${db}`, missingDb: db };
215
+ return {
216
+ problem: `database "${dbName(databaseUrl)}" does not exist`,
217
+ missingDb: dbName(databaseUrl),
218
+ };
188
219
  }
189
220
  if (err.code === '28P01' || err.code === '28000') {
190
- return { problem: 'authentication failed', fix: 'Check the user and password in the URL.' };
221
+ return {
222
+ problem: 'authentication failed',
223
+ fix: 'Check the user and password in the URL.',
224
+ };
191
225
  }
192
- if (err.code === 'ECONNREFUSED') {
226
+ if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND') {
193
227
  return {
194
- problem: 'nothing is accepting connections at that host and port',
195
- fix: 'Start Postgres — brew services start postgresql, or pg_ctl start.',
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).',
196
233
  };
197
234
  }
198
235
  return { problem: err.message, fix: null };
@@ -201,119 +238,357 @@ export async function checkDatabase(target, databaseUrl) {
201
238
  }
202
239
  }
203
240
 
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
+ ],
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
+ }
273
+
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?)';
290
+ }
291
+ }
292
+
204
293
  const validPort = (v) => {
205
294
  if (!v) return; // blank takes the default
206
295
  const n = Number(v);
207
- if (!Number.isInteger(n) || n < 1 || n > 65535) return 'Enter a port between 1 and 65535.';
296
+ if (!Number.isInteger(n) || n < 1 || n > 65535)
297
+ return 'Enter a port between 1 and 65535.';
208
298
  };
209
299
 
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.`);
314
+ }
315
+ }
316
+
210
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.
211
320
  if (Number(process.versions.node.split('.')[0]) < 20) {
212
- die(`Node 20 or newer is required — you are on ${process.versions.node}.`);
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
+ );
213
328
  }
214
- if (!tryRun('git', ['--version'])) die('git is required but was not found.');
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']));
215
339
 
216
- console.log();
217
- p.intro(`${chalk.bgCyan.black(' Memory Soda ')} ${chalk.dim('self-hosted memory for AI agents')}`);
340
+ p.intro(chalk.bold('create-memory-soda'));
341
+ const todo = []; // things the user must do themselves before the app runs
218
342
 
219
- const dirArg = process.argv.slice(2).find((a) => !a.startsWith('-'));
343
+ // ── 1. Folder
220
344
  const dirName =
221
- dirArg ||
345
+ process.argv.slice(2).find((a) => !a.startsWith('-')) ??
222
346
  guard(
223
347
  await p.text({
224
- message: 'Where should we create your project?',
348
+ message: 'Project folder',
225
349
  placeholder: 'memory-soda',
226
350
  defaultValue: 'memory-soda',
227
351
  validate: (v) =>
228
- isUsableTarget(resolve(process.cwd(), v || 'memory-soda')) ? undefined : 'That folder exists and is not empty.',
352
+ isUsableTarget(resolve(process.cwd(), v || 'memory-soda'))
353
+ ? undefined
354
+ : 'That folder exists and is not empty.',
229
355
  }),
230
356
  );
231
357
  const target = resolve(process.cwd(), dirName);
232
- if (!isUsableTarget(target)) die(`${target} already exists and is not empty.`);
358
+ if (!isUsableTarget(target))
359
+ die(`${target} already exists and is not empty.`);
233
360
 
234
- const geminiKey = guard(
235
- await p.password({
236
- message: `Gemini API key ${chalk.dim('(aistudio.google.com — required for the memory layer, leave blank to add to .env later)')}`,
237
- mask: '•',
238
- }),
239
- ) ?? '';
240
- if (!geminiKey) p.log.warn('No key given. Memory extraction and search will not work until GOOGLE_GENERATIVE_AI_API_KEY is set in .env.');
241
-
242
- let databaseUrl = guard(
243
- await p.text({
244
- message: `Postgres URL ${chalk.dim('(bring your own pgvector must be installed; the database itself is created for you)')}`,
245
- placeholder: DEFAULT_DB,
246
- defaultValue: DEFAULT_DB,
247
- validate: (v) => (v && !parsePostgresUrl(v) ? 'Expected a postgres:// URL.' : undefined),
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
+ ],
248
388
  }),
249
389
  );
250
- const addr = parsePostgresUrl(databaseUrl);
251
- if (addr && !(await isReachable(addr))) {
252
- // Only a warning: the server may legitimately be starting, or firewalled
253
- // from here but reachable from wherever the app will actually run.
254
- p.log.warn(`Nothing is listening on ${addr.host}:${addr.port} right now — checked again after install.`);
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
+ );
255
474
  }
256
475
 
257
- const apiPort = Number(guard(await p.text({ message: 'API port', placeholder: '3004', defaultValue: '3004', validate: validPort })));
258
- const dashboardPort = Number(
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.
476
+ // ── 4. Gemini
477
+ let geminiKey = '';
278
478
  for (;;) {
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;
279
487
  const s = p.spinner();
280
- s.start('Checking the database');
281
- let result = await checkDatabase(target, databaseUrl);
282
- if (result?.missingDb) {
283
- s.message(`Creating database ${result.missingDb}`);
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');
488
+ s.start('Checking the key');
489
+ const problem = await checkGeminiKey(geminiKey);
490
+ if (!problem) {
491
+ s.stop('Gemini key accepted');
293
492
  break;
294
493
  }
295
- if (result.skipped) {
296
- s.stop(`Database check skipped — ${result.skipped}`, 1);
494
+ s.stop(problem, 1);
495
+ if (
496
+ guard(
497
+ await p.confirm({ message: 'Keep it anyway?', initialValue: false }),
498
+ )
499
+ )
297
500
  break;
298
- }
299
- s.stop(`Database: ${result.problem}`, 1);
300
- if (result.fix) p.log.info(result.fix);
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',
501
+ }
502
+ if (!geminiKey)
503
+ todo.push(
504
+ 'Set GOOGLE_GENERATIVE_AI_API_KEY in .env — the API refuses to start without it',
505
+ );
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: '•',
305
522
  }),
306
523
  ) ?? '';
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();
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
+ );
314
566
  }
567
+ } else {
568
+ todo.push('npm run db:migrate (once the database is ready)');
315
569
  }
316
570
 
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',
589
+ );
590
+ if (todo.length)
591
+ p.note(todo.map((t, i) => `${i + 1}. ${t}`).join('\n'), 'Before you run');
317
592
  p.note(
318
593
  [
319
594
  `cd ${dirName}`,
@@ -323,10 +598,9 @@ async function main() {
323
598
  `API ${chalk.underline(`http://localhost:${apiPort}`)}`,
324
599
  `Login ${adminUser} / ${adminPassword ? 'the password you chose' : 'password printed at first boot'}`,
325
600
  '',
326
- 'First boot applies migrations and prints your API key once.',
327
- ...(geminiKey ? [] : ['Set GOOGLE_GENERATIVE_AI_API_KEY in .env before starting.']),
601
+ 'Then sign in, create an API key, and: npm install @memory-soda/sdk',
328
602
  ].join('\n'),
329
- 'Next steps',
603
+ 'Run',
330
604
  );
331
605
  p.outro(`${chalk.bold(basename(target))} is ready.`);
332
606
  }
@@ -339,7 +613,9 @@ async function main() {
339
613
  function isEntryPoint() {
340
614
  if (!process.argv[1]) return false;
341
615
  try {
342
- return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
616
+ return (
617
+ import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href
618
+ );
343
619
  } catch {
344
620
  return false;
345
621
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-memory-soda",
3
- "version": "0.2.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",
@@ -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
  }