@inneranimalmedia/agentsam-sdk 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * agentsam scaffold
4
+ * Usage:
5
+ * npx @inneranimalmedia/agentsam-sdk scaffold
6
+ * npx @inneranimalmedia/agentsam-sdk scaffold cms
7
+ * npx @inneranimalmedia/agentsam-sdk scaffold worker-api
8
+ */
9
+
10
+ import { runScaffold } from '../src/lib/scaffold/index.js';
11
+
12
+ const type = process.argv[2] ?? null;
13
+
14
+ runScaffold(type).catch((err) => {
15
+ console.error(err);
16
+ process.exit(1);
17
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inneranimalmedia/agentsam-sdk",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Agent Sam is a full-stack AI agent SDK for autonomous task execution — covering data management, creative workflows, design commands, and multi-step agentic pipelines.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -9,7 +9,8 @@
9
9
  "./package.json": "./package.json"
10
10
  },
11
11
  "bin": {
12
- "agentsam": "src/cli.js"
12
+ "agentsam": "src/cli.js",
13
+ "agentsam-scaffold": "bin/scaffold.mjs"
13
14
  },
14
15
  "files": [
15
16
  "src",
@@ -19,7 +20,8 @@
19
20
  "test",
20
21
  "README.md",
21
22
  "LICENSE",
22
- "DEVELOPMENT.md"
23
+ "DEVELOPMENT.md",
24
+ "bin"
23
25
  ],
24
26
  "scripts": {
25
27
  "test": "node test/smoke.mjs",
@@ -30,7 +32,9 @@
30
32
  "node": ">=20"
31
33
  },
32
34
  "dependencies": {
35
+ "@clack/prompts": "^1.7.0",
33
36
  "node-pty": "^1.0.0",
37
+ "picocolors": "^1.1.1",
34
38
  "ws": "^8.18.0"
35
39
  },
36
40
  "publishConfig": {
@@ -54,5 +58,8 @@
54
58
  "bugs": {
55
59
  "url": "https://github.com/SamPrimeaux/agentsam-sdk/issues"
56
60
  },
57
- "homepage": "https://github.com/SamPrimeaux/agentsam-sdk#readme"
61
+ "homepage": "https://github.com/SamPrimeaux/agentsam-sdk#readme",
62
+ "allowScripts": {
63
+ "node-pty@1.1.0": true
64
+ }
58
65
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @inneranimalmedia/agentsam-sdk — scaffold system
3
+ * Entry point for all guided scaffold wizards.
4
+ *
5
+ * Usage:
6
+ * npx @inneranimalmedia/agentsam-sdk scaffold
7
+ * npx @inneranimalmedia/agentsam-sdk scaffold cms
8
+ * npx @inneranimalmedia/agentsam-sdk scaffold worker-api
9
+ */
10
+
11
+ import { intro, outro, select, cancel, isCancel, note } from '@clack/prompts';
12
+ import { runCmsWizard } from './wizards/cms.js';
13
+ import { runWorkerApiWizard } from './wizards/worker-api.js';
14
+ import pc from 'picocolors';
15
+
16
+ const SCAFFOLDS = {
17
+ cms: {
18
+ label: 'CMS Site',
19
+ description: 'Cloudflare Worker + D1 + R2 with nav, pages, and reusable templates',
20
+ run: runCmsWizard,
21
+ },
22
+ 'worker-api': {
23
+ label: 'Worker API',
24
+ description: 'Bare Cloudflare Worker with typed route handlers and D1 binding',
25
+ run: runWorkerApiWizard,
26
+ },
27
+ };
28
+
29
+ export async function runScaffold(type) {
30
+ intro(pc.bgCyan(pc.black(' Agent Sam Scaffold ')));
31
+
32
+ // If a type was passed directly (e.g. `scaffold cms`), run it
33
+ if (type && SCAFFOLDS[type]) {
34
+ await SCAFFOLDS[type].run();
35
+ outro(pc.green('Done. Files written — check the output above.'));
36
+ return;
37
+ }
38
+
39
+ if (type && !SCAFFOLDS[type]) {
40
+ note(`Unknown scaffold type: "${type}"\nAvailable: ${Object.keys(SCAFFOLDS).join(', ')}`, 'Error');
41
+ process.exit(1);
42
+ }
43
+
44
+ // No type passed — show picker
45
+ const choice = await select({
46
+ message: 'What do you want to scaffold?',
47
+ options: Object.entries(SCAFFOLDS).map(([value, { label, description }]) => ({
48
+ value,
49
+ label,
50
+ hint: description,
51
+ })),
52
+ });
53
+
54
+ if (isCancel(choice)) {
55
+ cancel('Cancelled.');
56
+ process.exit(0);
57
+ }
58
+
59
+ await SCAFFOLDS[choice].run();
60
+ outro(pc.green('Done. Files written — check the output above.'));
61
+ }
@@ -0,0 +1,472 @@
1
+ /**
2
+ * CMS template generator.
3
+ * Returns a flat file-tree object keyed by relative path → file content string.
4
+ */
5
+
6
+ export function cmsTemplates(config) {
7
+ const { projectName, siteTitle, navStyle, pages, templateStyle, contactEmail, cfAccountId } = config;
8
+
9
+ const files = {};
10
+
11
+ // ── package.json ──────────────────────────────────────────────────────────
12
+ files['package.json'] = JSON.stringify({
13
+ name: projectName,
14
+ version: '0.1.0',
15
+ private: true,
16
+ scripts: {
17
+ deploy: 'wrangler deploy',
18
+ dev: 'wrangler dev',
19
+ 'db:migrate': `wrangler d1 execute ${projectName} --file=migrations/001_init.sql --remote`,
20
+ },
21
+ devDependencies: {
22
+ wrangler: '^3.0.0',
23
+ },
24
+ }, null, 2);
25
+
26
+ // ── wrangler.toml ─────────────────────────────────────────────────────────
27
+ files['wrangler.toml'] = `name = "${projectName}"
28
+ main = "src/index.js"
29
+ compatibility_date = "2024-01-01"
30
+ account_id = "${cfAccountId}"
31
+
32
+ [[d1_databases]]
33
+ binding = "DB"
34
+ database_name = "${projectName}"
35
+ database_id = "REPLACE_WITH_YOUR_D1_ID"
36
+
37
+ [[r2_buckets]]
38
+ binding = "ASSETS"
39
+ bucket_name = "${projectName}"
40
+ `;
41
+
42
+ // ── D1 migration ──────────────────────────────────────────────────────────
43
+ let migrationSql = `-- ${projectName} initial schema\n\n`;
44
+
45
+ migrationSql += `CREATE TABLE IF NOT EXISTS cms_pages (
46
+ id TEXT PRIMARY KEY,
47
+ slug TEXT NOT NULL UNIQUE,
48
+ title TEXT NOT NULL,
49
+ template TEXT NOT NULL DEFAULT 'default',
50
+ content_json TEXT,
51
+ status TEXT NOT NULL DEFAULT 'draft',
52
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
53
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
54
+ );\n\n`;
55
+
56
+ migrationSql += `CREATE TABLE IF NOT EXISTS cms_nav_items (
57
+ id TEXT PRIMARY KEY,
58
+ label TEXT NOT NULL,
59
+ href TEXT NOT NULL,
60
+ position INTEGER NOT NULL DEFAULT 0,
61
+ parent_id TEXT,
62
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
63
+ );\n\n`;
64
+
65
+ if (pages.includes('blog')) {
66
+ migrationSql += `CREATE TABLE IF NOT EXISTS cms_posts (
67
+ id TEXT PRIMARY KEY,
68
+ slug TEXT NOT NULL UNIQUE,
69
+ title TEXT NOT NULL,
70
+ body TEXT,
71
+ published_at INTEGER,
72
+ status TEXT NOT NULL DEFAULT 'draft',
73
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
74
+ );\n\n`;
75
+ }
76
+
77
+ if (contactEmail) {
78
+ migrationSql += `CREATE TABLE IF NOT EXISTS cms_contact_submissions (
79
+ id TEXT PRIMARY KEY,
80
+ name TEXT,
81
+ email TEXT,
82
+ message TEXT,
83
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
84
+ );\n\n`;
85
+ }
86
+
87
+ // Seed nav items from selected pages
88
+ const navOrder = ['home', 'about', 'services', 'blog', 'contact'];
89
+ const navPages = navOrder.filter(p => pages.includes(p));
90
+ navPages.forEach((page, i) => {
91
+ const label = page.charAt(0).toUpperCase() + page.slice(1);
92
+ const href = page === 'home' ? '/' : `/${page}`;
93
+ migrationSql += `INSERT OR IGNORE INTO cms_nav_items (id, label, href, position) VALUES ('nav_${page}', '${label}', '${href}', ${i});\n`;
94
+ });
95
+
96
+ files['migrations/001_init.sql'] = migrationSql;
97
+
98
+ // ── Worker entry point ────────────────────────────────────────────────────
99
+ files['src/index.js'] = workerEntry(config);
100
+
101
+ // ── Route handlers ────────────────────────────────────────────────────────
102
+ files['src/routes/pages.js'] = pagesRoute(config);
103
+ files['src/routes/nav.js'] = navRoute();
104
+
105
+ if (pages.includes('blog')) {
106
+ files['src/routes/blog.js'] = blogRoute();
107
+ }
108
+
109
+ if (contactEmail) {
110
+ files['src/routes/contact.js'] = contactRoute(contactEmail);
111
+ }
112
+
113
+ // ── HTML shell template ───────────────────────────────────────────────────
114
+ files['src/templates/shell.js'] = htmlShell(config);
115
+
116
+ // ── Nav component ─────────────────────────────────────────────────────────
117
+ files['src/templates/nav.js'] = navComponent(navStyle, siteTitle);
118
+
119
+ // ── Page templates ────────────────────────────────────────────────────────
120
+ pages.forEach(page => {
121
+ files[`src/templates/pages/${page}.js`] = pageTemplate(page, siteTitle, templateStyle);
122
+ });
123
+
124
+ // ── README ────────────────────────────────────────────────────────────────
125
+ files['README.md'] = readme(config);
126
+
127
+ return files;
128
+ }
129
+
130
+ // ── Worker entry ─────────────────────────────────────────────────────────────
131
+ function workerEntry({ projectName, pages, contactEmail }) {
132
+ const imports = [`import { handlePages } from './routes/pages.js';`,
133
+ `import { handleNav } from './routes/nav.js';`];
134
+
135
+ if (pages.includes('blog')) imports.push(`import { handleBlog } from './routes/blog.js';`);
136
+ if (contactEmail) imports.push(`import { handleContact } from './routes/contact.js';`);
137
+
138
+ return `${imports.join('\n')}
139
+
140
+ export default {
141
+ async fetch(request, env, ctx) {
142
+ const url = new URL(request.url);
143
+ const { pathname } = url;
144
+
145
+ // CORS preflight
146
+ if (request.method === 'OPTIONS') {
147
+ return new Response(null, {
148
+ headers: {
149
+ 'Access-Control-Allow-Origin': '*',
150
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
151
+ 'Access-Control-Allow-Headers': 'Content-Type',
152
+ },
153
+ });
154
+ }
155
+
156
+ // API routes
157
+ if (pathname.startsWith('/api/nav')) return handleNav(request, env);
158
+ ${pages.includes('blog') ? ` if (pathname.startsWith('/api/blog')) return handleBlog(request, env);\n` : ''}${contactEmail ? ` if (pathname === '/api/contact' && request.method === 'POST') return handleContact(request, env);\n` : ''}
159
+ // Page routes — catch-all
160
+ return handlePages(request, env);
161
+ },
162
+ };
163
+ `;
164
+ }
165
+
166
+ // ── Route: pages ─────────────────────────────────────────────────────────────
167
+ function pagesRoute({ pages, templateStyle, siteTitle }) {
168
+ return `import { renderShell } from '../templates/shell.js';
169
+ import { renderNav } from '../templates/nav.js';
170
+ ${pages.map(p => `import { render${cap(p)}Page } from '../templates/pages/${p}.js';`).join('\n')}
171
+
172
+ const PAGE_MAP = {
173
+ ${pages.map(p => ` '${p === 'home' ? '/' : `/${p}`}': render${cap(p)}Page,`).join('\n')}
174
+ };
175
+
176
+ export async function handlePages(request, env) {
177
+ const url = new URL(request.url);
178
+ const slug = url.pathname.replace(/\\/$/, '') || '/';
179
+
180
+ const renderPage = PAGE_MAP[slug];
181
+ if (!renderPage) {
182
+ return new Response(renderShell('404', renderNav([]), '<h1>Page not found</h1>'), {
183
+ status: 404,
184
+ headers: { 'Content-Type': 'text/html;charset=UTF-8' },
185
+ });
186
+ }
187
+
188
+ // Load nav from D1
189
+ const navRows = await env.DB.prepare(
190
+ 'SELECT label, href FROM cms_nav_items ORDER BY position ASC'
191
+ ).all();
192
+ const nav = renderNav(navRows.results ?? []);
193
+
194
+ ${templateStyle === 'fragment'
195
+ ? ` // Load page fragment from R2
196
+ const fragmentKey = \`pages/\${slug === '/' ? 'home' : slug.slice(1)}/content.html\`;
197
+ const fragment = await env.ASSETS.get(fragmentKey);
198
+ const content = fragment ? await fragment.text() : renderPage();`
199
+ : templateStyle === 'json'
200
+ ? ` // Load page content from D1
201
+ const pageRow = await env.DB.prepare(
202
+ 'SELECT content_json FROM cms_pages WHERE slug = ? AND status = \\'published\\''
203
+ ).bind(slug === '/' ? 'home' : slug.slice(1)).first();
204
+ const content = renderPage(pageRow?.content_json ? JSON.parse(pageRow.content_json) : {});`
205
+ : ` const content = renderPage();`}
206
+
207
+ const html = renderShell('${siteTitle}', nav, content);
208
+ return new Response(html, {
209
+ headers: { 'Content-Type': 'text/html;charset=UTF-8' },
210
+ });
211
+ }
212
+ `;
213
+ }
214
+
215
+ // ── Route: nav ───────────────────────────────────────────────────────────────
216
+ function navRoute() {
217
+ return `export async function handleNav(request, env) {
218
+ const rows = await env.DB.prepare(
219
+ 'SELECT id, label, href, position FROM cms_nav_items ORDER BY position ASC'
220
+ ).all();
221
+ return Response.json(rows.results ?? []);
222
+ }
223
+ `;
224
+ }
225
+
226
+ // ── Route: blog ──────────────────────────────────────────────────────────────
227
+ function blogRoute() {
228
+ return `import { renderShell } from '../templates/shell.js';
229
+ import { renderNav } from '../templates/nav.js';
230
+
231
+ export async function handleBlog(request, env) {
232
+ const url = new URL(request.url);
233
+ const slug = url.pathname.replace('/blog/', '').replace('/blog', '');
234
+
235
+ if (slug && slug !== '/') {
236
+ // Single post
237
+ const post = await env.DB.prepare(
238
+ 'SELECT * FROM cms_posts WHERE slug = ? AND status = \\'published\\''
239
+ ).bind(slug).first();
240
+
241
+ if (!post) return new Response('Post not found', { status: 404 });
242
+
243
+ const navRows = await env.DB.prepare('SELECT label, href FROM cms_nav_items ORDER BY position ASC').all();
244
+ const html = renderShell(post.title, renderNav(navRows.results ?? []), \`
245
+ <article>
246
+ <h1>\${post.title}</h1>
247
+ <div class="post-body">\${post.body ?? ''}</div>
248
+ </article>
249
+ \`);
250
+ return new Response(html, { headers: { 'Content-Type': 'text/html;charset=UTF-8' } });
251
+ }
252
+
253
+ // Post list
254
+ const posts = await env.DB.prepare(
255
+ 'SELECT slug, title, published_at FROM cms_posts WHERE status = \\'published\\' ORDER BY published_at DESC LIMIT 20'
256
+ ).all();
257
+
258
+ const navRows = await env.DB.prepare('SELECT label, href FROM cms_nav_items ORDER BY position ASC').all();
259
+ const listHtml = (posts.results ?? []).map(p =>
260
+ \`<li><a href="/blog/\${p.slug}">\${p.title}</a></li>\`
261
+ ).join('');
262
+
263
+ const html = renderShell('Blog', renderNav(navRows.results ?? []), \`
264
+ <h1>Blog</h1>
265
+ <ul class="post-list">\${listHtml}</ul>
266
+ \`);
267
+ return new Response(html, { headers: { 'Content-Type': 'text/html;charset=UTF-8' } });
268
+ }
269
+ `;
270
+ }
271
+
272
+ // ── Route: contact ────────────────────────────────────────────────────────────
273
+ function contactRoute(email) {
274
+ return `export async function handleContact(request, env) {
275
+ let body;
276
+ try {
277
+ body = await request.json();
278
+ } catch {
279
+ return Response.json({ error: 'Invalid JSON' }, { status: 400 });
280
+ }
281
+
282
+ const { name, email: fromEmail, message } = body;
283
+ if (!name || !fromEmail || !message) {
284
+ return Response.json({ error: 'name, email, and message are required' }, { status: 400 });
285
+ }
286
+
287
+ // Save to D1
288
+ const id = crypto.randomUUID();
289
+ await env.DB.prepare(
290
+ 'INSERT INTO cms_contact_submissions (id, name, email, message) VALUES (?, ?, ?, ?)'
291
+ ).bind(id, name, fromEmail, message).run();
292
+
293
+ // Send via Resend (requires RESEND_API_KEY secret)
294
+ if (env.RESEND_API_KEY) {
295
+ await fetch('https://api.resend.com/emails', {
296
+ method: 'POST',
297
+ headers: {
298
+ Authorization: \`Bearer \${env.RESEND_API_KEY}\`,
299
+ 'Content-Type': 'application/json',
300
+ },
301
+ body: JSON.stringify({
302
+ from: 'noreply@yourdomain.com',
303
+ to: '${email}',
304
+ subject: \`New contact form submission from \${name}\`,
305
+ text: \`Name: \${name}\\nEmail: \${fromEmail}\\n\\n\${message}\`,
306
+ }),
307
+ });
308
+ }
309
+
310
+ return Response.json({ ok: true, id });
311
+ }
312
+ `;
313
+ }
314
+
315
+ // ── Template: HTML shell ──────────────────────────────────────────────────────
316
+ function htmlShell({ siteTitle }) {
317
+ return `export function renderShell(pageTitle, navHtml, bodyHtml) {
318
+ return \`<!DOCTYPE html>
319
+ <html lang="en">
320
+ <head>
321
+ <meta charset="UTF-8" />
322
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
323
+ <title>\${pageTitle} — ${siteTitle}</title>
324
+ <style>
325
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
326
+ body { font-family: system-ui, sans-serif; color: #1a1a1a; background: #fff; }
327
+ a { color: inherit; }
328
+ main { max-width: 1100px; margin: 0 auto; padding: 2rem 1rem; }
329
+ h1 { font-size: 2rem; margin-bottom: 1rem; }
330
+ h2 { font-size: 1.4rem; margin-bottom: 0.75rem; }
331
+ p { line-height: 1.65; margin-bottom: 1rem; }
332
+ </style>
333
+ </head>
334
+ <body>
335
+ \${navHtml}
336
+ <main>\${bodyHtml}</main>
337
+ </body>
338
+ </html>\`;
339
+ }
340
+ `;
341
+ }
342
+
343
+ // ── Template: nav component ───────────────────────────────────────────────────
344
+ function navComponent(navStyle, siteTitle) {
345
+ const styles = {
346
+ topbar: `
347
+ nav { display: flex; align-items: center; gap: 2rem; background: #111; padding: 0 1.5rem; height: 56px; }
348
+ nav .site-title { color: #fff; font-weight: 700; font-size: 1.1rem; text-decoration: none; margin-right: auto; }
349
+ nav a { color: #ccc; text-decoration: none; font-size: 0.9rem; }
350
+ nav a:hover { color: #fff; }`,
351
+ sidebar: `
352
+ .nav-sidebar { position: fixed; top: 0; left: 0; width: 220px; height: 100vh; background: #111; padding: 1.5rem 1rem; display: flex; flex-direction: column; gap: 0.5rem; }
353
+ .nav-sidebar .site-title { color: #fff; font-weight: 700; font-size: 1.1rem; text-decoration: none; margin-bottom: 1rem; }
354
+ .nav-sidebar a { color: #ccc; text-decoration: none; font-size: 0.9rem; padding: 0.4rem 0.6rem; border-radius: 4px; }
355
+ .nav-sidebar a:hover { background: #222; color: #fff; }
356
+ body { padding-left: 220px; }`,
357
+ minimal: `
358
+ nav { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.5rem; border-bottom: 1px solid #eee; }
359
+ nav .site-title { font-weight: 700; font-size: 1.1rem; text-decoration: none; }
360
+ .nav-toggle { background: none; border: none; cursor: pointer; font-size: 1.2rem; }`,
361
+ }[navStyle];
362
+
363
+ const navClass = navStyle === 'sidebar' ? 'class="nav-sidebar"' : '';
364
+
365
+ return `export function renderNav(items) {
366
+ const links = items.map(i => \`<a href="\${i.href}">\${i.label}</a>\`).join('');
367
+ return \`<style>${styles}</style>
368
+ <nav ${navClass}>
369
+ <a class="site-title" href="/">${siteTitle}</a>
370
+ \${links}
371
+ </nav>\`;
372
+ }
373
+ `;
374
+ }
375
+
376
+ // ── Template: individual page templates ──────────────────────────────────────
377
+ function pageTemplate(page, siteTitle, templateStyle) {
378
+ const pageContent = {
379
+ home: `<section class="hero">
380
+ <h1>Welcome to ${siteTitle}</h1>
381
+ <p>Your tagline goes here.</p>
382
+ </section>`,
383
+ about: `<h1>About Us</h1>
384
+ <p>Tell your story here.</p>`,
385
+ services: `<h1>Services</h1>
386
+ <ul>
387
+ <li>Service one</li>
388
+ <li>Service two</li>
389
+ <li>Service three</li>
390
+ </ul>`,
391
+ contact: `<h1>Contact</h1>
392
+ <form id="contact-form">
393
+ <div><label>Name<br><input type="text" name="name" required /></label></div>
394
+ <div><label>Email<br><input type="email" name="email" required /></label></div>
395
+ <div><label>Message<br><textarea name="message" required></textarea></label></div>
396
+ <button type="submit">Send</button>
397
+ </form>
398
+ <script>
399
+ document.getElementById('contact-form').addEventListener('submit', async (e) => {
400
+ e.preventDefault();
401
+ const data = Object.fromEntries(new FormData(e.target));
402
+ const res = await fetch('/api/contact', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(data) });
403
+ if (res.ok) { e.target.reset(); alert('Message sent!'); }
404
+ });
405
+ </script>`,
406
+ blog: `<h1>Blog</h1><p>Loading posts...</p>`,
407
+ privacy: `<h1>Privacy Policy</h1>
408
+ <p>Last updated: ${new Date().toLocaleDateString()}</p>
409
+ <p>We do not sell your data. Replace this with your actual privacy policy.</p>`,
410
+ };
411
+
412
+ const body = pageContent[page] ?? `<h1>${cap(page)}</h1><p>Content coming soon.</p>`;
413
+
414
+ if (templateStyle === 'json') {
415
+ return `export function render${cap(page)}Page(data = {}) {
416
+ return \`${body}\`;
417
+ }
418
+ `;
419
+ }
420
+
421
+ return `export function render${cap(page)}Page() {
422
+ return \`${body}\`;
423
+ }
424
+ `;
425
+ }
426
+
427
+ // ── README ────────────────────────────────────────────────────────────────────
428
+ function readme({ projectName, siteTitle, pages, navStyle, templateStyle }) {
429
+ return `# ${siteTitle}
430
+
431
+ Scaffolded by [@inneranimalmedia/agentsam-sdk](https://github.com/SamPrimeaux/agentsam-sdk).
432
+
433
+ ## Stack
434
+
435
+ - Cloudflare Worker (entry: \`src/index.js\`)
436
+ - D1 database (binding: \`DB\`)
437
+ - R2 bucket (binding: \`ASSETS\`)
438
+ - Template style: \`${templateStyle}\`
439
+ - Nav style: \`${navStyle}\`
440
+
441
+ ## Pages
442
+
443
+ ${pages.map(p => `- \`${p === 'home' ? '/' : `/${p}`}\` — ${cap(p)}`).join('\n')}
444
+
445
+ ## Deploy
446
+
447
+ \`\`\`bash
448
+ npm install
449
+
450
+ # Create D1 (copy the returned database_id into wrangler.toml)
451
+ npx wrangler d1 create ${projectName}
452
+
453
+ # Run migration
454
+ npx wrangler d1 execute ${projectName} --file=migrations/001_init.sql --remote
455
+
456
+ # Deploy
457
+ npx wrangler deploy
458
+ \`\`\`
459
+
460
+ ## Environment secrets
461
+
462
+ | Secret | Purpose |
463
+ |--------|---------|
464
+ | \`RESEND_API_KEY\` | Contact form email delivery (optional) |
465
+
466
+ Set with: \`npx wrangler secret put RESEND_API_KEY\`
467
+ `;
468
+ }
469
+
470
+ function cap(str) {
471
+ return str.charAt(0).toUpperCase() + str.slice(1);
472
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Worker API template generator.
3
+ */
4
+
5
+ export function workerApiTemplates({ projectName, routes, cfAccountId }) {
6
+ const files = {};
7
+
8
+ files['package.json'] = JSON.stringify({
9
+ name: projectName,
10
+ version: '0.1.0',
11
+ private: true,
12
+ scripts: {
13
+ deploy: 'wrangler deploy',
14
+ dev: 'wrangler dev',
15
+ 'db:migrate': `wrangler d1 execute ${projectName} --file=migrations/001_init.sql --remote`,
16
+ },
17
+ devDependencies: { wrangler: '^3.0.0' },
18
+ }, null, 2);
19
+
20
+ files['wrangler.toml'] = `name = "${projectName}"
21
+ main = "src/index.js"
22
+ compatibility_date = "2024-01-01"
23
+ account_id = "${cfAccountId}"
24
+
25
+ [[d1_databases]]
26
+ binding = "DB"
27
+ database_name = "${projectName}"
28
+ database_id = "REPLACE_WITH_YOUR_D1_ID"
29
+ `;
30
+
31
+ // Entry
32
+ const routeImports = routes.map(r => `import { handle${cap(r)} } from './routes/${r}.js';`).join('\n');
33
+ const routeMatches = routes.map(r => {
34
+ const path = r === 'health' ? `pathname === '/health'`
35
+ : r === 'auth' ? `pathname.startsWith('/auth')`
36
+ : r === 'webhook' ? `pathname.startsWith('/webhooks')`
37
+ : `pathname.startsWith('/${r}')`;
38
+ return ` if (${path}) return handle${cap(r)}(request, env);`;
39
+ }).join('\n');
40
+
41
+ files['src/index.js'] = `${routeImports}
42
+
43
+ export default {
44
+ async fetch(request, env) {
45
+ const url = new URL(request.url);
46
+ const { pathname } = url;
47
+
48
+ if (request.method === 'OPTIONS') {
49
+ return new Response(null, {
50
+ headers: {
51
+ 'Access-Control-Allow-Origin': '*',
52
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
53
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
54
+ },
55
+ });
56
+ }
57
+
58
+ ${routeMatches}
59
+
60
+ return Response.json({ error: 'Not found' }, { status: 404 });
61
+ },
62
+ };
63
+ `;
64
+
65
+ // Route stubs
66
+ if (routes.includes('health')) {
67
+ files['src/routes/health.js'] = `export async function handleHealth(request, env) {
68
+ return Response.json({ ok: true, ts: Date.now() });
69
+ }
70
+ `;
71
+ }
72
+
73
+ if (routes.includes('auth')) {
74
+ files['src/routes/auth.js'] = `export async function handleAuth(request, env) {
75
+ const url = new URL(request.url);
76
+ if (url.pathname === '/auth/token' && request.method === 'POST') {
77
+ // TODO: issue token
78
+ return Response.json({ token: 'replace-me' });
79
+ }
80
+ return Response.json({ error: 'Unknown auth route' }, { status: 404 });
81
+ }
82
+ `;
83
+ }
84
+
85
+ if (routes.includes('users')) {
86
+ files['src/routes/users.js'] = `export async function handleUsers(request, env) {
87
+ const url = new URL(request.url);
88
+ const id = url.pathname.replace('/users/', '').replace('/users', '') || null;
89
+
90
+ if (request.method === 'GET' && !id) {
91
+ const rows = await env.DB.prepare('SELECT * FROM users LIMIT 50').all();
92
+ return Response.json(rows.results ?? []);
93
+ }
94
+ if (request.method === 'GET' && id) {
95
+ const row = await env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(id).first();
96
+ if (!row) return Response.json({ error: 'Not found' }, { status: 404 });
97
+ return Response.json(row);
98
+ }
99
+ if (request.method === 'POST') {
100
+ const body = await request.json();
101
+ const newId = crypto.randomUUID();
102
+ await env.DB.prepare('INSERT INTO users (id, email) VALUES (?, ?)').bind(newId, body.email).run();
103
+ return Response.json({ id: newId }, { status: 201 });
104
+ }
105
+ if (request.method === 'DELETE' && id) {
106
+ await env.DB.prepare('DELETE FROM users WHERE id = ?').bind(id).run();
107
+ return Response.json({ ok: true });
108
+ }
109
+ return Response.json({ error: 'Method not allowed' }, { status: 405 });
110
+ }
111
+ `;
112
+ }
113
+
114
+ if (routes.includes('content')) {
115
+ files['src/routes/content.js'] = `export async function handleContent(request, env) {
116
+ const url = new URL(request.url);
117
+ const slug = url.pathname.replace('/content/', '').replace('/content', '') || null;
118
+
119
+ if (request.method === 'GET' && !slug) {
120
+ const rows = await env.DB.prepare("SELECT id, slug, title, status FROM cms_pages LIMIT 50").all();
121
+ return Response.json(rows.results ?? []);
122
+ }
123
+ if (request.method === 'GET' && slug) {
124
+ const row = await env.DB.prepare('SELECT * FROM cms_pages WHERE slug = ?').bind(slug).first();
125
+ if (!row) return Response.json({ error: 'Not found' }, { status: 404 });
126
+ return Response.json(row);
127
+ }
128
+ if (request.method === 'POST') {
129
+ const body = await request.json();
130
+ const id = crypto.randomUUID();
131
+ await env.DB.prepare(
132
+ 'INSERT INTO cms_pages (id, slug, title, template, content_json) VALUES (?, ?, ?, ?, ?)'
133
+ ).bind(id, body.slug, body.title, body.template ?? 'default', JSON.stringify(body.content ?? {})).run();
134
+ return Response.json({ id }, { status: 201 });
135
+ }
136
+ return Response.json({ error: 'Method not allowed' }, { status: 405 });
137
+ }
138
+ `;
139
+ }
140
+
141
+ if (routes.includes('webhook')) {
142
+ files['src/routes/webhook.js'] = `export async function handleWebhook(request, env) {
143
+ const url = new URL(request.url);
144
+ const type = url.pathname.replace('/webhooks/', '').replace('/webhooks', '') || 'unknown';
145
+
146
+ let body;
147
+ try { body = await request.json(); } catch { body = {}; }
148
+
149
+ console.log(\`[webhook] type=\${type}\`, JSON.stringify(body));
150
+
151
+ // TODO: dispatch based on type
152
+ return Response.json({ received: true, type });
153
+ }
154
+ `;
155
+ }
156
+
157
+ // Migration
158
+ let sql = `-- ${projectName} initial schema\n\n`;
159
+ if (routes.includes('users')) {
160
+ sql += `CREATE TABLE IF NOT EXISTS users (
161
+ id TEXT PRIMARY KEY,
162
+ email TEXT NOT NULL UNIQUE,
163
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
164
+ );\n\n`;
165
+ }
166
+ if (routes.includes('content')) {
167
+ sql += `CREATE TABLE IF NOT EXISTS cms_pages (
168
+ id TEXT PRIMARY KEY,
169
+ slug TEXT NOT NULL UNIQUE,
170
+ title TEXT NOT NULL,
171
+ template TEXT NOT NULL DEFAULT 'default',
172
+ content_json TEXT,
173
+ status TEXT NOT NULL DEFAULT 'draft',
174
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
175
+ );\n\n`;
176
+ }
177
+
178
+ files['migrations/001_init.sql'] = sql;
179
+
180
+ files['README.md'] = `# ${projectName}
181
+
182
+ Scaffolded by [@inneranimalmedia/agentsam-sdk](https://github.com/SamPrimeaux/agentsam-sdk).
183
+
184
+ ## Routes
185
+
186
+ ${routes.map(r => `- \`/${r}\``).join('\n')}
187
+
188
+ ## Deploy
189
+
190
+ \`\`\`bash
191
+ npm install
192
+ npx wrangler d1 create ${projectName}
193
+ # paste database_id into wrangler.toml
194
+ npx wrangler d1 execute ${projectName} --file=migrations/001_init.sql --remote
195
+ npx wrangler deploy
196
+ \`\`\`
197
+ `;
198
+
199
+ return files;
200
+ }
201
+
202
+ function cap(str) {
203
+ return str.charAt(0).toUpperCase() + str.slice(1);
204
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * CMS Site Scaffold Wizard
3
+ *
4
+ * Guides the user step-by-step through scaffolding a
5
+ * Cloudflare Worker + D1 + R2 CMS site with:
6
+ * - global nav
7
+ * - reusable page templates
8
+ * - optional blog, contact, about pages
9
+ * - wrangler.toml ready to deploy
10
+ */
11
+
12
+ import {
13
+ text,
14
+ select,
15
+ multiselect,
16
+ confirm,
17
+ spinner,
18
+ note,
19
+ isCancel,
20
+ cancel,
21
+ } from '@clack/prompts';
22
+ import pc from 'picocolors';
23
+ import { writeFileTree } from '../writer.js';
24
+ import { cmsTemplates } from '../templates/cms/index.js';
25
+
26
+ export async function runCmsWizard() {
27
+ note('A Cloudflare Worker + D1 + R2 CMS site with reusable page templates.', 'CMS Site');
28
+
29
+ // ── Step 1: Project name ──────────────────────────────────────────────────
30
+ const projectName = await text({
31
+ message: 'Project name?',
32
+ placeholder: 'my-client-site',
33
+ validate(val) {
34
+ if (!val || val.trim().length === 0) return 'Required.';
35
+ if (!/^[a-z0-9-]+$/.test(val.trim())) return 'Lowercase letters, numbers, and hyphens only.';
36
+ },
37
+ });
38
+ if (isCancel(projectName)) { cancel('Cancelled.'); process.exit(0); }
39
+
40
+ // ── Step 2: Site title ────────────────────────────────────────────────────
41
+ const siteTitle = await text({
42
+ message: 'Site title?',
43
+ placeholder: 'Acme Corp',
44
+ validate(val) {
45
+ if (!val || val.trim().length === 0) return 'Required.';
46
+ },
47
+ });
48
+ if (isCancel(siteTitle)) { cancel('Cancelled.'); process.exit(0); }
49
+
50
+ // ── Step 3: Nav style ─────────────────────────────────────────────────────
51
+ const navStyle = await select({
52
+ message: 'Global nav style?',
53
+ options: [
54
+ { value: 'topbar', label: 'Top bar', hint: 'Horizontal links, fixed to top' },
55
+ { value: 'sidebar', label: 'Sidebar', hint: 'Left rail, collapsible on mobile' },
56
+ { value: 'minimal', label: 'Minimal', hint: 'Logo + hamburger only' },
57
+ ],
58
+ });
59
+ if (isCancel(navStyle)) { cancel('Cancelled.'); process.exit(0); }
60
+
61
+ // ── Step 4: Pages to include ──────────────────────────────────────────────
62
+ const pages = await multiselect({
63
+ message: 'Which pages do you want? (space to toggle, enter to confirm)',
64
+ options: [
65
+ { value: 'home', label: 'Home', hint: 'Landing / hero section' },
66
+ { value: 'about', label: 'About', hint: 'About us / team' },
67
+ { value: 'services', label: 'Services', hint: 'Services or products list' },
68
+ { value: 'blog', label: 'Blog', hint: 'D1-backed post list + detail' },
69
+ { value: 'contact', label: 'Contact', hint: 'Contact form → Resend email' },
70
+ { value: 'privacy', label: 'Privacy', hint: 'Privacy policy (static)' },
71
+ ],
72
+ required: true,
73
+ });
74
+ if (isCancel(pages)) { cancel('Cancelled.'); process.exit(0); }
75
+
76
+ // ── Step 5: Page template style ───────────────────────────────────────────
77
+ const templateStyle = await select({
78
+ message: 'Page template style?',
79
+ options: [
80
+ {
81
+ value: 'fragment',
82
+ label: 'R2 HTML fragments',
83
+ hint: 'Each section is a .html fragment stored in R2, assembled by the Worker',
84
+ },
85
+ {
86
+ value: 'json',
87
+ label: 'D1-driven JSON blocks',
88
+ hint: 'Page content stored in D1 as JSON blocks, rendered server-side',
89
+ },
90
+ {
91
+ value: 'static',
92
+ label: 'Static HTML',
93
+ hint: 'Pre-rendered HTML files, no D1 dependency',
94
+ },
95
+ ],
96
+ });
97
+ if (isCancel(templateStyle)) { cancel('Cancelled.'); process.exit(0); }
98
+
99
+ // ── Step 6: Contact form destination (only if contact page selected) ──────
100
+ let contactEmail = null;
101
+ if (pages.includes('contact')) {
102
+ contactEmail = await text({
103
+ message: 'Where should contact form submissions go? (email)',
104
+ placeholder: 'hello@example.com',
105
+ validate(val) {
106
+ if (!val || val.trim().length === 0) return 'Required for contact page.';
107
+ if (!val.includes('@')) return 'Enter a valid email.';
108
+ },
109
+ });
110
+ if (isCancel(contactEmail)) { cancel('Cancelled.'); process.exit(0); }
111
+ }
112
+
113
+ // ── Step 7: Cloudflare account details ────────────────────────────────────
114
+ const cfAccountId = await text({
115
+ message: 'Cloudflare account ID? (from dash.cloudflare.com → right sidebar)',
116
+ placeholder: 'abc123...',
117
+ validate(val) {
118
+ if (!val || val.trim().length === 0) return 'Required for wrangler.toml.';
119
+ },
120
+ });
121
+ if (isCancel(cfAccountId)) { cancel('Cancelled.'); process.exit(0); }
122
+
123
+ // ── Step 8: Confirm before writing ───────────────────────────────────────
124
+ const summary = [
125
+ ` Project: ${pc.cyan(projectName)}`,
126
+ ` Title: ${pc.cyan(siteTitle)}`,
127
+ ` Nav: ${pc.cyan(navStyle)}`,
128
+ ` Pages: ${pc.cyan(pages.join(', '))}`,
129
+ ` Template: ${pc.cyan(templateStyle)}`,
130
+ contactEmail ? ` Contact: ${pc.cyan(contactEmail)}` : null,
131
+ ].filter(Boolean).join('\n');
132
+
133
+ note(summary, 'Your scaffold');
134
+
135
+ const confirmed = await confirm({ message: 'Write files now?' });
136
+ if (isCancel(confirmed) || !confirmed) { cancel('Cancelled.'); process.exit(0); }
137
+
138
+ // ── Write files ───────────────────────────────────────────────────────────
139
+ const s = spinner();
140
+ s.start('Generating files...');
141
+
142
+ const config = {
143
+ projectName: projectName.trim(),
144
+ siteTitle: siteTitle.trim(),
145
+ navStyle,
146
+ pages,
147
+ templateStyle,
148
+ contactEmail: contactEmail?.trim() ?? null,
149
+ cfAccountId: cfAccountId.trim(),
150
+ };
151
+
152
+ const fileTree = cmsTemplates(config);
153
+
154
+ await writeFileTree(`./${config.projectName}`, fileTree);
155
+
156
+ s.stop(pc.green(`Files written to ./${config.projectName}/`));
157
+
158
+ note(
159
+ [
160
+ `cd ${config.projectName}`,
161
+ `npm install`,
162
+ ``,
163
+ `# Create your D1 database:`,
164
+ `npx wrangler d1 create ${config.projectName}`,
165
+ `# Copy the database_id into wrangler.toml [[d1_databases]] binding`,
166
+ ``,
167
+ `# Run the D1 migration:`,
168
+ `npx wrangler d1 execute ${config.projectName} --file=migrations/001_init.sql --remote`,
169
+ ``,
170
+ `# Deploy:`,
171
+ `npx wrangler deploy`,
172
+ ].join('\n'),
173
+ 'Next steps'
174
+ );
175
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Worker API Scaffold Wizard
3
+ * Bare Cloudflare Worker with typed route handlers and D1 binding.
4
+ */
5
+
6
+ import {
7
+ text,
8
+ multiselect,
9
+ confirm,
10
+ spinner,
11
+ note,
12
+ isCancel,
13
+ cancel,
14
+ } from '@clack/prompts';
15
+ import pc from 'picocolors';
16
+ import { writeFileTree } from '../writer.js';
17
+ import { workerApiTemplates } from '../templates/worker-api/index.js';
18
+
19
+ export async function runWorkerApiWizard() {
20
+ note('A typed Cloudflare Worker with route handlers, D1 binding, and CORS ready.', 'Worker API');
21
+
22
+ const projectName = await text({
23
+ message: 'Project name?',
24
+ placeholder: 'my-api-worker',
25
+ validate(val) {
26
+ if (!val || val.trim().length === 0) return 'Required.';
27
+ if (!/^[a-z0-9-]+$/.test(val.trim())) return 'Lowercase letters, numbers, and hyphens only.';
28
+ },
29
+ });
30
+ if (isCancel(projectName)) { cancel('Cancelled.'); process.exit(0); }
31
+
32
+ const routes = await multiselect({
33
+ message: 'Which route groups do you want?',
34
+ options: [
35
+ { value: 'health', label: 'GET /health', hint: 'Liveness check' },
36
+ { value: 'auth', label: 'POST /auth/*', hint: 'Token issue + verify' },
37
+ { value: 'users', label: 'CRUD /users', hint: 'D1-backed user records' },
38
+ { value: 'content', label: 'CRUD /content', hint: 'Generic content/pages' },
39
+ { value: 'webhook', label: 'POST /webhooks/:type', hint: 'Inbound webhook receiver' },
40
+ ],
41
+ required: true,
42
+ });
43
+ if (isCancel(routes)) { cancel('Cancelled.'); process.exit(0); }
44
+
45
+ const cfAccountId = await text({
46
+ message: 'Cloudflare account ID?',
47
+ placeholder: 'abc123...',
48
+ validate(val) {
49
+ if (!val || val.trim().length === 0) return 'Required.';
50
+ },
51
+ });
52
+ if (isCancel(cfAccountId)) { cancel('Cancelled.'); process.exit(0); }
53
+
54
+ const confirmed = await confirm({ message: `Write files to ./${projectName.trim()}/?` });
55
+ if (isCancel(confirmed) || !confirmed) { cancel('Cancelled.'); process.exit(0); }
56
+
57
+ const s = spinner();
58
+ s.start('Generating files...');
59
+
60
+ const config = { projectName: projectName.trim(), routes, cfAccountId: cfAccountId.trim() };
61
+ const fileTree = workerApiTemplates(config);
62
+ await writeFileTree(`./${config.projectName}`, fileTree);
63
+
64
+ s.stop(pc.green(`Files written to ./${config.projectName}/`));
65
+
66
+ note(
67
+ [
68
+ `cd ${config.projectName}`,
69
+ `npm install`,
70
+ `npx wrangler d1 create ${config.projectName}`,
71
+ `npx wrangler d1 execute ${config.projectName} --file=migrations/001_init.sql --remote`,
72
+ `npx wrangler deploy`,
73
+ ].join('\n'),
74
+ 'Next steps'
75
+ );
76
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Writes a nested file-tree object to disk.
3
+ *
4
+ * fileTree shape:
5
+ * {
6
+ * 'src/index.js': '// content',
7
+ * 'wrangler.toml': '...',
8
+ * 'migrations/001_init.sql': '...',
9
+ * }
10
+ */
11
+
12
+ import fs from 'fs/promises';
13
+ import path from 'path';
14
+
15
+ export async function writeFileTree(baseDir, fileTree) {
16
+ for (const [relativePath, content] of Object.entries(fileTree)) {
17
+ const fullPath = path.join(baseDir, relativePath);
18
+ const dir = path.dirname(fullPath);
19
+ await fs.mkdir(dir, { recursive: true });
20
+ await fs.writeFile(fullPath, content, 'utf8');
21
+ }
22
+ }
package/test/smoke.mjs CHANGED
@@ -48,8 +48,8 @@ printContextSummary({
48
48
  iam: { ready: true, source: 'sdk-token', detail: 'AGENTSAM_SDK_TOKEN' },
49
49
  gcp: { source: 'vm-metadata', email: 'execos@project.iam.gserviceaccount.com' },
50
50
  gcp_vm: true,
51
- github: { source: 'gh-cli', account: 'connor@example.com' },
52
- cloudflare: { source: 'wrangler', account: 'connor@cloudflare.test' },
51
+ github: { source: 'gh-cli', account: 'user@example.com' },
52
+ cloudflare: { source: 'wrangler', account: 'user@cloudflare.test' },
53
53
  });
54
54
  assert.deepEqual(missingForInit({ iam: { ready: false } }, '', { runTarget: 'local' }), []);
55
55
  assert.deepEqual(missingForInit({ iam: { ready: false } }, '', { runTarget: 'cloudflare' }), ['iam']);