@upend/cli 0.1.2 → 0.1.5

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
@@ -193,6 +193,40 @@ Neon needs to reach your JWKS URL to validate JWTs for the Data API. After your
193
193
  bunx upend setup:jwks
194
194
  ```
195
195
 
196
+ ### Operations
197
+
198
+ ```bash
199
+ # check service health, disk, memory, cron jobs
200
+ bunx upend status
201
+
202
+ # tail logs (all services, or pick one)
203
+ bunx upend logs
204
+ bunx upend logs api
205
+ bunx upend logs claude
206
+ bunx upend logs -f # follow in realtime
207
+
208
+ # SSH into the remote instance
209
+ bunx upend ssh # interactive shell, cd'd to project
210
+ bunx upend ssh "bun -v" # run a command
211
+ ```
212
+
213
+ ### Workflows
214
+
215
+ Workflows are TypeScript files in `workflows/` that run on a cron schedule or manually:
216
+
217
+ ```bash
218
+ # list workflows and their schedules
219
+ bunx upend workflows
220
+
221
+ # run one manually
222
+ bunx upend workflows run cleanup-sessions
223
+
224
+ # install cron schedules (also happens on deploy)
225
+ bunx upend workflows install
226
+ ```
227
+
228
+ Workflows are also visible in the dashboard with a manual trigger button.
229
+
196
230
  ## CLI Commands
197
231
 
198
232
  | Command | What |
@@ -201,6 +235,11 @@ bunx upend setup:jwks
201
235
  | `upend dev` | Start gateway + claude + caddy locally |
202
236
  | `upend migrate` | Run SQL migrations from `migrations/` |
203
237
  | `upend deploy` | rsync to remote, install, migrate, restart |
238
+ | `upend status` | Check remote service health |
239
+ | `upend logs [service]` | Tail remote logs (`-f` to follow) |
240
+ | `upend ssh [cmd]` | SSH into remote instance |
241
+ | `upend workflows` | List, run, or install workflow cron schedules |
242
+ | `upend env:set <K> <V>` | Set an env var (decrypts, sets, re-encrypts) |
204
243
  | `upend infra:aws` | Provision an EC2 instance |
205
244
 
206
245
  ## Config
package/bin/cli.ts CHANGED
@@ -21,6 +21,10 @@ const commands: Record<string, () => Promise<void>> = {
21
21
  migrate: () => import("../src/commands/migrate").then((m) => m.default(args.slice(1))),
22
22
  infra: () => import("../src/commands/infra").then((m) => m.default(args.slice(1))),
23
23
  env: () => import("../src/commands/env").then((m) => m.default(args.slice(1))),
24
+ workflows: () => import("../src/commands/workflows").then((m) => m.default(args.slice(1))),
25
+ logs: () => import("../src/commands/logs").then((m) => m.default(args.slice(1))),
26
+ status: () => import("../src/commands/status").then((m) => m.default(args.slice(1))),
27
+ ssh: () => import("../src/commands/ssh").then((m) => m.default(args.slice(1))),
24
28
  };
25
29
 
26
30
  if (!command || command === "--help" || command === "-h") {
@@ -33,6 +37,13 @@ if (!command || command === "--help" || command === "-h") {
33
37
  upend deploy deploy to remote instance
34
38
  upend migrate run database migrations
35
39
  upend env:set <K> <V> set an env var (decrypts, sets, re-encrypts)
40
+ upend workflows list workflows
41
+ upend workflows run <n> run a workflow manually
42
+ upend workflows install install cron schedules
43
+ upend logs [service] tail remote logs (api|claude|caddy|all)
44
+ upend logs -f follow logs in realtime
45
+ upend status check remote service health
46
+ upend ssh [cmd] SSH into remote (or run a command)
36
47
  upend infra:aws provision AWS infrastructure
37
48
 
38
49
  options:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upend/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "description": "Anti-SaaS stack. Deploy live apps with Claude, Postgres, and rsync.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,8 @@
9
9
  "files": [
10
10
  "bin",
11
11
  "src",
12
- "templates"
12
+ "templates",
13
+ "src/apps"
13
14
  ],
14
15
  "keywords": ["cli", "deploy", "postgres", "claude", "rsync", "caddy", "bun"],
15
16
  "author": "cif",
@@ -0,0 +1,227 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>users</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script>tailwind.config = { theme: { extend: { colors: { bg: '#0a0a0a', surface: '#141414', border: '#262626', accent: '#f97316', muted: '#737373' } } } }</script>
9
+ <style>body { background: #0a0a0a; font-family: ui-monospace, monospace; }</style>
10
+ </head>
11
+ <body class="text-gray-200 min-h-screen p-6">
12
+ <div class="max-w-4xl mx-auto">
13
+ <div class="flex items-center justify-between mb-6">
14
+ <h1 class="text-accent font-bold text-lg">users</h1>
15
+ <div class="flex items-center gap-3">
16
+ <span id="my-info" class="text-xs text-muted"></span>
17
+ <button id="btn-add" onclick="showModal('add')" class="bg-accent text-black text-xs px-3 py-1.5 rounded font-bold cursor-pointer hidden">+ add user</button>
18
+ </div>
19
+ </div>
20
+
21
+ <table class="w-full text-sm">
22
+ <thead>
23
+ <tr class="text-muted text-xs text-left border-b border-border">
24
+ <th class="pb-2 font-normal">email</th>
25
+ <th class="pb-2 font-normal">role</th>
26
+ <th class="pb-2 font-normal">created</th>
27
+ <th class="pb-2 font-normal w-40"></th>
28
+ </tr>
29
+ </thead>
30
+ <tbody id="users-table"></tbody>
31
+ </table>
32
+ </div>
33
+
34
+ <!-- edit modal -->
35
+ <div id="edit-modal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 hidden" onclick="if(event.target===this)hideModal('edit')">
36
+ <form onsubmit="saveUser(event)" class="bg-surface border border-border rounded-xl p-6 w-96 flex flex-col gap-4">
37
+ <h3 class="text-accent font-bold text-sm" id="edit-title">edit user</h3>
38
+ <input id="edit-email" type="email" placeholder="email"
39
+ class="bg-bg border border-border rounded-md px-3 py-2 text-sm text-gray-200 font-mono outline-none focus:border-accent">
40
+ <div id="edit-role-wrap">
41
+ <select id="edit-role"
42
+ class="w-full bg-bg border border-border rounded-md px-3 py-2 text-sm text-gray-200 font-mono outline-none focus:border-accent">
43
+ <option value="user">user</option>
44
+ <option value="admin">admin</option>
45
+ </select>
46
+ </div>
47
+ <input type="hidden" id="edit-id">
48
+ <div class="flex gap-2 justify-end">
49
+ <button type="button" onclick="hideModal('edit')"
50
+ class="text-muted text-xs px-3 py-1.5 rounded border border-border cursor-pointer hover:text-gray-200 font-mono">cancel</button>
51
+ <button type="submit"
52
+ class="bg-accent text-black text-xs px-4 py-1.5 rounded font-bold cursor-pointer font-mono">save</button>
53
+ </div>
54
+ </form>
55
+ </div>
56
+
57
+ <!-- add modal -->
58
+ <div id="add-modal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 hidden" onclick="if(event.target===this)hideModal('add')">
59
+ <form onsubmit="addUser(event)" class="bg-surface border border-border rounded-xl p-6 w-96 flex flex-col gap-4">
60
+ <h3 class="text-accent font-bold text-sm">add user</h3>
61
+ <input id="add-email" type="email" placeholder="email" required
62
+ class="bg-bg border border-border rounded-md px-3 py-2 text-sm text-gray-200 font-mono outline-none focus:border-accent">
63
+ <input id="add-password" type="password" placeholder="password" required
64
+ class="bg-bg border border-border rounded-md px-3 py-2 text-sm text-gray-200 font-mono outline-none focus:border-accent">
65
+ <select id="add-role"
66
+ class="bg-bg border border-border rounded-md px-3 py-2 text-sm text-gray-200 font-mono outline-none focus:border-accent">
67
+ <option value="user">user</option>
68
+ <option value="admin">admin</option>
69
+ </select>
70
+ <div class="flex gap-2 justify-end">
71
+ <button type="button" onclick="hideModal('add')"
72
+ class="text-muted text-xs px-3 py-1.5 rounded border border-border cursor-pointer hover:text-gray-200 font-mono">cancel</button>
73
+ <button type="submit"
74
+ class="bg-accent text-black text-xs px-4 py-1.5 rounded font-bold cursor-pointer font-mono">create</button>
75
+ </div>
76
+ </form>
77
+ </div>
78
+
79
+ <!-- impersonation banner -->
80
+ <div id="imp-banner" class="fixed top-0 left-0 right-0 bg-yellow-500/20 border-b border-yellow-500/50 text-yellow-400 text-xs text-center py-1.5 z-50 hidden font-mono">
81
+ viewing as <span id="imp-email"></span>
82
+ <button onclick="exitImpersonation()" class="ml-3 underline cursor-pointer">exit</button>
83
+ </div>
84
+
85
+ <script>
86
+ const token = localStorage.getItem('upend_token');
87
+ if (!token) window.location.href = '/';
88
+
89
+ const claims = JSON.parse(atob(token.split('.')[1]));
90
+ const isAdmin = claims.app_role === 'admin';
91
+ const myId = claims.sub;
92
+
93
+ // impersonation check
94
+ const realToken = localStorage.getItem('upend_real_token');
95
+ if (realToken) {
96
+ document.getElementById('imp-banner').classList.remove('hidden');
97
+ document.getElementById('imp-email').textContent = claims.email;
98
+ }
99
+
100
+ document.getElementById('my-info').textContent = `${claims.email} (${claims.app_role || 'user'})`;
101
+ if (isAdmin) document.getElementById('btn-add').classList.remove('hidden');
102
+
103
+ const headers = { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' };
104
+
105
+ async function api(url, opts = {}) {
106
+ const res = await fetch(url, { headers, ...opts });
107
+ if (res.status === 401) { localStorage.removeItem('upend_token'); window.location.href = '/'; }
108
+ return res;
109
+ }
110
+
111
+ async function load() {
112
+ // everyone sees all users
113
+ const res = await api('/api/data/users?select=id,email,role,created_at&order=created_at.desc');
114
+ if (!res.ok) { console.error('failed to load users', await res.text()); return; }
115
+ const users = await res.json();
116
+ render(users);
117
+ }
118
+
119
+ function render(users) {
120
+ document.getElementById('users-table').innerHTML = users.map(u => {
121
+ const isMe = u.id === myId;
122
+ const canEdit = isMe || isAdmin;
123
+ return `
124
+ <tr class="border-b border-border/50 hover:bg-surface/50">
125
+ <td class="py-2.5">${u.email} ${isMe ? '<span class="text-accent text-xs">(you)</span>' : ''}</td>
126
+ <td class="py-2.5">
127
+ <span class="px-2 py-0.5 rounded text-xs ${u.role === 'admin' ? 'bg-accent/20 text-accent' : 'bg-border text-muted'}">${u.role}</span>
128
+ </td>
129
+ <td class="py-2.5 text-muted text-xs">${new Date(u.created_at).toLocaleDateString()}</td>
130
+ <td class="py-2.5 text-right space-x-2">
131
+ ${canEdit ? `<button onclick='editUser(${JSON.stringify(u).replace(/'/g, "&#39;")})' class="text-muted text-xs hover:text-gray-200 cursor-pointer">edit</button>` : ''}
132
+ ${isAdmin && !isMe ? `<button onclick='impersonate("${u.id}")' class="text-muted text-xs hover:text-yellow-400 cursor-pointer">impersonate</button>` : ''}
133
+ ${isAdmin && !isMe ? `<button onclick='deleteUser("${u.id}")' class="text-muted text-xs hover:text-red-400 cursor-pointer">delete</button>` : ''}
134
+ </td>
135
+ </tr>`;
136
+ }).join('');
137
+ }
138
+
139
+ function showModal(name) {
140
+ document.getElementById(name + '-modal').classList.remove('hidden');
141
+ const input = document.getElementById(name + '-email');
142
+ if (input) setTimeout(() => input.focus(), 50);
143
+ }
144
+ function hideModal(name) { document.getElementById(name + '-modal').classList.add('hidden'); }
145
+
146
+ function editUser(user) {
147
+ const isMe = user.id === myId;
148
+ document.getElementById('edit-id').value = user.id;
149
+ document.getElementById('edit-email').value = user.email;
150
+ document.getElementById('edit-email').readOnly = !isAdmin && !isMe;
151
+ document.getElementById('edit-role').value = user.role;
152
+ document.getElementById('edit-title').textContent = isMe ? 'my profile' : 'edit user';
153
+
154
+ // only admins can change role, and not their own
155
+ const roleWrap = document.getElementById('edit-role-wrap');
156
+ if (!isAdmin || isMe) {
157
+ roleWrap.innerHTML = `<input type="text" value="${user.role}" readonly class="w-full bg-bg border border-border rounded-md px-3 py-2 text-sm text-gray-400 font-mono">`;
158
+ } else {
159
+ roleWrap.innerHTML = `<select id="edit-role" class="w-full bg-bg border border-border rounded-md px-3 py-2 text-sm text-gray-200 font-mono outline-none focus:border-accent">
160
+ <option value="user" ${user.role === 'user' ? 'selected' : ''}>user</option>
161
+ <option value="admin" ${user.role === 'admin' ? 'selected' : ''}>admin</option>
162
+ </select>`;
163
+ }
164
+ showModal('edit');
165
+ }
166
+
167
+ async function saveUser(e) {
168
+ e.preventDefault();
169
+ const id = document.getElementById('edit-id').value;
170
+ const email = document.getElementById('edit-email').value;
171
+ const roleEl = document.getElementById('edit-role');
172
+ const body = { email };
173
+ if (roleEl && roleEl.tagName === 'SELECT') body.role = roleEl.value;
174
+ await api(`/api/data/users?id=eq.${id}`, { method: 'PATCH', body: JSON.stringify(body) });
175
+ hideModal('edit');
176
+ load();
177
+ }
178
+
179
+ async function addUser(e) {
180
+ e.preventDefault();
181
+ const res = await api('/api/auth/signup', {
182
+ method: 'POST',
183
+ body: JSON.stringify({
184
+ email: document.getElementById('add-email').value,
185
+ password: document.getElementById('add-password').value,
186
+ role: document.getElementById('add-role').value,
187
+ }),
188
+ });
189
+ if (res.ok) { hideModal('add'); load(); }
190
+ else { const err = await res.json(); alert(err.error || 'failed'); }
191
+ }
192
+
193
+ async function deleteUser(id) {
194
+ if (!confirm('delete this user?')) return;
195
+ await api(`/api/data/users?id=eq.${id}`, { method: 'DELETE' });
196
+ load();
197
+ }
198
+
199
+ async function impersonate(userId) {
200
+ const res = await api('/api/auth/impersonate', {
201
+ method: 'POST',
202
+ body: JSON.stringify({ user_id: userId }),
203
+ });
204
+ if (res.ok) {
205
+ const { token: newToken } = await res.json();
206
+ localStorage.setItem('upend_real_token', token);
207
+ localStorage.setItem('upend_token', newToken);
208
+ window.location.reload();
209
+ } else {
210
+ const err = await res.json();
211
+ alert(err.error || 'impersonation failed');
212
+ }
213
+ }
214
+
215
+ function exitImpersonation() {
216
+ const real = localStorage.getItem('upend_real_token');
217
+ if (real) {
218
+ localStorage.setItem('upend_token', real);
219
+ localStorage.removeItem('upend_real_token');
220
+ window.location.reload();
221
+ }
222
+ }
223
+
224
+ load();
225
+ </script>
226
+ </body>
227
+ </html>
@@ -57,6 +57,23 @@ export default async function deploy(args: string[]) {
57
57
  sleep 3
58
58
  curl -s -o /dev/null -w "API: %{http_code}\\n" http://localhost:3001/
59
59
  curl -s -o /dev/null -w "Caddy: %{http_code}\\n" http://localhost:80/
60
+
61
+ # install workflow cron schedules
62
+ if [ -d ${appDir}/workflows ]; then
63
+ # strip old upend entries
64
+ crontab -l 2>/dev/null | grep -v "# upend-workflow:" > /tmp/upend-crontab-clean || true
65
+ # add new entries from @cron comments
66
+ for f in ${appDir}/workflows/*.ts; do
67
+ [ -f "$f" ] || continue
68
+ name=$(basename "$f" .ts)
69
+ cron=$(grep -oP "//\\s*@cron\\s+\\K.+" "$f" || true)
70
+ if [ -n "$cron" ]; then
71
+ echo "$cron cd ${appDir} && bun $f >> /tmp/upend-workflow-$name.log 2>&1 # upend-workflow: $name" >> /tmp/upend-crontab-clean
72
+ fi
73
+ done
74
+ crontab /tmp/upend-crontab-clean
75
+ echo "workflows installed: $(crontab -l 2>/dev/null | grep -c upend-workflow) cron entries"
76
+ fi
60
77
  '`);
