create-memory-soda 0.1.1 → 0.2.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 +8 -12
  2. package/index.js +158 -133
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -9,7 +9,9 @@ npm create memory-soda@latest
9
9
 
10
10
  It asks for a folder name, a Gemini API key, your Postgres connection string,
11
11
  which ports to use, and the dashboard admin login. Then it clones the repo,
12
- writes `.env`, installs dependencies, and verifies the database.
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.
13
15
 
14
16
  ```bash
15
17
  cd memory-soda
@@ -21,17 +23,11 @@ once. Dashboard on :3000, API on :3004.
21
23
 
22
24
  ## Postgres
23
25
 
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
- ```
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.
35
31
 
36
32
  The role in the URL needs permission to `CREATE EXTENSION vector` — the first
37
33
  migration creates the extension. On most installations that means a superuser.
package/index.js CHANGED
@@ -1,34 +1,27 @@
1
1
  #!/usr/bin/env node
2
- import { createInterface } from 'node:readline/promises';
3
- import { execFileSync } from 'node:child_process';
4
- import {
5
- existsSync,
6
- readdirSync,
7
- writeFileSync,
8
- rmSync,
9
- realpathSync,
10
- } from 'node:fs';
2
+ import { spawn, execFileSync } from 'node:child_process';
3
+ import { existsSync, readdirSync, writeFileSync, rmSync, realpathSync } from 'node:fs';
11
4
  import { resolve, basename, join } from 'node:path';
12
5
  import { createRequire } from 'node:module';
13
6
  import { connect } from 'node:net';
14
- import { stdin, stdout, exit } from 'node:process';
7
+ import { exit } from 'node:process';
15
8
  import { pathToFileURL } from 'node:url';
9
+ import * as p from '@clack/prompts';
16
10
  import chalk from 'chalk';
17
11
 
18
12
  const REPO = 'https://github.com/alagappan17/memory-soda.git';
19
13
  const DEFAULT_DB = 'postgresql://localhost:5432/memory_db';
20
-
21
- const step = (msg) => console.log(`${chalk.cyan('→')} ${msg}`);
22
- const warn = (msg) => console.log(`${chalk.yellow('!')} ${msg}`);
23
- const note = (msg) => console.log(chalk.dim(` ${msg}`));
14
+ const VERBOSE = process.argv.includes('--verbose');
24
15
 
25
16
  function die(msg) {
26
17
  console.error(`\n${chalk.red('✗')} ${msg}\n`);
27
18
  exit(1);
28
19
  }
29
20
 
30
- function run(cmd, args, opts = {}) {
31
- return execFileSync(cmd, args, { stdio: 'inherit', ...opts });
21
+ /** Bail if the user hit ctrl-c on a prompt. */
22
+ function guard(value) {
23
+ if (p.isCancel(value)) die('Cancelled.');
24
+ return value;
32
25
  }
33
26
 
34
27
  /** Run a command quietly, returning stdout — or null if it failed or is missing. */
@@ -40,19 +33,47 @@ function tryRun(cmd, args, opts = {}) {
40
33
  }
41
34
  }
42
35
 
