@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 +231 -0
- package/bin/cli.ts +48 -0
- package/package.json +26 -0
- package/src/commands/deploy.ts +67 -0
- package/src/commands/dev.ts +96 -0
- package/src/commands/infra.ts +227 -0
- package/src/commands/init.ts +323 -0
- package/src/commands/migrate.ts +64 -0
- package/src/config.ts +18 -0
- package/src/index.ts +2 -0
- package/src/lib/auth.ts +89 -0
- package/src/lib/db.ts +14 -0
- package/src/lib/exec.ts +38 -0
- package/src/lib/log.ts +16 -0
- package/src/lib/middleware.ts +51 -0
- package/src/services/claude/index.ts +507 -0
- package/src/services/claude/snapshots.ts +142 -0
- package/src/services/claude/worktree.ts +151 -0
- package/src/services/dashboard/public/index.html +888 -0
- package/src/services/gateway/auth-routes.ts +203 -0
- package/src/services/gateway/index.ts +64 -0
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
|
+
}
|