61
78
  log.success("deployed");
62
79
 
@@ -215,10 +215,36 @@ CREATE TABLE IF NOT EXISTS users (
215
215
 
216
216
  // apps + services
217
217
  mkdirSync(join(projectDir, "apps"), { recursive: true });
218
- writeFile(projectDir, "apps/.gitkeep", "");
218
+ // copy built-in users app
219
+ const usersAppSrc = new URL("../apps/users/index.html", import.meta.url).pathname;
220
+ mkdirSync(join(projectDir, "apps/users"), { recursive: true });
221
+ writeFileSync(join(projectDir, "apps/users/index.html"), readFileSync(usersAppSrc, "utf-8"));
219
222
  mkdirSync(join(projectDir, "services"), { recursive: true });
220
223
  writeFile(projectDir, "services/.gitkeep", "");
221
224
 
225
+ // workflows
226
+ mkdirSync(join(projectDir, "workflows"), { recursive: true });
227
+ writeFile(projectDir, "workflows/example.ts", `// example workflow — runs on a schedule or manually
228
+ // @cron 0 */6 * * *
229
+ // @description clean up ended sessions older than 7 days
230
+
231
+ import postgres from "postgres";
232
+
233
+ const sql = postgres(process.env.DATABASE_URL!);
234
+
235
+ export async function run() {
236
+ const deleted = await sql\`
237
+ DELETE FROM upend.editing_sessions
238
+ WHERE status = 'ended' AND created_at < now() - interval '7 days'
239
+ RETURNING id
240
+ \`;
241
+ console.log(\`cleaned up \${deleted.length} old sessions\`);
242
+ }
243
+
244
+ // run directly: bun workflows/example.ts
245
+ run().then(() => process.exit(0));
246
+ `);
247
+
222
248
  // CLAUDE.md
223
249
  writeFile(projectDir, "CLAUDE.md", `# ${name}
224
250
 
@@ -247,6 +273,43 @@ Apps talk to Neon Data API at \`/api/data/<table>\`:
247
273
  - DELETE \`/api/data/example?id=eq.5\` — delete
248
274
  All requests need \`Authorization: Bearer <jwt>\` header.
249
275
 
276
+ ## Access control
277
+ Apps read JWT claims to decide what to show:
278
+ \`\`\`js
279
+ const token = localStorage.getItem('upend_token');
280
+ const claims = JSON.parse(atob(token.split('.')[1]));
281
+ const isAdmin = claims.app_role === 'admin';
282
+ const myId = claims.sub;
283
+ \`\`\`
284
+
285
+ The data API at \`/api/data/:table\` sets JWT claims as Postgres session variables before each query,
286
+ so RLS policies have access to the current user.
287
+
288
+ When the user asks for access control, create RLS policies in a migration:
289
+ \`\`\`sql
290
+ ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
291
+ ALTER TABLE projects FORCE ROW LEVEL SECURITY;
292
+
293
+ -- everyone can read
294
+ CREATE POLICY "read" ON projects FOR SELECT USING (true);
295
+
296
+ -- users can only update their own rows
297
+ CREATE POLICY "update_own" ON projects FOR UPDATE
298
+ USING (owner_id = current_setting('request.jwt.sub')::uuid);
299
+
300
+ -- only admins can delete
301
+ CREATE POLICY "admin_delete" ON projects FOR DELETE
302
+ USING (current_setting('request.jwt.role') = 'admin');
303
+ \`\`\`
304
+
305
+ Available session variables in RLS policies:
306
+ - \`current_setting('request.jwt.sub')\` — user ID
307
+ - \`current_setting('request.jwt.role')\` — user role (admin/user)
308
+ - \`current_setting('request.jwt.email')\` — user email
309
+
310
+ The dashboard data tab shows active RLS policies per table so users can see what rules are in place.
311
+ The \`apps/users/\` app is an example of the access control pattern.
312
+
250
313
  ## Conventions
251
314
  - Migrations: plain SQL in \`migrations/\`, numbered \`001_name.sql\`
252
315
  - Apps: static HTML/JS/CSS in \`apps/<name>/\`
@@ -0,0 +1,43 @@
1
+ import { log } from "../lib/log";
2
+ import { exec } from "../lib/exec";
3
+
4
+ export default async function logs(args: string[]) {
5
+ const host = process.env.DEPLOY_HOST;
6
+ const sshKey = process.env.DEPLOY_SSH_KEY || `${process.env.HOME}/.ssh/upend.pem`;
7
+
8
+ if (!host) {
9
+ log.error("DEPLOY_HOST not set. Add it to .env");
10
+ process.exit(1);
11
+ }
12
+
13
+ const service = args[0]; // api, claude, caddy, workflow-<name>, or blank for all
14
+ let logFiles: string;
15
+
16
+ if (service === "api") {
17
+ logFiles = "/tmp/upend-api.log";
18
+ } else if (service === "claude") {
19
+ logFiles = "/tmp/upend-claude.log";
20
+ } else if (service === "caddy") {
21
+ logFiles = "/tmp/upend-caddy.log";
22
+ } else if (service?.startsWith("workflow-")) {
23
+ logFiles = `/tmp/upend-workflow-${service.replace("workflow-", "")}.log`;
24
+ } else {
25
+ logFiles = "/tmp/upend-api.log /tmp/upend-claude.log /tmp/upend-caddy.log";
26
+ }
27
+
28
+ const lines = args.includes("-n") ? args[args.indexOf("-n") + 1] || "50" : "50";
29
+ const follow = args.includes("-f") || args.includes("--follow");
30
+
31
+ const tailCmd = follow
32
+ ? `tail -f ${logFiles}`
33
+ : `tail -n ${lines} ${logFiles}`;
34
+
35
+ log.dim(`ssh ${host} → ${tailCmd}`);
36
+
37
+ // use spawn for streaming output (especially -f)
38
+ const proc = Bun.spawn(["ssh", "-i", sshKey, host, tailCmd], {
39
+ stdout: "inherit",
40
+ stderr: "inherit",
41
+ });
42
+ await proc.exited;
43
+ }
@@ -0,0 +1,32 @@
1
+ import { log } from "../lib/log";
2
+
3
+ export default async function ssh(args: string[]) {
4
+ const host = process.env.DEPLOY_HOST;
5
+ const sshKey = process.env.DEPLOY_SSH_KEY || `${process.env.HOME}/.ssh/upend.pem`;
6
+ const appDir = process.env.DEPLOY_DIR || "/opt/upend";
7
+
8
+ if (!host) {
9
+ log.error("DEPLOY_HOST not set. Add it to .env");
10
+ process.exit(1);
11
+ }
12
+
13
+ // if args provided, run as remote command
14
+ if (args.length > 0) {
15
+ const cmd = args.join(" ");
16
+ log.dim(`ssh ${host} → ${cmd}`);
17
+ const proc = Bun.spawn(["ssh", "-i", sshKey, host, `cd ${appDir} && ${cmd}`], {
18
+ stdout: "inherit",
19
+ stderr: "inherit",
20
+ });
21
+ process.exit(await proc.exited);
22
+ }
23
+
24
+ // otherwise, interactive shell
25
+ log.dim(`ssh ${host} (cd ${appDir})`);
26
+ const proc = Bun.spawn(["ssh", "-i", sshKey, "-t", host, `cd ${appDir} && exec bash`], {
27
+ stdout: "inherit",
28
+ stderr: "inherit",
29
+ stdin: "inherit",
30
+ });
31
+ process.exit(await proc.exited);
32
+ }
@@ -0,0 +1,47 @@
1
+ import { log } from "../lib/log";
2
+ import { exec } from "../lib/exec";
3
+
4
+ export default async function status(args: string[]) {
5
+ const host = process.env.DEPLOY_HOST;
6
+ const sshKey = process.env.DEPLOY_SSH_KEY || `${process.env.HOME}/.ssh/upend.pem`;
7
+
8
+ if (!host) {
9
+ log.error("DEPLOY_HOST not set. Add it to .env");
10
+ process.exit(1);
11
+ }
12
+
13
+ const appDir = process.env.DEPLOY_DIR || "/opt/upend";
14
+
15
+ log.header(`${host}`);
16
+
17
+ // check services
18
+ const { stdout } = await exec(["ssh", "-i", sshKey, host, `bash -c '
19
+ echo "=== services ==="
20
+ pgrep -af "bun services/api" > /dev/null && echo "api: running" || echo "api: stopped"
21
+ pgrep -af "bun services/claude" > /dev/null && echo "claude: running" || echo "claude: stopped"
22
+ pgrep -af "caddy" > /dev/null && echo "caddy: running" || echo "caddy: stopped"
23
+
24
+ echo ""
25
+ echo "=== health ==="
26
+ curl -s -o /dev/null -w "api: %{http_code}" http://localhost:3001/ 2>/dev/null || echo "api: unreachable"
27
+ echo ""
28
+ curl -s -o /dev/null -w "caddy: %{http_code}" http://localhost:80/ 2>/dev/null || echo "caddy: unreachable"
29
+ echo ""
30
+
31
+ echo ""
32
+ echo "=== system ==="
33
+ uptime
34
+ df -h / | tail -1 | awk "{print \"disk: \" \\$3 \" used / \" \\$2 \" (\" \\$5 \")\"}"
35
+ free -h 2>/dev/null | awk "/Mem:/{print \"memory: \" \\$3 \" used / \" \\$2}" || echo "memory: n/a"
36
+
37
+ echo ""
38
+ echo "=== workflows (crontab) ==="
39
+ crontab -l 2>/dev/null | grep "upend-workflow" || echo "none installed"
40
+
41
+ echo ""
42
+ echo "=== last deploy ==="
43
+ cd ${appDir} && git log --oneline -1 2>/dev/null || echo "no git history"
44
+ '`]);
45
+
46
+ console.log(stdout);
47
+ }