36
+ /**
37
+ * Run a long command behind a spinner. The spinner shows the command's last
38
+ * output line as it goes; the full log only appears if it fails (or with
39
+ * --verbose, where it streams straight through).
40
+ */
41
+ function runWithSpinner(title, cmd, args, { cwd } = {}) {
42
+ if (VERBOSE) {
43
+ p.log.step(title);
44
+ return new Promise((done, fail) => {
45
+ spawn(cmd, args, { cwd, stdio: 'inherit' }).on('close', (code) =>
46
+ code === 0 ? done() : fail(new Error(`${cmd} exited with ${code}`)),
47
+ );
48
+ });
49
+ }
50
+ const s = p.spinner();
51
+ s.start(title);
52
+ return new Promise((done, fail) => {
53
+ let log = '';
54
+ const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
55
+ const onData = (chunk) => {
56
+ log += chunk;
57
+ const last = String(chunk).trim().split('\n').pop()?.trim();
58
+ if (last) s.message(`${title} ${chalk.dim(last.slice(0, 60))}`);
59
+ };
60
+ child.stdout.on('data', onData);
61
+ child.stderr.on('data', onData);
62
+ child.on('close', (code) => {
63
+ if (code === 0) return s.stop(title) ?? done();
64
+ s.stop(`${title} — failed`, 1);
65
+ 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.`));
67
+ });
68
+ });
69
+ }
70
+
43
71
  /**
44
72
  * Render the .env file. Only the values the installer collects are written
45
73
  * explicitly — everything else stays on the defaults baked into config.ts, so
46
74
  * this file does not drift every time a new option is added there.
47
75
  */
48
- export function renderEnv({
49
- databaseUrl,
50
- geminiKey,
51
- adminUser,
52
- adminPassword,
53
- apiPort,
54
- dashboardPort,
55
- }) {
76
+ export function renderEnv({ databaseUrl, geminiKey, adminUser, adminPassword, apiPort, dashboardPort }) {
56
77
  const lines = [
57
78
  '# Generated by create-memory-soda.',
58
79
  '# Full reference: https://github.com/alagappan17/memory-soda',
@@ -60,11 +81,11 @@ export function renderEnv({
60
81
  '# PostgreSQL — must have the pgvector extension available.',
61
82
  `DATABASE_URL=${databaseUrl}`,
62
83
  '',
63
- '# Google Gemini — get a key at https://aistudio.google.com',
84
+ '# Google Gemini — REQUIRED for extraction and search. Get a key at https://aistudio.google.com',
64
85
  `GOOGLE_GENERATIVE_AI_API_KEY=${geminiKey}`,
65
86
  '',
66
87
  '# Ports. CORS_ORIGIN must match where the dashboard is served from, and',
67
- '# VITE_API_URL is the browser\'s view of the API — not the server\'s.',
88
+ "# VITE_API_URL is the browser's view of the API — not the server's.",
68
89
  `PORT=${apiPort}`,
69
90
  `DASHBOARD_PORT=${dashboardPort}`,
70
91
  `CORS_ORIGIN=http://localhost:${dashboardPort}`,
@@ -89,8 +110,7 @@ export function isUsableTarget(dir) {
89
110
 
90
111
  /**
91
112
  * Pull the host and port out of a Postgres connection string, for the cheap
92
- * reachability probe. Returns null for anything unparseable — a malformed URL
93
- * is the database check's problem to report, not the probe's.
113
+ * reachability probe. Returns null for anything unparseable.
94
114
  */
95
115
  export function parsePostgresUrl(url) {
96
116
  try {
@@ -117,6 +137,26 @@ function isReachable({ host, port }, timeout = 2000) {
117
137
  });
118
138
  }
119
139
 
140
+ function loadPg(target) {
141
+ const require = createRequire(pathToFileURL(join(target, 'package.json')));
142
+ return require('pg').Client;
143
+ }
144
+
145
+ /** 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);
148
+ const url = new URL(databaseUrl);
149
+ const db = decodeURIComponent(url.pathname.slice(1));
150
+ url.pathname = '/postgres';
151
+ const client = new Client({ connectionString: url.href });
152
+ try {
153
+ await client.connect();
154
+ await client.query(`CREATE DATABASE "${db.replaceAll('"', '""')}"`);
155
+ } finally {
156
+ await client.end().catch(() => {});
157
+ }
158
+ }
159
+
120
160
  /**
121
161
  * Connect for real and confirm pgvector is installable, using the `pg` copy
122
162
  * inside the freshly installed project so the installer needs no database
@@ -125,8 +165,7 @@ function isReachable({ host, port }, timeout = 2000) {
125
165
  export async function checkDatabase(target, databaseUrl) {
126
166
  let Client;
127
167
  try {
128
- const require = createRequire(pathToFileURL(join(target, 'package.json')));
129
- ({ Client } = require('pg'));
168
+ Client = loadPg(target);
130
169
  } catch {
131
170
  return { skipped: 'could not load pg from the new project' };
132
171
  }
@@ -134,23 +173,18 @@ export async function checkDatabase(target, databaseUrl) {
134
173
  const client = new Client({ connectionString: databaseUrl });
135
174
  try {
136
175
  await client.connect();
137
- const { rows } = await client.query(
138
- "SELECT 1 FROM pg_available_extensions WHERE name = 'vector'",
139
- );
176
+ const { rows } = await client.query("SELECT 1 FROM pg_available_extensions WHERE name = 'vector'");
140
177
  if (rows.length === 0) {
141
178
  return {
142
179
  problem: 'pgvector is not available on this server',
143
- fix: 'Install it — brew install pgvector, or apt install postgresql-16-pgvector.',
180
+ fix: 'Install it — brew install pgvector, or apt install postgresql-16-pgvector — then retry.',
144
181
  };
145
182
  }
146
183
  return null;
147
184
  } catch (err) {
148
185
  if (err.code === '3D000') {
149
186
  const db = decodeURIComponent(new URL(databaseUrl).pathname.slice(1));
150
- return {
151
- problem: `database "${db}" does not exist`,
152
- fix: `createdb ${db}`,
153
- };
187
+ return { problem: `database "${db}" does not exist`, fix: `createdb ${db}`, missingDb: db };
154
188
  }
155
189
  if (err.code === '28P01' || err.code === '28000') {
156
190
  return { problem: 'authentication failed', fix: 'Check the user and password in the URL.' };
@@ -167,119 +201,111 @@ export async function checkDatabase(target, databaseUrl) {
167
201
  }
168
202
  }
169
203
 
204
+ const validPort = (v) => {
205
+ if (!v) return; // blank takes the default
206
+ const n = Number(v);
207
+ if (!Number.isInteger(n) || n < 1 || n > 65535) return 'Enter a port between 1 and 65535.';
208
+ };
209
+
170
210
  async function main() {
171
211
  if (Number(process.versions.node.split('.')[0]) < 20) {
172
212
  die(`Node 20 or newer is required — you are on ${process.versions.node}.`);
173
213
  }
174
214
  if (!tryRun('git', ['--version'])) die('git is required but was not found.');
175
215
 
176
- console.log(
177
- `\n${chalk.bold('Memory Soda')} ${chalk.dim('self-hosted memory for AI agents')}\n`,
178
- );
179
-
180
- // Read answers off the line iterator rather than rl.question(): with a piped
181
- // stdin, question() consumes only the first line of a buffered chunk and
182
- // drops the rest, so `printf 'a\nb\n' | npm create memory-soda` would stall.
183
- const rl = createInterface({ input: stdin });
184
- const lines = rl[Symbol.asyncIterator]();
185
- const ask = async (q, fallback = '') => {
186
- const hint = fallback ? chalk.dim(` (${fallback})`) : '';
187
- stdout.write(`${q}${hint}: `);
188
- const { value, done } = await lines.next();
189
- // EOF (a script that piped fewer answers than there are questions) takes
190
- // the default rather than hanging.
191
- if (done) stdout.write('\n');
192
- return (value ?? '').trim() || fallback;
193
- };
194
- const askPort = async (q, fallback) => {
195
- for (;;) {
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
- };
216
+ console.log();
217
+ p.intro(`${chalk.bgCyan.black(' Memory Soda ')} ${chalk.dim('self-hosted memory for AI agents')}`);
202
218
 
203
- const dirName = process.argv[2] || (await ask('Folder name', 'memory-soda'));
219
+ const dirArg = process.argv.slice(2).find((a) => !a.startsWith('-'));
220
+ const dirName =
221
+ dirArg ||
222
+ guard(
223
+ await p.text({
224
+ message: 'Where should we create your project?',
225
+ placeholder: 'memory-soda',
226
+ defaultValue: 'memory-soda',
227
+ validate: (v) =>
228
+ isUsableTarget(resolve(process.cwd(), v || 'memory-soda')) ? undefined : 'That folder exists and is not empty.',
229
+ }),
230
+ );
204
231
  const target = resolve(process.cwd(), dirName);
205
- if (!isUsableTarget(target)) {
206
- rl.close();
207
- die(`${target} already exists and is not empty.`);
208
- }
232
+ if (!isUsableTarget(target)) die(`${target} already exists and is not empty.`);
209
233
 
210
- const geminiKey = await ask('Gemini API key');
211
- if (!geminiKey) note('No key given — set it in .env before starting.');
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.');
212
241
 
213
- console.log(
214
- `\n${chalk.bold('Postgres')} ${chalk.dim('— bring your own, with the pgvector extension available')}`,
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),
248
+ }),
215
249
  );
216
- let databaseUrl = await ask('DATABASE_URL', DEFAULT_DB);
217
250
  const addr = parsePostgresUrl(databaseUrl);
218
- if (!addr) {
219
- warn('That does not parse as a postgres:// URL — it is written as given.');
220
- } else if (!(await isReachable(addr))) {
251
+ if (addr && !(await isReachable(addr))) {
221
252
  // Only a warning: the server may legitimately be starting, or firewalled
222
253
  // from here but reachable from wherever the app will actually run.
223
- warn(`Nothing is listening on ${addr.host}:${addr.port} right now.`);
224
- note('Continuing — the connection is checked again after install.');
225
- } else {
226
- note(`${addr.host}:${addr.port} is reachable.`);
254
+ p.log.warn(`Nothing is listening on ${addr.host}:${addr.port} right now — checked again after install.`);
227
255
  }
228
256
 
229
- console.log(`\n${chalk.bold('Ports')}`);
230
- const apiPort = await askPort('API port', 3004);
231
- const dashboardPort = await askPort('Dashboard port', 3000);
232
-
233
- console.log(`\n${chalk.bold('Dashboard login')}`);
234
- const adminUser = await ask('Admin username', 'admin');
235
- const adminPassword = await ask(
236
- `Admin password ${chalk.dim('(blank = generate one)')}`,
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 })),
237
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: '•' })) ?? '';
238
264
 
239
- console.log();
240
- step(`Cloning into ${chalk.bold(dirName)}`);
241
- run('git', ['clone', '--depth', '1', REPO, target]);
265
+ await runWithSpinner(`Cloning into ${chalk.bold(dirName)}`, 'git', ['clone', '--depth', '1', REPO, target]);
242
266
  rmSync(resolve(target, '.git'), { recursive: true, force: true });
243
267
 
244
268
  const writeEnv = () =>
245
- writeFileSync(
246
- resolve(target, '.env'),
247
- renderEnv({
248
- databaseUrl,
249
- geminiKey,
250
- adminUser,
251
- adminPassword,
252
- apiPort,
253
- dashboardPort,
254
- }),
255
- );
269
+ writeFileSync(resolve(target, '.env'), renderEnv({ databaseUrl, geminiKey, adminUser, adminPassword, apiPort, dashboardPort }));
256
270
  writeEnv();
257
- step('Wrote .env');
271
+ p.log.success('Wrote .env');
258
272
 
259
- step(`Installing dependencies ${chalk.dim('(this takes a minute)')}`);
260
- run('npm', ['install'], { cwd: target });
273
+ await runWithSpinner('Installing dependencies', 'npm', ['ci', '--no-audit', '--no-fund'], { cwd: target });
261
274
 
262
275
  // Now that pg exists in the project, check the database for real. Looping
263
276
  // here beats failing at first boot: the fix is usually one command away and
264
277
  // the answer is still in front of the user.
265
278
  for (;;) {
266
- step('Checking the database');
267
- const result = await checkDatabase(target, databaseUrl);
279
+ 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
+ }
268
291
  if (result === null) {
269
- note('Connected, and pgvector is available.');
292
+ s.stop('Database ready — connected, pgvector available');
270
293
  break;
271
294
  }
272
295
  if (result.skipped) {
273
- note(`Skipped — ${result.skipped}.`);
296
+ s.stop(`Database check skipped — ${result.skipped}`, 1);
274
297
  break;
275
298
  }
276
- warn(result.problem);
277
- if (result.fix) note(result.fix);
278
- const retry = await ask(
279
- `Fix it and press enter to retry, or type a new DATABASE_URL ${chalk.dim('(s to skip)')}`,
280
- );
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',
305
+ }),
306
+ ) ?? '';
281
307
  if (retry.toLowerCase() === 's') {
282
- note('Skipped — the API will fail on boot until this is resolved.');
308
+ p.log.warn('Skipped — the API will fail on boot until this is resolved.');
283
309
  break;
284
310
  }
285
311
  if (retry) {
@@ -288,28 +314,27 @@ async function main() {
288
314
  }
289
315
  }
290
316
 
291
- rl.close();
292
- console.log(`\n${chalk.green('✓')} ${chalk.bold(basename(target))} is ready.\n`);
293
- console.log(` cd ${dirName}`);
294
- console.log(` npm run dev\n`);
295
- note('First boot applies migrations and prints your API key once.');
296
- note(
297
- `Dashboard ${chalk.underline(`http://localhost:${dashboardPort}`)} · API ${chalk.underline(`http://localhost:${apiPort}`)}`,
317
+ p.note(
318
+ [
319
+ `cd ${dirName}`,
320
+ 'npm run dev',
321
+ '',
322
+ `Dashboard ${chalk.underline(`http://localhost:${dashboardPort}`)}`,
323
+ `API ${chalk.underline(`http://localhost:${apiPort}`)}`,
324
+ `Login ${adminUser} / ${adminPassword ? 'the password you chose' : 'password printed at first boot'}`,
325
+ '',
326
+ 'First boot applies migrations and prints your API key once.',
327
+ ...(geminiKey ? [] : ['Set GOOGLE_GENERATIVE_AI_API_KEY in .env before starting.']),
328
+ ].join('\n'),
329
+ 'Next steps',
298
330
  );
299
- note(
300
- adminPassword
301
- ? `Log in as ${adminUser} with the password you chose.`
302
- : `Log in as ${adminUser} — the generated password is printed at first boot.`,
303
- );
304
- console.log();
331
+ p.outro(`${chalk.bold(basename(target))} is ready.`);
305
332
  }
306
333
 
307
334
  /**
308
335
  * True when this file is the process entry point, false when it is imported.
309
- *
310
- * argv[1] must be resolved through realpath first: npm and npx invoke the bin
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.
336
+ * argv[1] is resolved through realpath: npm and npx invoke the bin as a
337
+ * symlink in node_modules/.bin, while import.meta.url is the real path.
313
338
  */
314
339
  function isEntryPoint() {
315
340
  if (!process.argv[1]) return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-memory-soda",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Scaffold a self-hosted Memory Soda instance — API, dashboard, and Postgres",
5
5
  "keywords": [
6
6
  "ai",
@@ -36,6 +36,7 @@
36
36
  }
37
37
  },
38
38
  "dependencies": {
39
+ "@clack/prompts": "^1.7.0",
39
40
  "chalk": "^5.6.2"
40
41
  }
41
42
  }