@upend/cli 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.
package/README.md ADDED
@@ -0,0 +1,231 @@
1
+ # upend
2
+
3
+ Anti-SaaS stack. Your code, your server, your database. Deploy via rsync. Edit live with Claude.
4
+
5
+ Bun + Hono + Neon Postgres + Caddy. Custom JWT auth. Claude editing sessions with git worktree isolation. Hot-deployed frontend apps.
6
+
7
+ ## Prerequisites
8
+
9
+ - [Bun](https://bun.sh) — `curl -fsSL https://bun.sh/install | bash`
10
+ - [Caddy](https://caddyserver.com) — `brew install caddy`
11
+ - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) — `npm i -g @anthropic-ai/claude-code`
12
+ - A [Neon](https://neon.tech) account (free tier works)
13
+ - Optionally: [neonctl](https://neon.tech/docs/reference/neon-cli) — `npm i -g neonctl` (automates DB setup)
14
+
15
+ ## Quickstart
16
+
17
+ ```bash
18
+ # create a new project
19
+ bunx @upend/cli init my-app
20
+
21
+ # follow the prompts — if neonctl is installed, it will:
22
+ # 1. create a Neon database
23
+ # 2. enable the Data API (PostgREST)
24
+ # 3. configure JWKS for JWT auth
25
+ # 4. generate RSA signing keys
26
+ # 5. encrypt your .env with dotenvx
27
+
28
+ cd my-app
29
+
30
+ # add your Anthropic API key
31
+ # (edit .env, then re-encrypt)
32
+ vi .env
33
+ bunx @dotenvx/dotenvx encrypt
34
+
35
+ # run migrations
36
+ bunx upend migrate
37
+
38
+ # start dev
39
+ bunx upend dev
40
+ ```
41
+
42
+ Open http://localhost:4000 — you'll see the dashboard.
43
+
44
+ ## What you get
45
+
46
+ ```
47
+ my-app/
48
+ ├── apps/ → hot-deployed frontends (drop files in, they're live)
49
+ ├── migrations/
50
+ │ └── 001_init.sql → starter migration
51
+ ├── services/ → custom Hono services (optional)
52
+ ├── upend.config.ts → project config
53
+ ├── CLAUDE.md → instructions for Claude editing sessions
54
+ ├── .env → encrypted credentials (safe to commit)
55
+ ├── .env.keys → decryption keys (gitignored)
56
+ ├── .keys/ → JWT signing keys (gitignored)
57
+ └── package.json
58
+ ```
59
+
60
+ ## URLs
61
+
62
+ Everything runs through Caddy at `:4000`:
63
+
64
+ | URL | What |
65
+ |-----|------|
66
+ | `http://localhost:4000` | Dashboard — chat with Claude, browse data, manage apps |
67
+ | `/api/auth/signup` | Create account — `POST {email, password}` → `{user, token}` |
68
+ | `/api/auth/login` | Login — `POST {email, password}` → `{user, token}` |
69
+ | `/.well-known/jwks.json` | Public keys for JWT verification |
70
+ | `/apps/<name>/` | Your apps, served from the filesystem |
71
+
72
+ ## Auth
73
+
74
+ Sign up:
75
+
76
+ ```bash
77
+ curl -X POST http://localhost:4000/api/auth/signup \
78
+ -H 'Content-Type: application/json' \
79
+ -d '{"email":"you@example.com","password":"yourpassword"}'
80
+ # → { user: { id, email }, token: "eyJ..." }
81
+ ```
82
+
83
+ Use the token everywhere:
84
+
85
+ ```bash
86
+ TOKEN="eyJ..."
87
+ curl http://localhost:4000/api/data/example \
88
+ -H "Authorization: Bearer $TOKEN"
89
+ ```
90
+
91
+ ## Data API
92
+
93
+ Your tables are automatically available as REST endpoints via Neon's Data API:
94
+
95
+ ```bash
96
+ # list rows
97
+ curl /api/data/example?order=created_at.desc \
98
+ -H "Authorization: Bearer $TOKEN"
99
+
100
+ # create
101
+ curl -X POST /api/data/example \
102
+ -H "Authorization: Bearer $TOKEN" \
103
+ -H 'Content-Type: application/json' \
104
+ -H 'Prefer: return=representation' \
105
+ -d '{"name":"hello","data":{"key":"value"}}'
106
+
107
+ # update
108
+ curl -X PATCH '/api/data/example?id=eq.5' \
109
+ -H "Authorization: Bearer $TOKEN" \
110
+ -H 'Content-Type: application/json' \
111
+ -d '{"name":"updated"}'
112
+
113
+ # delete
114
+ curl -X DELETE '/api/data/example?id=eq.5' \
115
+ -H "Authorization: Bearer $TOKEN"
116
+ ```
117
+
118
+ PostgREST filter operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`, `is`, `in`, `not`.
119
+
120
+ ## Migrations
121
+
122
+ Plain SQL files in `migrations/`, numbered sequentially:
123
+
124
+ ```bash
125
+ # create a migration
126
+ cat > migrations/002_projects.sql << 'SQL'
127
+ CREATE TABLE projects (
128
+ id BIGSERIAL PRIMARY KEY,
129
+ name TEXT NOT NULL,
130
+ owner_id UUID REFERENCES users(id),
131
+ created_at TIMESTAMPTZ DEFAULT now()
132
+ );
133
+ SQL
134
+
135
+ # run it
136
+ bunx upend migrate
137
+ ```
138
+
139
+ Or tell Claude in the dashboard: *"add a projects table with name and owner"* — it'll create the migration and run it.
140
+
141
+ ## Apps
142
+
143
+ Apps are static files in `apps/<name>/`. No build step. Drop files in, they're instantly live at `/apps/<name>/`.
144
+
145
+ From the dashboard, tell Claude: *"build a todo app"* — it creates the files in a git worktree, you preview them, then publish to live.
146
+
147
+ Apps can call the API at the same origin:
148
+
149
+ ```js
150
+ const token = localStorage.getItem('upend_token');
151
+ const res = await fetch('/api/data/projects?order=created_at.desc', {
152
+ headers: { 'Authorization': `Bearer ${token}` }
153
+ });
154
+ const projects = await res.json();
155
+ ```
156
+
157
+ ## Editing with Claude
158
+
159
+ The dashboard at `/` has a built-in chat. Each conversation creates an isolated git worktree — Claude edits files there, you preview the changes, then click **Publish** to merge into live.
160
+
161
+ If something breaks, close the session without publishing. Your live code is untouched.
162
+
163
+ ## Deploy
164
+
165
+ ### Provision infrastructure
166
+
167
+ ```bash
168
+ # provision an EC2 instance (t4g.small, Amazon Linux 2023)
169
+ bunx upend infra:aws
170
+
171
+ # this creates:
172
+ # - EC2 instance with Bun, Node, Caddy, Claude Code
173
+ # - security group (ports 22, 80, 443)
174
+ # - SSH key pair
175
+ # - SSH config entry: "ssh upend"
176
+ ```
177
+
178
+ ### Deploy your code
179
+
180
+ ```bash
181
+ # set your deploy target in .env
182
+ DEPLOY_HOST=ec2-user@<ip>
183
+
184
+ # deploy (rsync → install → migrate → restart)
185
+ bunx upend deploy
186
+ ```
187
+
188
+ ### Register JWKS (after first deploy)
189
+
190
+ Neon needs to reach your JWKS URL to validate JWTs for the Data API. After your first deploy, when your domain is live:
191
+
192
+ ```bash
193
+ bunx upend setup:jwks
194
+ ```
195
+
196
+ ## CLI Commands
197
+
198
+ | Command | What |
199
+ |---------|------|
200
+ | `upend init <name>` | Scaffold a new project (creates Neon DB, generates keys, encrypts env) |
201
+ | `upend dev` | Start gateway + claude + caddy locally |
202
+ | `upend migrate` | Run SQL migrations from `migrations/` |
203
+ | `upend deploy` | rsync to remote, install, migrate, restart |
204
+ | `upend infra:aws` | Provision an EC2 instance |
205
+
206
+ ## Config
207
+
208
+ `upend.config.ts`:
209
+
210
+ ```ts
211
+ import { defineConfig } from "@upend/cli";
212
+
213
+ export default defineConfig({
214
+ name: "my-app",
215
+ database: process.env.DATABASE_URL,
216
+ dataApi: process.env.NEON_DATA_API,
217
+ deploy: {
218
+ host: process.env.DEPLOY_HOST,
219
+ dir: "/opt/upend",
220
+ },
221
+ });
222
+ ```
223
+
224
+ ## Philosophy
225
+
226
+ - **One server per customer.** Vertical scaling. No multi-tenant complexity.
227
+ - **No git workflows.** Claude edits live (in a worktree). Publish when ready.
228
+ - **No CI/CD.** `rsync --delete` is the deploy.
229
+ - **No build step.** Bun runs TypeScript directly. Apps are static files.
230
+ - **Encrypted env.** `.env` is encrypted with dotenvx — safe to commit. `.env.keys` is gitignored.
231
+ - **Snapshots, not rollback strategies.** Before any change, snapshot files + database. Undo = restore.
package/bin/cli.ts ADDED
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env bun
2
+
3
+ const args = process.argv.slice(2);
4
+ const command = args[0];
5
+
6
+ const commands: Record<string, () => Promise<void>> = {
7
+ init: () => import("../src/commands/init").then((m) => m.default(args.slice(1))),
8
+ dev: () => import("../src/commands/dev").then((m) => m.default(args.slice(1))),
9
+ deploy: () => import("../src/commands/deploy").then((m) => m.default(args.slice(1))),
10
+ migrate: () => import("../src/commands/migrate").then((m) => m.default(args.slice(1))),
11
+ infra: () => import("../src/commands/infra").then((m) => m.default(args.slice(1))),
12
+ };
13
+
14
+ if (!command || command === "--help" || command === "-h") {
15
+ console.log(`
16
+ upend — anti-SaaS stack
17
+
18
+ usage:
19
+ upend init <name> scaffold a new project
20
+ upend dev start local dev (services + caddy)
21
+ upend deploy deploy to remote instance
22
+ upend migrate run database migrations
23
+ upend infra:aws provision AWS infrastructure
24
+ upend infra:gcp provision GCP infrastructure
25
+
26
+ options:
27
+ --help, -h show this help
28
+ --version, -v show version
29
+ `);
30
+ process.exit(0);
31
+ }
32
+
33
+ if (command === "--version" || command === "-v") {
34
+ const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json();
35
+ console.log(pkg.version);
36
+ process.exit(0);
37
+ }
38
+
39
+ // handle infra:provider syntax
40
+ const cmd = command.startsWith("infra:") ? "infra" : command;
41
+
42
+ if (!commands[cmd]) {
43
+ console.error(`unknown command: ${command}`);
44
+ console.error(`run 'upend --help' for usage`);
45
+ process.exit(1);
46
+ }
47
+
48
+ await commands[cmd]();
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@upend/cli",
3
+ "version": "0.1.0",
4
+ "description": "Anti-SaaS stack. Deploy live apps with Claude, Postgres, and rsync.",
5
+ "type": "module",
6
+ "bin": {
7
+ "upend": "./bin/cli.ts"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "templates"
13
+ ],
14
+ "keywords": ["cli", "deploy", "postgres", "claude", "rsync", "caddy", "bun"],
15
+ "author": "cif",
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/cif/upend"
20
+ },
21
+ "dependencies": {
22
+ "hono": "^4.12.8",
23
+ "jose": "^6.2.1",
24
+ "postgres": "^3.4.8"
25
+ }
26
+ }
@@ -0,0 +1,67 @@
1
+ import { log } from "../lib/log";
2
+ import { exec, execOrDie, hasCommand } from "../lib/exec";
3
+ import { resolve } from "path";
4
+
5
+ export default async function deploy(args: string[]) {
6
+ const projectDir = resolve(".");
7
+ const host = process.env.DEPLOY_HOST;
8
+ const sshKey = process.env.DEPLOY_SSH_KEY || `${process.env.HOME}/.ssh/upend.pem`;
9
+
10
+ if (!host) {
11
+ log.error("DEPLOY_HOST not set. Add it to .env (e.g. ec2-user@1.2.3.4)");
12
+ process.exit(1);
13
+ }
14
+
15
+ const appDir = process.env.DEPLOY_DIR || "/opt/upend";
16
+ const ssh = (cmd: string) => execOrDie(["ssh", "-i", sshKey, host, cmd]);
17
+
18
+ log.header(`deploying to ${host}`);
19
+
20
+ // step 1: stop services
21
+ log.info("stopping services...");
22
+ await ssh("pkill -f 'bun services/' 2>/dev/null || true; sudo pkill caddy 2>/dev/null || true; sleep 1");
23
+ log.success("services stopped");
24
+
25
+ // step 2: full rsync
26
+ log.info("pushing files...");
27
+ await ssh(`sudo mkdir -p ${appDir} && sudo chown $(whoami):$(whoami) ${appDir}`);
28
+ await execOrDie([
29
+ "rsync", "-azP", "--delete",
30
+ "--exclude", "node_modules",
31
+ "--exclude", ".env.keys",
32
+ "--exclude", ".keys",
33
+ "--exclude", ".snapshots",
34
+ "--exclude", ".git",
35
+ "--exclude", "sessions",
36
+ "-e", `ssh -i ${sshKey}`,
37
+ "./", `${host}:${appDir}/`,
38
+ ]);
39
+ log.success("files pushed");
40
+
41
+ // step 3: sync secrets
42
+ log.info("syncing secrets...");
43
+ await exec(["rsync", "-azP", "-e", `ssh -i ${sshKey}`, ".env.keys", `${host}:${appDir}/.env.keys`]);
44
+ await exec(["rsync", "-azP", "-e", `ssh -i ${sshKey}`, ".keys/", `${host}:${appDir}/.keys/`]);
45
+ log.success("secrets synced");
46
+
47
+ // step 4: install + migrate + start
48
+ log.info("installing deps + migrating + starting...");
49
+ await ssh(`bash -c '
50
+ cd ${appDir}
51
+ bun install
52
+ dotenvx run -- bun src/migrate.ts
53
+ git add -A && git commit -m "deploy $(date +%Y-%m-%d-%H%M)" --allow-empty 2>/dev/null || true
54
+ nohup dotenvx run -- bun services/api/index.ts > /tmp/upend-api.log 2>&1 &
55
+ nohup dotenvx run -- bun services/claude/index.ts > /tmp/upend-claude.log 2>&1 &
56
+ nohup sudo caddy run --config ${appDir}/infra/Caddyfile > /tmp/upend-caddy.log 2>&1 &
57
+ sleep 3
58
+ curl -s -o /dev/null -w "API: %{http_code}\\n" http://localhost:3001/
59
+ curl -s -o /dev/null -w "Caddy: %{http_code}\\n" http://localhost:80/
60
+ '`);
61
+ log.success("deployed");
62
+
63
+ log.blank();
64
+ log.header("live!");
65
+ log.info(`ssh ${host} 'tail -f /tmp/upend-*.log'`);
66
+ log.blank();
67
+ }
@@ -0,0 +1,96 @@
1
+ import { log } from "../lib/log";
2
+ import { hasCommand } from "../lib/exec";
3
+ import { existsSync } from "fs";
4
+ import { resolve } from "path";
5
+
6
+ export default async function dev(args: string[]) {
7
+ const projectDir = resolve(".");
8
+
9
+ if (!existsSync("upend.config.ts") && !existsSync("package.json")) {
10
+ log.error("not in an upend project (no upend.config.ts found)");
11
+ process.exit(1);
12
+ }
13
+
14
+ // find @upend/cli's bundled services
15
+ const cliRoot = new URL("../../", import.meta.url).pathname;
16
+
17
+ // ports from env or defaults
18
+ const apiPort = process.env.API_PORT || "3001";
19
+ const claudePort = process.env.CLAUDE_PORT || "3002";
20
+ const proxyPort = process.env.PORT || "4000";
21
+
22
+ log.header("starting upend dev");
23
+
24
+ // start API service
25
+ log.info(`starting api → :${apiPort}`);
26
+ Bun.spawn(["bun", "--watch", `${cliRoot}/src/services/gateway/index.ts`], {
27
+ env: { ...process.env, API_PORT: apiPort, UPEND_PROJECT: projectDir },
28
+ stdout: "inherit",
29
+ stderr: "inherit",
30
+ });
31
+
32
+ // start Claude service
33
+ log.info(`starting claude → :${claudePort}`);
34
+ Bun.spawn(["bun", "--watch", `${cliRoot}/src/services/claude/index.ts`], {
35
+ env: { ...process.env, CLAUDE_PORT: claudePort, UPEND_PROJECT: projectDir },
36
+ stdout: "inherit",
37
+ stderr: "inherit",
38
+ });
39
+
40
+ // start Caddy
41
+ if (await hasCommand("caddy")) {
42
+ log.info(`starting caddy → :${proxyPort}`);
43
+ const caddyfile = generateCaddyfile(projectDir, cliRoot, apiPort, claudePort, proxyPort);
44
+ const caddyPath = `/tmp/upend-Caddyfile-${proxyPort}`;
45
+ await Bun.write(caddyPath, caddyfile);
46
+ Bun.spawn(["caddy", "run", "--config", caddyPath, "--adapter", "caddyfile"], {
47
+ stdout: "inherit",
48
+ stderr: "inherit",
49
+ });
50
+ } else {
51
+ log.warn("caddy not found — install with: brew install caddy");
52
+ log.warn(`services running on individual ports (${apiPort}, ${claudePort})`);
53
+ }
54
+
55
+ log.blank();
56
+ log.header(`upend running on :${proxyPort}`);
57
+ log.info(`http://localhost:${proxyPort}/ → dashboard`);
58
+ log.info(`http://localhost:${proxyPort}/api/ → api`);
59
+ log.info(`http://localhost:${proxyPort}/claude/ → claude`);
60
+ log.info(`http://localhost:${proxyPort}/apps/ → live apps`);
61
+ log.blank();
62
+ }
63
+
64
+ function generateCaddyfile(projectDir: string, cliRoot: string, apiPort: string, claudePort: string, proxyPort: string): string {
65
+ return `:${proxyPort} {
66
+ # Live apps
67
+ handle_path /apps/* {
68
+ root * ${projectDir}/apps
69
+ try_files {path} {path}/index.html
70
+ file_server
71
+ }
72
+
73
+ # API service (auth, JWKS, tables)
74
+ handle_path /api/* {
75
+ reverse_proxy localhost:${apiPort}
76
+ }
77
+
78
+ # Claude service + WebSocket
79
+ handle_path /claude/* {
80
+ reverse_proxy localhost:${claudePort}
81
+ }
82
+
83
+ # JWKS
84
+ handle /.well-known/* {
85
+ reverse_proxy localhost:${apiPort}
86
+ }
87
+
88
+ # Default → dashboard
89
+ handle {
90
+ root * ${cliRoot}/src/services/dashboard/public
91
+ try_files {path} /index.html
92
+ file_server
93
+ }
94
+ }
95
+ `;
96
+ }
@@ -0,0 +1,227 @@
1
+ import { log } from "../lib/log";
2
+ import { exec, execOrDie, hasCommand } from "../lib/exec";
3
+
4
+ export default async function infra(args: string[]) {
5
+ // parse provider from command: infra:aws, infra:gcp, etc.
6
+ const fullCommand = process.argv[2]; // e.g. "infra:aws"
7
+ const provider = fullCommand?.split(":")[1] || args[0];
8
+
9
+ if (!provider) {
10
+ log.error("usage: upend infra:<provider>");
11
+ log.dim(" upend infra:aws provision an EC2 instance");
12
+ log.dim(" upend infra:gcp provision a GCE instance (coming soon)");
13
+ log.dim(" upend infra:azure provision an Azure VM (coming soon)");
14
+ process.exit(1);
15
+ }
16
+
17
+ switch (provider) {
18
+ case "aws":
19
+ await provisionAWS();
20
+ break;
21
+ case "gcp":
22
+ case "azure":
23
+ log.error(`${provider} support coming soon`);
24
+ process.exit(1);
25
+ default:
26
+ log.error(`unknown provider: ${provider}`);
27
+ process.exit(1);
28
+ }
29
+ }
30
+
31
+ async function provisionAWS() {
32
+ log.header("provisioning AWS infrastructure");
33
+
34
+ // check AWS CLI
35
+ if (!(await hasCommand("aws"))) {
36
+ log.error("AWS CLI not found. Install: brew install awscli");
37
+ process.exit(1);
38
+ }
39
+
40
+ // verify credentials
41
+ log.info("verifying AWS credentials...");
42
+ const { stdout: identity } = await execOrDie(["aws", "sts", "get-caller-identity"]);
43
+ const account = JSON.parse(identity);
44
+ log.success(`authenticated as ${account.Arn}`);
45
+
46
+ const region = process.env.AWS_REGION || "us-east-1";
47
+ const keyName = "upend";
48
+ const instanceType = "t4g.small";
49
+
50
+ // create key pair if it doesn't exist
51
+ log.info("setting up SSH key pair...");
52
+ const { exitCode: keyExists } = await exec(
53
+ ["aws", "ec2", "describe-key-pairs", "--key-names", keyName, "--region", region],
54
+ { silent: true }
55
+ );
56
+
57
+ if (keyExists !== 0) {
58
+ const sshDir = `${process.env.HOME}/.ssh`;
59
+ await execOrDie([
60
+ "aws", "ec2", "create-key-pair",
61
+ "--key-name", keyName,
62
+ "--key-type", "ed25519",
63
+ "--query", "KeyMaterial",
64
+ "--output", "text",
65
+ "--region", region,
66
+ ]);
67
+ // the key material goes to stdout — capture and write
68
+ const { stdout: keyMaterial } = await execOrDie([
69
+ "aws", "ec2", "create-key-pair",
70
+ "--key-name", `${keyName}-2`,
71
+ "--key-type", "ed25519",
72
+ "--query", "KeyMaterial",
73
+ "--output", "text",
74
+ "--region", region,
75
+ ]);
76
+ // actually let's do this properly
77
+ log.warn("key pair created but you'll need to save it manually");
78
+ log.dim(`aws ec2 create-key-pair --key-name ${keyName} --query KeyMaterial --output text > ~/.ssh/upend.pem`);
79
+ }
80
+ log.success("SSH key pair ready");
81
+
82
+ // create security group
83
+ log.info("creating security group...");
84
+ const { stdout: sgJson, exitCode: sgExists } = await exec(
85
+ ["aws", "ec2", "describe-security-groups", "--group-names", "upend", "--region", region],
86
+ { silent: true }
87
+ );
88
+
89
+ let sgId: string;
90
+ if (sgExists === 0) {
91
+ sgId = JSON.parse(sgJson).SecurityGroups[0].GroupId;
92
+ log.success(`using existing security group: ${sgId}`);
93
+ } else {
94
+ const { stdout: newSg } = await execOrDie([
95
+ "aws", "ec2", "create-security-group",
96
+ "--group-name", "upend",
97
+ "--description", "upend server",
98
+ "--query", "GroupId",
99
+ "--output", "text",
100
+ "--region", region,
101
+ ]);
102
+ sgId = newSg;
103
+
104
+ // open ports
105
+ for (const port of [22, 80, 443]) {
106
+ await exec([
107
+ "aws", "ec2", "authorize-security-group-ingress",
108
+ "--group-id", sgId,
109
+ "--protocol", "tcp",
110
+ "--port", String(port),
111
+ "--cidr", "0.0.0.0/0",
112
+ "--region", region,
113
+ ], { silent: true });
114
+ }
115
+ log.success(`security group created: ${sgId}`);
116
+ }
117
+
118
+ // find latest Amazon Linux 2023 ARM AMI
119
+ log.info("finding latest AMI...");
120
+ const { stdout: amiId } = await execOrDie([
121
+ "aws", "ec2", "describe-images",
122
+ "--owners", "amazon",
123
+ "--filters", "Name=name,Values=al2023-ami-2023*-arm64", "Name=state,Values=available",
124
+ "--query", "sort_by(Images, &CreationDate)[-1].ImageId",
125
+ "--output", "text",
126
+ "--region", region,
127
+ ]);
128
+ log.success(`AMI: ${amiId}`);
129
+
130
+ // launch instance
131
+ log.info("launching instance...");
132
+ const { stdout: instanceId } = await execOrDie([
133
+ "aws", "ec2", "run-instances",
134
+ "--image-id", amiId,
135
+ "--instance-type", instanceType,
136
+ "--key-name", keyName,
137
+ "--security-group-ids", sgId,
138
+ "--block-device-mappings", '[{"DeviceName":"/dev/xvda","Ebs":{"VolumeSize":20,"VolumeType":"gp3"}}]',
139
+ "--tag-specifications", 'ResourceType=instance,Tags=[{Key=Name,Value=upend}]',
140
+ "--query", "Instances[0].InstanceId",
141
+ "--output", "text",
142
+ "--region", region,
143
+ ]);
144
+ log.success(`instance: ${instanceId}`);
145
+
146
+ // wait for running
147
+ log.info("waiting for instance to start...");
148
+ await execOrDie([
149
+ "aws", "ec2", "wait", "instance-running",
150
+ "--instance-ids", instanceId,
151
+ "--region", region,
152
+ ]);
153
+
154
+ // get public IP
155
+ const { stdout: publicIp } = await execOrDie([
156
+ "aws", "ec2", "describe-instances",
157
+ "--instance-ids", instanceId,
158
+ "--query", "Reservations[0].Instances[0].PublicIpAddress",
159
+ "--output", "text",
160
+ "--region", region,
161
+ ]);
162
+ log.success(`public IP: ${publicIp}`);
163
+
164
+ // set up SSH config
165
+ log.info("adding SSH config...");
166
+ const sshConfigEntry = `\nHost upend\n HostName ${publicIp}\n User ec2-user\n IdentityFile ~/.ssh/upend.pem\n`;
167
+ const sshConfigPath = `${process.env.HOME}/.ssh/config`;
168
+ const existing = await Bun.file(sshConfigPath).text().catch(() => "");
169
+ if (!existing.includes("Host upend")) {
170
+ await Bun.write(sshConfigPath, existing + sshConfigEntry);
171
+ }
172
+ log.success("SSH config updated");
173
+
174
+ // wait for SSH to be ready
175
+ log.info("waiting for SSH...");
176
+ for (let i = 0; i < 30; i++) {
177
+ const { exitCode } = await exec(
178
+ ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5", "upend", "echo ok"],
179
+ { silent: true }
180
+ );
181
+ if (exitCode === 0) break;
182
+ await new Promise((r) => setTimeout(r, 2000));
183
+ }
184
+ log.success("SSH connected");
185
+
186
+ // run setup on the instance
187
+ log.info("installing bun, node, caddy, claude code...");
188
+ const setupScript = `
189
+ set -euo pipefail
190
+ curl -fsSL https://bun.sh/install | bash
191
+ export PATH="$HOME/.bun/bin:$PATH"
192
+ curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo bash -
193
+ sudo dnf install -y nodejs git
194
+ ARCH=$(uname -m | sed 's/aarch64/arm64/' | sed 's/x86_64/amd64/')
195
+ curl -fsSL "https://caddyserver.com/api/download?os=linux&arch=$ARCH" -o /tmp/caddy
196
+ sudo mv /tmp/caddy /usr/local/bin/caddy && sudo chmod +x /usr/local/bin/caddy
197
+ bun install -g @anthropic-ai/claude-code
198
+ sudo ln -sf $HOME/.bun/bin/bun /usr/local/bin/bun
199
+ sudo ln -sf $HOME/.bun/bin/bunx /usr/local/bin/bunx
200
+ sudo ln -sf $HOME/.bun/bin/claude /usr/local/bin/claude
201
+ sudo ln -sf $HOME/.bun/bin/dotenvx /usr/local/bin/dotenvx
202
+ sudo mkdir -p /opt/upend && sudo chown $(whoami):$(whoami) /opt/upend
203
+ echo "setup complete"
204
+ `;
205
+ await execOrDie(["ssh", "upend", "bash -s"], { cwd: process.cwd() });
206
+ // actually need to pipe the script
207
+ const setupProc = Bun.spawn(["ssh", "upend", "bash -s"], {
208
+ stdin: new TextEncoder().encode(setupScript),
209
+ stdout: "inherit",
210
+ stderr: "inherit",
211
+ });
212
+ await setupProc.exited;
213
+ log.success("instance provisioned");
214
+
215
+ log.blank();
216
+ log.header("infrastructure ready!");
217
+ log.info(`instance: ${instanceId}`);
218
+ log.info(`IP: ${publicIp}`);
219
+ log.info(`SSH: ssh upend`);
220
+ log.blank();
221
+ log.info("add to your .env:");
222
+ log.dim(`DEPLOY_HOST=ec2-user@${publicIp}`);
223
+ log.blank();
224
+ log.info("then deploy:");
225
+ log.dim("upend deploy");
226
+ log.blank();
227
+ }