@upend/cli 0.1.1 → 0.1.4
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/bin/cli.ts +15 -0
- package/package.json +4 -2
- package/src/apps/users/index.html +227 -0
- package/src/commands/deploy.ts +17 -0
- package/src/commands/dev.ts +2 -2
- package/src/commands/init.ts +67 -4
- package/src/commands/workflows.ts +142 -0
- package/src/services/claude/index.ts +20 -20
- package/src/services/dashboard/public/index.html +211 -10
- package/src/services/gateway/auth-routes.ts +50 -4
- package/src/services/gateway/index.ts +206 -18
package/bin/cli.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
|
|
5
|
+
// auto-load encrypted .env if present (skip for init — no .env yet)
|
|
3
6
|
const args = process.argv.slice(2);
|
|
4
7
|
const command = args[0];
|
|
8
|
+
if (command !== "init" && existsSync(".env")) {
|
|
9
|
+
try {
|
|
10
|
+
const { config } = await import("@dotenvx/dotenvx");
|
|
11
|
+
config({ quiet: true });
|
|
12
|
+
} catch {
|
|
13
|
+
// dotenvx not available, env vars must be set manually
|
|
14
|
+
}
|
|
15
|
+
}
|
|
5
16
|
|
|
6
17
|
const commands: Record<string, () => Promise<void>> = {
|
|
7
18
|
init: () => import("../src/commands/init").then((m) => m.default(args.slice(1))),
|
|
@@ -10,6 +21,7 @@ const commands: Record<string, () => Promise<void>> = {
|
|
|
10
21
|
migrate: () => import("../src/commands/migrate").then((m) => m.default(args.slice(1))),
|
|
11
22
|
infra: () => import("../src/commands/infra").then((m) => m.default(args.slice(1))),
|
|
12
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))),
|
|
13
25
|
};
|
|
14
26
|
|
|
15
27
|
if (!command || command === "--help" || command === "-h") {
|
|
@@ -22,6 +34,9 @@ if (!command || command === "--help" || command === "-h") {
|
|
|
22
34
|
upend deploy deploy to remote instance
|
|
23
35
|
upend migrate run database migrations
|
|
24
36
|
upend env:set <K> <V> set an env var (decrypts, sets, re-encrypts)
|
|
37
|
+
upend workflows list workflows
|
|
38
|
+
upend workflows run <n> run a workflow manually
|
|
39
|
+
upend workflows install install cron schedules
|
|
25
40
|
upend infra:aws provision AWS infrastructure
|
|
26
41
|
|
|
27
42
|
options:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@upend/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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",
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
"url": "https://github.com/cif/upend"
|
|
20
21
|
},
|
|
21
22
|
"dependencies": {
|
|
23
|
+
"@dotenvx/dotenvx": "^1.55.1",
|
|
22
24
|
"hono": "^4.12.8",
|
|
23
25
|
"jose": "^6.2.1",
|
|
24
26
|
"postgres": "^3.4.8"
|
|
@@ -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/dev.ts
CHANGED
|
@@ -23,7 +23,7 @@ export default async function dev(args: string[]) {
|
|
|
23
23
|
|
|
24
24
|
// start API service
|
|
25
25
|
log.info(`starting api → :${apiPort}`);
|
|
26
|
-
Bun.spawn(["
|
|
26
|
+
Bun.spawn(["bun", "--watch", `${cliRoot}/src/services/gateway/index.ts`], {
|
|
27
27
|
cwd: projectDir,
|
|
28
28
|
env: { ...process.env, API_PORT: apiPort, UPEND_PROJECT: projectDir },
|
|
29
29
|
stdout: "inherit",
|
|
@@ -32,7 +32,7 @@ export default async function dev(args: string[]) {
|
|
|
32
32
|
|
|
33
33
|
// start Claude service
|
|
34
34
|
log.info(`starting claude → :${claudePort}`);
|
|
35
|
-
Bun.spawn(["
|
|
35
|
+
Bun.spawn(["bun", "--watch", `${cliRoot}/src/services/claude/index.ts`], {
|
|
36
36
|
cwd: projectDir,
|
|
37
37
|
env: { ...process.env, CLAUDE_PORT: claudePort, UPEND_PROJECT: projectDir },
|
|
38
38
|
stdout: "inherit",
|
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>/\`
|
|
@@ -340,10 +403,10 @@ All requests need \`Authorization: Bearer <jwt>\` header.
|
|
|
340
403
|
log.blank();
|
|
341
404
|
log.info(`cd ${name}`);
|
|
342
405
|
if (!process.env.ANTHROPIC_API_KEY) {
|
|
343
|
-
log.info("upend env:set ANTHROPIC_API_KEY <your-key>");
|
|
406
|
+
log.info("bunx upend env:set ANTHROPIC_API_KEY <your-key>");
|
|
344
407
|
}
|
|
345
|
-
log.info("upend migrate");
|
|
346
|
-
log.info("upend dev");
|
|
408
|
+
log.info("bunx upend migrate");
|
|
409
|
+
log.info("bunx upend dev");
|
|
347
410
|
log.blank();
|
|
348
411
|
if (databaseUrl) {
|
|
349
412
|
log.dim(`database: ${neonProjectId}`);
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { log } from "../lib/log";
|
|
2
|
+
import { exec } from "../lib/exec";
|
|
3
|
+
import { readdirSync, readFileSync } from "fs";
|
|
4
|
+
import { join, resolve } from "path";
|
|
5
|
+
|
|
6
|
+
type Workflow = {
|
|
7
|
+
name: string;
|
|
8
|
+
file: string;
|
|
9
|
+
cron: string | null;
|
|
10
|
+
description: string;
|
|
11
|
+
installed: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default async function workflows(args: string[]) {
|
|
15
|
+
const sub = args[0];
|
|
16
|
+
const projectDir = resolve(".");
|
|
17
|
+
const workflowsDir = join(projectDir, "workflows");
|
|
18
|
+
|
|
19
|
+
switch (sub) {
|
|
20
|
+
case "list":
|
|
21
|
+
case undefined:
|
|
22
|
+
await list(workflowsDir);
|
|
23
|
+
break;
|
|
24
|
+
case "run":
|
|
25
|
+
await run(workflowsDir, args[1]);
|
|
26
|
+
break;
|
|
27
|
+
case "install":
|
|
28
|
+
await install(workflowsDir);
|
|
29
|
+
break;
|
|
30
|
+
case "uninstall":
|
|
31
|
+
await uninstall();
|
|
32
|
+
break;
|
|
33
|
+
default:
|
|
34
|
+
// treat it as a workflow name to run
|
|
35
|
+
await run(workflowsDir, sub);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseWorkflows(dir: string): Workflow[] {
|
|
40
|
+
let files: string[];
|
|
41
|
+
try {
|
|
42
|
+
files = readdirSync(dir).filter(f => f.endsWith(".ts") || f.endsWith(".js"));
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return files.map(file => {
|
|
48
|
+
const content = readFileSync(join(dir, file), "utf-8");
|
|
49
|
+
const cronMatch = content.match(/\/\/\s*@cron\s+(.+)/);
|
|
50
|
+
const descMatch = content.match(/\/\/\s*@description\s+(.+)/);
|
|
51
|
+
const cron = cronMatch ? cronMatch[1].trim() : null;
|
|
52
|
+
const description = descMatch ? descMatch[1].trim() : "";
|
|
53
|
+
const name = file.replace(/\.(ts|js)$/, "");
|
|
54
|
+
return { name, file, cron, description, installed: false };
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function getCrontab(): Promise<string> {
|
|
59
|
+
const { stdout } = await exec(["crontab", "-l"], { silent: true });
|
|
60
|
+
return stdout;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function list(dir: string) {
|
|
64
|
+
const wfs = parseWorkflows(dir);
|
|
65
|
+
if (wfs.length === 0) {
|
|
66
|
+
log.info("no workflows found in workflows/");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const crontab = await getCrontab();
|
|
71
|
+
|
|
72
|
+
log.header("workflows");
|
|
73
|
+
for (const wf of wfs) {
|
|
74
|
+
const installed = crontab.includes(`workflows/${wf.file}`);
|
|
75
|
+
const status = installed ? "installed" : wf.cron ? "not installed" : "manual only";
|
|
76
|
+
const statusColor = installed ? "\x1b[32m" : "\x1b[90m";
|
|
77
|
+
console.log(` ${wf.name}`);
|
|
78
|
+
if (wf.description) console.log(` ${wf.description}`);
|
|
79
|
+
if (wf.cron) console.log(` cron: ${wf.cron} [${statusColor}${status}\x1b[0m]`);
|
|
80
|
+
else console.log(` [manual only]`);
|
|
81
|
+
console.log();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function run(dir: string, name?: string) {
|
|
86
|
+
if (!name) {
|
|
87
|
+
log.error("usage: upend workflows run <name>");
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const file = name.endsWith(".ts") || name.endsWith(".js") ? name : `${name}.ts`;
|
|
92
|
+
const path = join(dir, file);
|
|
93
|
+
|
|
94
|
+
log.info(`running ${file}...`);
|
|
95
|
+
const { stdout, stderr, exitCode } = await exec(["bun", path]);
|
|
96
|
+
if (stdout) console.log(stdout);
|
|
97
|
+
if (stderr) console.error(stderr);
|
|
98
|
+
|
|
99
|
+
if (exitCode === 0) {
|
|
100
|
+
log.success(`${file} complete`);
|
|
101
|
+
} else {
|
|
102
|
+
log.error(`${file} failed (exit ${exitCode})`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function install(dir: string) {
|
|
107
|
+
const wfs = parseWorkflows(dir).filter(w => w.cron);
|
|
108
|
+
if (wfs.length === 0) {
|
|
109
|
+
log.info("no workflows with @cron found");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const crontab = await getCrontab();
|
|
114
|
+
const lines = crontab.split("\n").filter(l => !l.includes("# upend-workflow:"));
|
|
115
|
+
const projectDir = resolve(".");
|
|
116
|
+
|
|
117
|
+
for (const wf of wfs) {
|
|
118
|
+
lines.push(`${wf.cron} cd ${projectDir} && bun workflows/${wf.file} >> /tmp/upend-workflow-${wf.name}.log 2>&1 # upend-workflow: ${wf.name}`);
|
|
119
|
+
log.info(`installing ${wf.name}: ${wf.cron}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const newCrontab = lines.filter(Boolean).join("\n") + "\n";
|
|
123
|
+
const proc = Bun.spawn(["crontab", "-"], { stdin: "pipe" });
|
|
124
|
+
proc.stdin.write(newCrontab);
|
|
125
|
+
proc.stdin.end();
|
|
126
|
+
await proc.exited;
|
|
127
|
+
|
|
128
|
+
log.success(`${wfs.length} workflow(s) installed`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function uninstall() {
|
|
132
|
+
const crontab = await getCrontab();
|
|
133
|
+
const lines = crontab.split("\n").filter(l => !l.includes("# upend-workflow:"));
|
|
134
|
+
const newCrontab = lines.filter(Boolean).join("\n") + "\n";
|
|
135
|
+
|
|
136
|
+
const proc = Bun.spawn(["crontab", "-"], { stdin: "pipe" });
|
|
137
|
+
proc.stdin.write(newCrontab);
|
|
138
|
+
proc.stdin.end();
|
|
139
|
+
await proc.exited;
|
|
140
|
+
|
|
141
|
+
log.success("all upend workflows removed from crontab");
|
|
142
|
+
}
|