@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 +39 -0
- package/bin/cli.ts +11 -0
- package/package.json +3 -2
- package/src/apps/users/index.html +227 -0
- package/src/commands/deploy.ts +17 -0
- package/src/commands/init.ts +64 -1
- package/src/commands/logs.ts +43 -0
- package/src/commands/ssh.ts +32 -0
- package/src/commands/status.ts +47 -0
- package/src/commands/workflows.ts +142 -0
- package/src/lib/middleware.ts +1 -1
- package/src/services/claude/index.ts +20 -20
- package/src/services/dashboard/public/index.html +178 -6
- package/src/services/gateway/auth-routes.ts +50 -4
- package/src/services/gateway/index.ts +206 -18
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.
|
|
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, "'")})' 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>
|
package/src/commands/deploy.ts
CHANGED
|
@@ -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
|
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|