create-memory-soda 0.1.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 +41 -0
  2. package/index.js +326 -0
  3. package/package.json +41 -0
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # create-memory-soda
2
+
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.
5
+
6
+ ```bash
7
+ npm create memory-soda@latest
8
+ ```
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, and verifies the database.
13
+
14
+ ```bash
15
+ cd memory-soda
16
+ npm run dev
17
+ ```
18
+
19
+ First boot applies migrations, creates your admin user, and prints an API key
20
+ once. Dashboard on :3000, API on :3004.
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.
38
+
39
+ ## Requirements
40
+
41
+ Node 20+, git, and a Postgres 14+ with the pgvector extension available.
package/index.js ADDED
@@ -0,0 +1,326 @@
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';
11
+ import { resolve, basename, join } from 'node:path';
12
+ import { createRequire } from 'node:module';
13
+ import { connect } from 'node:net';
14
+ import { stdin, stdout, exit } from 'node:process';
15
+ import { pathToFileURL } from 'node:url';
16
+ import chalk from 'chalk';
17
+
18
+ const REPO = 'https://github.com/alagappan17/memory-soda.git';
19
+ 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}`));
24
+
25
+ function die(msg) {
26
+ console.error(`\n${chalk.red('✗')} ${msg}\n`);
27
+ exit(1);
28
+ }
29
+
30
+ function run(cmd, args, opts = {}) {
31
+ return execFileSync(cmd, args, { stdio: 'inherit', ...opts });
32
+ }
33
+
34
+ /** Run a command quietly, returning stdout — or null if it failed or is missing. */
35
+ function tryRun(cmd, args, opts = {}) {
36
+ try {
37
+ return execFileSync(cmd, args, { encoding: 'utf8', stdio: 'pipe', ...opts });
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Render the .env file. Only the values the installer collects are written
45
+ * explicitly — everything else stays on the defaults baked into config.ts, so
46
+ * this file does not drift every time a new option is added there.
47
+ */
48
+ export function renderEnv({
49
+ databaseUrl,
50
+ geminiKey,
51
+ adminUser,
52
+ adminPassword,
53
+ apiPort,
54
+ dashboardPort,
55
+ }) {
56
+ const lines = [
57
+ '# Generated by create-memory-soda.',
58
+ '# Full reference: https://github.com/alagappan17/memory-soda',
59
+ '',
60
+ '# PostgreSQL — must have the pgvector extension available.',
61
+ `DATABASE_URL=${databaseUrl}`,
62
+ '',
63
+ '# Google Gemini — get a key at https://aistudio.google.com',
64
+ `GOOGLE_GENERATIVE_AI_API_KEY=${geminiKey}`,
65
+ '',
66
+ '# Ports. CORS_ORIGIN must match where the dashboard is served from, and',
67
+ '# NEXT_PUBLIC_API_URL is the browser\'s view of the API — not the server\'s.',
68
+ `PORT=${apiPort}`,
69
+ `DASHBOARD_PORT=${dashboardPort}`,
70
+ `CORS_ORIGIN=http://localhost:${dashboardPort}`,
71
+ `NEXT_PUBLIC_API_URL=http://localhost:${apiPort}`,
72
+ '',
73
+ '# Dashboard login, created once on first boot.',
74
+ `ADMIN_USERNAME=${adminUser}`,
75
+ ];
76
+ // An unset ADMIN_PASSWORD makes the API generate one and print it once. That
77
+ // is a better default than writing a weak chosen password to disk, so a blank
78
+ // answer omits the key entirely rather than writing an empty one.
79
+ if (adminPassword) lines.push(`ADMIN_PASSWORD=${adminPassword}`);
80
+ lines.push('');
81
+ return lines.join('\n');
82
+ }
83
+
84
+ /** True if the path is safe to scaffold into: missing, or an empty directory. */
85
+ export function isUsableTarget(dir) {
86
+ if (!existsSync(dir)) return true;
87
+ return readdirSync(dir).filter((f) => f !== '.DS_Store').length === 0;
88
+ }
89
+
90
+ /**
91
+ * 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.
94
+ */
95
+ export function parsePostgresUrl(url) {
96
+ try {
97
+ const u = new URL(url);
98
+ if (!u.protocol.startsWith('postgres')) return null;
99
+ return { host: u.hostname || 'localhost', port: Number(u.port) || 5432 };
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ /** Is something accepting TCP connections there? Cheap pre-flight, 2s budget. */
106
+ function isReachable({ host, port }, timeout = 2000) {
107
+ return new Promise((done) => {
108
+ const socket = connect({ host, port });
109
+ const finish = (result) => {
110
+ socket.destroy();
111
+ done(result);
112
+ };
113
+ socket.setTimeout(timeout);
114
+ socket.once('connect', () => finish(true));
115
+ socket.once('timeout', () => finish(false));
116
+ socket.once('error', () => finish(false));
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Connect for real and confirm pgvector is installable, using the `pg` copy
122
+ * inside the freshly installed project so the installer needs no database
123
+ * driver of its own. Returns null on success, or an operator-readable problem.
124
+ */
125
+ export async function checkDatabase(target, databaseUrl) {
126
+ let Client;
127
+ try {
128
+ const require = createRequire(pathToFileURL(join(target, 'package.json')));
129
+ ({ Client } = require('pg'));
130
+ } catch {
131
+ return { skipped: 'could not load pg from the new project' };
132
+ }
133
+
134
+ const client = new Client({ connectionString: databaseUrl });
135
+ try {
136
+ await client.connect();
137
+ const { rows } = await client.query(
138
+ "SELECT 1 FROM pg_available_extensions WHERE name = 'vector'",
139
+ );
140
+ if (rows.length === 0) {
141
+ return {
142
+ problem: 'pgvector is not available on this server',
143
+ fix: 'Install it — brew install pgvector, or apt install postgresql-16-pgvector.',
144
+ };
145
+ }
146
+ return null;
147
+ } catch (err) {
148
+ if (err.code === '3D000') {
149
+ const db = decodeURIComponent(new URL(databaseUrl).pathname.slice(1));
150
+ return {
151
+ problem: `database "${db}" does not exist`,
152
+ fix: `createdb ${db}`,
153
+ };
154
+ }
155
+ if (err.code === '28P01' || err.code === '28000') {
156
+ return { problem: 'authentication failed', fix: 'Check the user and password in the URL.' };
157
+ }
158
+ if (err.code === 'ECONNREFUSED') {
159
+ return {
160
+ problem: 'nothing is accepting connections at that host and port',
161
+ fix: 'Start Postgres — brew services start postgresql, or pg_ctl start.',
162
+ };
163
+ }
164
+ return { problem: err.message, fix: null };
165
+ } finally {
166
+ await client.end().catch(() => {});
167
+ }
168
+ }
169
+
170
+ async function main() {
171
+ if (Number(process.versions.node.split('.')[0]) < 20) {
172
+ die(`Node 20 or newer is required — you are on ${process.versions.node}.`);
173
+ }
174
+ if (!tryRun('git', ['--version'])) die('git is required but was not found.');
175
+
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
+ };
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.`);
208
+ }
209
+
210
+ const geminiKey = await ask('Gemini API key');
211
+ if (!geminiKey) note('No key given — set it in .env before starting.');
212
+
213
+ console.log(
214
+ `\n${chalk.bold('Postgres')} ${chalk.dim('— bring your own, with the pgvector extension available')}`,
215
+ );
216
+ let databaseUrl = await ask('DATABASE_URL', DEFAULT_DB);
217
+ 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))) {
221
+ // Only a warning: the server may legitimately be starting, or firewalled
222
+ // 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.`);
227
+ }
228
+
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)')}`,
237
+ );
238
+
239
+ console.log();
240
+ step(`Cloning into ${chalk.bold(dirName)}`);
241
+ run('git', ['clone', '--depth', '1', REPO, target]);
242
+ rmSync(resolve(target, '.git'), { recursive: true, force: true });
243
+
244
+ const writeEnv = () =>
245
+ writeFileSync(
246
+ resolve(target, '.env'),
247
+ renderEnv({
248
+ databaseUrl,
249
+ geminiKey,
250
+ adminUser,
251
+ adminPassword,
252
+ apiPort,
253
+ dashboardPort,
254
+ }),
255
+ );
256
+ writeEnv();
257
+ step('Wrote .env');
258
+
259
+ step(`Installing dependencies ${chalk.dim('(this takes a minute)')}`);
260
+ run('npm', ['install'], { cwd: target });
261
+
262
+ // Now that pg exists in the project, check the database for real. Looping
263
+ // here beats failing at first boot: the fix is usually one command away and
264
+ // the answer is still in front of the user.
265
+ for (;;) {
266
+ step('Checking the database');
267
+ const result = await checkDatabase(target, databaseUrl);
268
+ if (result === null) {
269
+ note('Connected, and pgvector is available.');
270
+ break;
271
+ }
272
+ if (result.skipped) {
273
+ note(`Skipped — ${result.skipped}.`);
274
+ break;
275
+ }
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
+ );
281
+ if (retry.toLowerCase() === 's') {
282
+ note('Skipped — the API will fail on boot until this is resolved.');
283
+ break;
284
+ }
285
+ if (retry) {
286
+ databaseUrl = retry;
287
+ writeEnv();
288
+ }
289
+ }
290
+
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}`)}`,
298
+ );
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();
305
+ }
306
+
307
+ /**
308
+ * 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.
313
+ */
314
+ function isEntryPoint() {
315
+ if (!process.argv[1]) return false;
316
+ try {
317
+ return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
318
+ } catch {
319
+ return false;
320
+ }
321
+ }
322
+
323
+ // Importing this file (tests) must not start an interactive session.
324
+ if (isEntryPoint()) {
325
+ main().catch((err) => die(err.message));
326
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "create-memory-soda",
3
+ "version": "0.1.0",
4
+ "description": "Scaffold a self-hosted memory-soda instance — API, dashboard, and Postgres",
5
+ "keywords": [
6
+ "ai",
7
+ "memory",
8
+ "agent",
9
+ "llm",
10
+ "create",
11
+ "scaffold"
12
+ ],
13
+ "license": "MIT",
14
+ "type": "module",
15
+ "bin": {
16
+ "create-memory-soda": "./index.js"
17
+ },
18
+ "files": [
19
+ "index.js",
20
+ "README.md"
21
+ ],
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "nx": {
29
+ "targets": {
30
+ "test": {
31
+ "executor": "nx:run-commands",
32
+ "options": {
33
+ "command": "node --test packages/create-memory-soda/*.test.js"
34
+ }
35
+ }
36
+ }
37
+ },
38
+ "dependencies": {
39
+ "chalk": "^5.6.2"
40
+ }
41
+ }