@upend/cli 0.1.0 → 0.1.1

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 CHANGED
@@ -16,7 +16,7 @@ Bun + Hono + Neon Postgres + Caddy. Custom JWT auth. Claude editing sessions wit
16
16
 
17
17
  ```bash
18
18
  # create a new project
19
- bunx @upend/cli init my-app
19
+ bunx @upend/cli init my-project
20
20
 
21
21
  # follow the prompts — if neonctl is installed, it will:
22
22
  # 1. create a Neon database
@@ -25,7 +25,7 @@ bunx @upend/cli init my-app
25
25
  # 4. generate RSA signing keys
26
26
  # 5. encrypt your .env with dotenvx
27
27
 
28
- cd my-app
28
+ cd my-project
29
29
 
30
30
  # add your Anthropic API key
31
31
  # (edit .env, then re-encrypt)
@@ -44,7 +44,7 @@ Open http://localhost:4000 — you'll see the dashboard.
44
44
  ## What you get
45
45
 
46
46
  ```
47
- my-app/
47
+ my-project/
48
48
  ├── apps/ → hot-deployed frontends (drop files in, they're live)
49
49
  ├── migrations/
50
50
  │ └── 001_init.sql → starter migration
@@ -211,7 +211,7 @@ bunx upend setup:jwks
211
211
  import { defineConfig } from "@upend/cli";
212
212
 
213
213
  export default defineConfig({
214
- name: "my-app",
214
+ name: "my-project",
215
215
  database: process.env.DATABASE_URL,
216
216
  dataApi: process.env.NEON_DATA_API,
217
217
  deploy: {
package/bin/cli.ts CHANGED
@@ -9,6 +9,7 @@ const commands: Record<string, () => Promise<void>> = {
9
9
  deploy: () => import("../src/commands/deploy").then((m) => m.default(args.slice(1))),
10
10
  migrate: () => import("../src/commands/migrate").then((m) => m.default(args.slice(1))),
11
11
  infra: () => import("../src/commands/infra").then((m) => m.default(args.slice(1))),
12
+ env: () => import("../src/commands/env").then((m) => m.default(args.slice(1))),
12
13
  };
13
14
 
14
15
  if (!command || command === "--help" || command === "-h") {
@@ -20,8 +21,8 @@ if (!command || command === "--help" || command === "-h") {
20
21
  upend dev start local dev (services + caddy)
21
22
  upend deploy deploy to remote instance
22
23
  upend migrate run database migrations
24
+ upend env:set <K> <V> set an env var (decrypts, sets, re-encrypts)
23
25
  upend infra:aws provision AWS infrastructure
24
- upend infra:gcp provision GCP infrastructure
25
26
 
26
27
  options:
27
28
  --help, -h show this help
@@ -36,8 +37,8 @@ if (command === "--version" || command === "-v") {
36
37
  process.exit(0);
37
38
  }
38
39
 
39
- // handle infra:provider syntax
40
- const cmd = command.startsWith("infra:") ? "infra" : command;
40
+ // handle colon syntax: infra:aws, env:set
41
+ const cmd = command.startsWith("infra:") ? "infra" : command.startsWith("env:") ? "env" : command;
41
42
 
42
43
  if (!commands[cmd]) {
43
44
  console.error(`unknown command: ${command}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upend/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Anti-SaaS stack. Deploy live apps with Claude, Postgres, and rsync.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,8 @@ export default async function dev(args: string[]) {
23
23
 
24
24
  // start API service
25
25
  log.info(`starting api → :${apiPort}`);
26
- Bun.spawn(["bun", "--watch", `${cliRoot}/src/services/gateway/index.ts`], {
26
+ Bun.spawn(["bunx", "@dotenvx/dotenvx", "run", "--", "bun", "--watch", `${cliRoot}/src/services/gateway/index.ts`], {
27
+ cwd: projectDir,
27
28
  env: { ...process.env, API_PORT: apiPort, UPEND_PROJECT: projectDir },
28
29
  stdout: "inherit",
29
30
  stderr: "inherit",
@@ -31,7 +32,8 @@ export default async function dev(args: string[]) {
31
32
 
32
33
  // start Claude service
33
34
  log.info(`starting claude → :${claudePort}`);
34
- Bun.spawn(["bun", "--watch", `${cliRoot}/src/services/claude/index.ts`], {
35
+ Bun.spawn(["bunx", "@dotenvx/dotenvx", "run", "--", "bun", "--watch", `${cliRoot}/src/services/claude/index.ts`], {
36
+ cwd: projectDir,
35
37
  env: { ...process.env, CLAUDE_PORT: claudePort, UPEND_PROJECT: projectDir },
36
38
  stdout: "inherit",
37
39
  stderr: "inherit",
@@ -0,0 +1,35 @@
1
+ import { log } from "../lib/log";
2
+ import { exec } from "../lib/exec";
3
+ import { readFileSync, writeFileSync } from "fs";
4
+
5
+ export default async function env(args: string[]) {
6
+ const [key, value] = args;
7
+
8
+ if (!key || !value) {
9
+ log.error("usage: upend env:set <KEY> <VALUE>");
10
+ log.dim(" e.g. upend env:set ANTHROPIC_API_KEY sk-ant-...");
11
+ process.exit(1);
12
+ }
13
+
14
+ // decrypt
15
+ log.info("decrypting .env...");
16
+ await exec(["bunx", "@dotenvx/dotenvx", "decrypt"], { silent: true });
17
+
18
+ // read, update, write
19
+ const envFile = readFileSync(".env", "utf-8");
20
+ const regex = new RegExp(`^${key}=.*$`, "m");
21
+
22
+ let updated: string;
23
+ if (regex.test(envFile)) {
24
+ updated = envFile.replace(regex, `${key}="${value}"`);
25
+ } else {
26
+ updated = envFile.trimEnd() + `\n${key}="${value}"\n`;
27
+ }
28
+ writeFileSync(".env", updated);
29
+ log.success(`${key} set`);
30
+
31
+ // re-encrypt
32
+ log.info("encrypting .env...");
33
+ await exec(["bunx", "@dotenvx/dotenvx", "encrypt"], { silent: true });
34
+ log.success(".env encrypted");
35
+ }
@@ -1,16 +1,20 @@
1
1
  import { log } from "../lib/log";
2
2
  import { exec, execOrDie, hasCommand } from "../lib/exec";
3
- import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs";
3
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "fs";
4
4
  import { join, resolve } from "path";
5
5
 
6
6
  export default async function init(args: string[]) {
7
7
  const name = args[0];
8
8
  if (!name) {
9
- log.error("usage: upend init <name>");
9
+ log.error("usage: upend init <name> [--admin-email <email> --admin-password <pass>]");
10
10
  log.dim(" e.g. upend init beta → deploys to beta.upend.site");
11
11
  process.exit(1);
12
12
  }
13
13
 
14
+ // parse optional flags
15
+ const adminEmail = getFlag(args, "--admin-email");
16
+ const adminPassword = getFlag(args, "--admin-password");
17
+
14
18
  const projectDir = resolve(name);
15
19
  const domain = `${name}.upend.site`;
16
20
 
@@ -46,7 +50,14 @@ export default async function init(args: string[]) {
46
50
  let neonDataApi = "";
47
51
  let neonProjectId = "";
48
52
 
49
- if (await hasCommand("neonctl")) {
53
+ if (!(await hasCommand("neonctl"))) {
54
+ log.error("neonctl is required — install with: npm i -g neonctl");
55
+ log.dim("upend currently requires Neon Postgres. More database support coming soon.");
56
+ rmSync(projectDir, { recursive: true, force: true });
57
+ process.exit(1);
58
+ }
59
+
60
+ {
50
61
  // check auth
51
62
  const { exitCode } = await exec(["neonctl", "me"], { silent: true });
52
63
  if (exitCode !== 0) {
@@ -125,21 +136,16 @@ export default async function init(args: string[]) {
125
136
  log.warn("couldn't read neon API token — data API needs manual setup");
126
137
  }
127
138
  }
128
- } else {
129
- log.warn("neonctl not found — install with: npm i -g neonctl");
130
- log.dim("then re-run: upend init " + name);
131
- log.dim("or set DATABASE_URL manually in .env");
132
139
  }
133
140
 
134
141
  // ── 3. scaffold project files ──
135
142
 
136
143
  log.info("scaffolding project...");
137
144
 
138
- // resolve @upend/cli dependency
145
+ // resolve @upend/cli version from our own package.json
139
146
  const cliPkgPath = new URL("../../package.json", import.meta.url).pathname;
140
147
  const cliPkg = JSON.parse(readFileSync(cliPkgPath, "utf-8"));
141
- const cliRoot = join(cliPkgPath, "..");
142
- const cliDep = cliPkg.version === "0.1.0" ? `file:${cliRoot}` : `^${cliPkg.version}`;
148
+ const cliDep = `^${cliPkg.version}`;
143
149
 
144
150
  writeFile(projectDir, "upend.config.ts", `import { defineConfig } from "@upend/cli";
145
151
 
@@ -175,6 +181,7 @@ ANTHROPIC_API_KEY=
175
181
  DEPLOY_HOST=
176
182
  API_PORT=3001
177
183
  CLAUDE_PORT=3002
184
+ SIGNUP_ENABLED=false
178
185
  `);
179
186
 
180
187
  writeFile(projectDir, ".env.example", `DATABASE_URL=postgresql://user:pass@host/db?sslmode=require
@@ -195,11 +202,12 @@ sessions/
195
202
 
196
203
  // migrations
197
204
  mkdirSync(join(projectDir, "migrations"), { recursive: true });
198
- writeFile(projectDir, "migrations/001_init.sql", `-- your first migration
199
- CREATE TABLE IF NOT EXISTS example (
200
- id BIGSERIAL PRIMARY KEY,
201
- name TEXT NOT NULL,
202
- data JSONB DEFAULT '{}',
205
+ writeFile(projectDir, "migrations/001_users.sql", `-- users table (exposed via Data API)
206
+ CREATE TABLE IF NOT EXISTS users (
207
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
208
+ email TEXT UNIQUE NOT NULL,
209
+ password_hash TEXT NOT NULL,
210
+ role TEXT DEFAULT 'user',
203
211
  created_at TIMESTAMPTZ DEFAULT now(),
204
212
  updated_at TIMESTAMPTZ DEFAULT now()
205
213
  );
@@ -269,18 +277,72 @@ All requests need \`Authorization: Bearer <jwt>\` header.
269
277
  await execOrDie(["bun", "install"], { cwd: projectDir });
270
278
  log.success("dependencies installed");
271
279
 
280
+ // ── 7. bootstrap DB + create admin ──
281
+
282
+ if (databaseUrl) {
283
+ // run migrations (bootstrap + user's 001_init.sql which creates users table)
284
+ log.info("running migrations...");
285
+ await exec(["bunx", "@dotenvx/dotenvx", "run", "--", "bunx", "upend", "migrate"], { cwd: projectDir });
286
+ log.success("database ready");
287
+
288
+ // prompt: create admin or enable signup?
289
+ log.blank();
290
+ process.stdout.write(" create an admin user now? (Y/n): ");
291
+ const answer = (await readLine()).trim().toLowerCase();
292
+
293
+ if (answer === "n" || answer === "no") {
294
+ // enable signup so they can create accounts from the dashboard
295
+ log.info("enabling public signup...");
296
+ await setEnvVar(projectDir, "SIGNUP_ENABLED", "true");
297
+ log.success("signup enabled — anyone can create an account");
298
+ } else {
299
+ // create admin user
300
+ let email = adminEmail;
301
+ let password = adminPassword;
302
+
303
+ if (!email) {
304
+ process.stdout.write(" admin email: ");
305
+ email = (await readLine()).trim();
306
+ }
307
+ if (!password) {
308
+ process.stdout.write(" admin password: ");
309
+ password = (await readLine()).trim();
310
+ }
311
+
312
+ if (email && password) {
313
+ log.info("creating admin user...");
314
+ const postgres = (await import("postgres")).default;
315
+ const sql = postgres(databaseUrl, { max: 1 });
316
+ const passwordHash = await Bun.password.hash(password, { algorithm: "argon2id" });
317
+ try {
318
+ const [user] = await sql`
319
+ INSERT INTO users (email, password_hash, role)
320
+ VALUES (${email}, ${passwordHash}, 'admin')
321
+ RETURNING id, email, role
322
+ `;
323
+ log.success(`admin: ${user.email}`);
324
+ } catch (err: any) {
325
+ if (err.code === "23505") {
326
+ log.warn("admin user already exists");
327
+ } else {
328
+ log.warn(`could not create admin: ${err.message}`);
329
+ }
330
+ }
331
+ await sql.end();
332
+ }
333
+ }
334
+ }
335
+
272
336
  // ── done ──
273
337
 
274
338
  log.blank();
275
339
  log.header(`${name} is ready`);
276
340
  log.blank();
277
341
  log.info(`cd ${name}`);
278
- if (!databaseUrl) {
279
- log.info("# add your DATABASE_URL to .env");
280
- }
281
342
  if (!process.env.ANTHROPIC_API_KEY) {
282
- log.info("# add your ANTHROPIC_API_KEY to .env");
343
+ log.info("upend env:set ANTHROPIC_API_KEY <your-key>");
283
344
  }
345
+ log.info("upend migrate");
284
346
  log.info("upend dev");
285
347
  log.blank();
286
348
  if (databaseUrl) {
@@ -314,6 +376,34 @@ function writeFile(dir: string, path: string, content: string) {
314
376
  writeFileSync(fullPath, content);
315
377
  }
316
378
 
379
+ async function setEnvVar(projectDir: string, key: string, value: string) {
380
+ await exec(["bunx", "@dotenvx/dotenvx", "decrypt"], { cwd: projectDir, silent: true });
381
+ const envFile = readFileSync(join(projectDir, ".env"), "utf-8");
382
+ const regex = new RegExp(`^${key}=.*$`, "m");
383
+ const updated = regex.test(envFile)
384
+ ? envFile.replace(regex, `${key}=${value}`)
385
+ : envFile.trimEnd() + `\n${key}=${value}\n`;
386
+ writeFileSync(join(projectDir, ".env"), updated);
387
+ await exec(["bunx", "@dotenvx/dotenvx", "encrypt"], { cwd: projectDir, silent: true });
388
+ }
389
+
390
+ function getFlag(args: string[], flag: string): string | undefined {
391
+ const idx = args.indexOf(flag);
392
+ return idx !== -1 && args[idx + 1] ? args[idx + 1] : undefined;
393
+ }
394
+
395
+ function readLine(): Promise<string> {
396
+ return new Promise((resolve) => {
397
+ const stdin = process.stdin;
398
+ stdin.resume();
399
+ stdin.setEncoding("utf-8");
400
+ stdin.once("data", (data: string) => {
401
+ stdin.pause();
402
+ resolve(data);
403
+ });
404
+ });
405
+ }
406
+
317
407
  async function exportKeyToPem(key: CryptoKey, type: "PRIVATE" | "PUBLIC") {
318
408
  const format = type === "PRIVATE" ? "pkcs8" : "spki";
319
409
  const exported = await crypto.subtle.exportKey(format, key);
@@ -2,13 +2,15 @@ import { log } from "../lib/log";
2
2
  import { readdirSync, readFileSync } from "fs";
3
3
  import { join, resolve } from "path";
4
4
 
5
+ // bootstrap SQL lives in the package — framework tables
6
+ const bootstrapPath = new URL("../lib/bootstrap.sql", import.meta.url).pathname;
7
+
5
8
  export default async function migrate(args: string[]) {
6
9
  const projectDir = resolve(".");
7
10
  const migrationsDir = join(projectDir, "migrations");
8
11
 
9
12
  log.header("running migrations");
10
13
 
11
- // dynamic import postgres from the project's node_modules
12
14
  const postgres = (await import("postgres")).default;
13
15
  const sql = postgres(process.env.DATABASE_URL!, {
14
16
  max: 1,
@@ -16,14 +18,13 @@ export default async function migrate(args: string[]) {
16
18
  });
17
19
 
18
20
  try {
19
- // ensure migrations table
20
- await sql`
21
- CREATE TABLE IF NOT EXISTS _migrations (
22
- name TEXT PRIMARY KEY,
23
- ran_at TIMESTAMPTZ DEFAULT now()
24
- )
25
- `;
21
+ // 1. bootstrap framework tables (idempotent)
22
+ log.info("bootstrapping framework tables...");
23
+ const bootstrap = readFileSync(bootstrapPath, "utf-8");
24
+ await sql.unsafe(bootstrap);
25
+ log.success("framework tables ready");
26
26
 
27
+ // 2. run user migrations
27
28
  const ran = new Set(
28
29
  (await sql`SELECT name FROM _migrations`).map((r: any) => r.name)
29
30
  );
@@ -35,7 +36,8 @@ export default async function migrate(args: string[]) {
35
36
  .sort();
36
37
  } catch {
37
38
  log.warn("no migrations directory found");
38
- process.exit(0);
39
+ await sql.end();
40
+ return;
39
41
  }
40
42
 
41
43
  let count = 0;
@@ -0,0 +1,43 @@
1
+ -- upend framework tables — created automatically, not user-managed
2
+
3
+ -- internal schema (hidden from Data API)
4
+ CREATE SCHEMA IF NOT EXISTS upend;
5
+
6
+ -- migration tracking (public, but prefixed with _ so Data API ignores it)
7
+ CREATE TABLE IF NOT EXISTS _migrations (
8
+ id SERIAL PRIMARY KEY,
9
+ name TEXT UNIQUE NOT NULL,
10
+ applied_at TIMESTAMPTZ DEFAULT now()
11
+ );
12
+
13
+ -- oauth state tracking — internal
14
+ CREATE TABLE IF NOT EXISTS upend.oauth_states (
15
+ id SERIAL PRIMARY KEY,
16
+ state TEXT UNIQUE NOT NULL,
17
+ provider TEXT NOT NULL,
18
+ created_at TIMESTAMPTZ DEFAULT now()
19
+ );
20
+
21
+ -- claude editing sessions — internal
22
+ CREATE TABLE IF NOT EXISTS upend.editing_sessions (
23
+ id BIGSERIAL PRIMARY KEY,
24
+ prompt TEXT NOT NULL,
25
+ title TEXT,
26
+ status TEXT DEFAULT 'active',
27
+ claude_session_id TEXT,
28
+ snapshot_name TEXT,
29
+ context JSONB DEFAULT '{}',
30
+ created_at TIMESTAMPTZ DEFAULT now(),
31
+ updated_at TIMESTAMPTZ DEFAULT now()
32
+ );
33
+
34
+ -- session messages — internal
35
+ CREATE TABLE IF NOT EXISTS upend.session_messages (
36
+ id BIGSERIAL PRIMARY KEY,
37
+ session_id BIGINT REFERENCES upend.editing_sessions(id),
38
+ role TEXT NOT NULL,
39
+ content TEXT,
40
+ result TEXT,
41
+ status TEXT DEFAULT 'pending',
42
+ created_at TIMESTAMPTZ DEFAULT now()
43
+ );
package/src/lib/db.ts CHANGED
@@ -5,6 +5,7 @@ export const sql = postgres(process.env.DATABASE_URL!, {
5
5
  idle_timeout: 20,
6
6
  connect_timeout: 10,
7
7
  transform: postgres.camel,
8
+ connection: { search_path: "public,upend" },
8
9
  onnotice: (notice) => console.log("pg:", notice.message),
9
10
  });
10
11
 
@@ -10,8 +10,12 @@ authRoutes.get("/.well-known/jwks.json", async (c) => {
10
10
  return c.json(jwks);
11
11
  });
12
12
 
13
- // signup
13
+ // signup (disabled by default — admin creates users, or set SIGNUP_ENABLED=true)
14
14
  authRoutes.post("/auth/signup", async (c) => {
15
+ if (process.env.SIGNUP_ENABLED !== "true") {
16
+ return c.json({ error: "signup is disabled — contact the admin" }, 403);
17
+ }
18
+
15
19
  const { email, password, role } = await c.req.json();
16
20
  console.log(`[auth] signup attempt: ${email}`);
17
21
  if (!email || !password) return c.json({ error: "email and password required" }, 400);