@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 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.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, "&#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
 
@@ -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(["bunx", "@dotenvx/dotenvx", "run", "--", "bun", "--watch", `${cliRoot}/src/services/gateway/index.ts`], {
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(["bunx", "@dotenvx/dotenvx", "run", "--", "bun", "--watch", `${cliRoot}/src/services/claude/index.ts`], {
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",
@@ -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>/\`
@@ -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
+ }