@poncho-ai/cli 0.36.9 → 0.38.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,528 @@
1
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, relative, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import {
5
+ parseAgentMarkdown,
6
+ loadPonchoConfig,
7
+ type CronJobConfig,
8
+ type PonchoConfig,
9
+ } from "@poncho-ai/harness";
10
+ import type { DeployTarget } from "./init-onboarding.js";
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ export const packageRoot = resolve(__dirname, "..");
14
+
15
+ export const ensureFile = async (path: string, content: string): Promise<void> => {
16
+ await mkdir(dirname(path), { recursive: true });
17
+ await writeFile(path, content, { encoding: "utf8", flag: "wx" });
18
+ };
19
+
20
+ type DeployScaffoldTarget = Exclude<DeployTarget, "none">;
21
+
22
+ export const normalizeDeployTarget = (target: string): DeployScaffoldTarget => {
23
+ const normalized = target.toLowerCase();
24
+ if (
25
+ normalized === "vercel" ||
26
+ normalized === "docker" ||
27
+ normalized === "lambda" ||
28
+ normalized === "fly"
29
+ ) {
30
+ return normalized;
31
+ }
32
+ throw new Error(`Unsupported build target: ${target}`);
33
+ };
34
+
35
+ export const readCliVersion = async (): Promise<string> => {
36
+ const fallback = "0.1.0";
37
+ try {
38
+ const packageJsonPath = resolve(packageRoot, "package.json");
39
+ const content = await readFile(packageJsonPath, "utf8");
40
+ const parsed = JSON.parse(content) as { version?: unknown };
41
+ if (typeof parsed.version === "string" && parsed.version.trim().length > 0) {
42
+ return parsed.version;
43
+ }
44
+ } catch {
45
+ // Use fallback when package metadata cannot be read.
46
+ }
47
+ return fallback;
48
+ };
49
+
50
+ export const readCliDependencyVersion = async (
51
+ dependencyName: string,
52
+ fallback: string,
53
+ ): Promise<string> => {
54
+ try {
55
+ const packageJsonPath = resolve(packageRoot, "package.json");
56
+ const content = await readFile(packageJsonPath, "utf8");
57
+ const parsed = JSON.parse(content) as { dependencies?: Record<string, unknown> };
58
+ const value = parsed.dependencies?.[dependencyName];
59
+ if (typeof value === "string" && value.trim().length > 0) {
60
+ return value;
61
+ }
62
+ } catch {
63
+ // Use fallback when package metadata cannot be read.
64
+ }
65
+ return fallback;
66
+ };
67
+
68
+ export const writeScaffoldFile = async (
69
+ filePath: string,
70
+ content: string,
71
+ options: { force?: boolean; writtenPaths: string[]; baseDir: string },
72
+ ): Promise<void> => {
73
+ if (!options.force) {
74
+ try {
75
+ await access(filePath);
76
+ throw new Error(
77
+ `Refusing to overwrite existing file: ${relative(options.baseDir, filePath)}. Re-run with --force to overwrite.`,
78
+ );
79
+ } catch (error) {
80
+ if (!(error instanceof Error) || !error.message.includes("Refusing to overwrite")) {
81
+ // File does not exist, safe to continue.
82
+ } else {
83
+ throw error;
84
+ }
85
+ }
86
+ }
87
+ await mkdir(dirname(filePath), { recursive: true });
88
+ await writeFile(filePath, content, "utf8");
89
+ options.writtenPaths.push(relative(options.baseDir, filePath));
90
+ };
91
+
92
+ export const UPLOAD_PROVIDER_DEPS: Record<string, Array<{ name: string; fallback: string }>> = {
93
+ "vercel-blob": [{ name: "@vercel/blob", fallback: "^2.3.0" }],
94
+ s3: [
95
+ { name: "@aws-sdk/client-s3", fallback: "^3.700.0" },
96
+ { name: "@aws-sdk/s3-request-presigner", fallback: "^3.700.0" },
97
+ ],
98
+ };
99
+
100
+ export const ensureRuntimeCliDependency = async (
101
+ projectDir: string,
102
+ cliVersion: string,
103
+ config?: PonchoConfig,
104
+ target?: string,
105
+ ): Promise<{ paths: string[]; addedDeps: string[] }> => {
106
+ const packageJsonPath = resolve(projectDir, "package.json");
107
+ const content = await readFile(packageJsonPath, "utf8");
108
+ const parsed = JSON.parse(content) as {
109
+ dependencies?: Record<string, string>;
110
+ devDependencies?: Record<string, string>;
111
+ };
112
+ const dependencies = { ...(parsed.dependencies ?? {}) };
113
+ const isLocalOnlySpecifier = (value: string | undefined): boolean =>
114
+ typeof value === "string" &&
115
+ (value.startsWith("link:") || value.startsWith("workspace:") || value.startsWith("file:"));
116
+
117
+ // Deployment projects should not depend on local monorepo paths.
118
+ if (isLocalOnlySpecifier(dependencies["@poncho-ai/harness"])) {
119
+ delete dependencies["@poncho-ai/harness"];
120
+ }
121
+ if (isLocalOnlySpecifier(dependencies["@poncho-ai/sdk"])) {
122
+ delete dependencies["@poncho-ai/sdk"];
123
+ }
124
+ dependencies.marked = await readCliDependencyVersion("marked", "^17.0.2");
125
+ dependencies["@poncho-ai/cli"] = `^${cliVersion}`;
126
+
127
+ const addedDeps: string[] = [];
128
+ const uploadsProvider = config?.uploads?.provider;
129
+ if (uploadsProvider && UPLOAD_PROVIDER_DEPS[uploadsProvider]) {
130
+ for (const dep of UPLOAD_PROVIDER_DEPS[uploadsProvider]) {
131
+ if (!dependencies[dep.name]) {
132
+ dependencies[dep.name] = dep.fallback;
133
+ addedDeps.push(dep.name);
134
+ }
135
+ }
136
+ }
137
+
138
+ if (target === "vercel" && !dependencies["@vercel/functions"]) {
139
+ dependencies["@vercel/functions"] = "^1.0.0";
140
+ addedDeps.push("@vercel/functions");
141
+ }
142
+
143
+ parsed.dependencies = dependencies;
144
+ await writeFile(packageJsonPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
145
+ return { paths: [relative(projectDir, packageJsonPath)], addedDeps };
146
+ };
147
+
148
+ export const checkVercelCronDrift = async (projectDir: string): Promise<void> => {
149
+ const vercelJsonPath = resolve(projectDir, "vercel.json");
150
+ try {
151
+ await access(vercelJsonPath);
152
+ } catch {
153
+ return;
154
+ }
155
+ let agentCrons: Record<string, CronJobConfig> = {};
156
+ try {
157
+ const agentMd = await readFile(resolve(projectDir, "AGENT.md"), "utf8");
158
+ const parsed = parseAgentMarkdown(agentMd);
159
+ agentCrons = parsed.frontmatter.cron ?? {};
160
+ } catch {
161
+ return;
162
+ }
163
+ let vercelCrons: Array<{ path: string; schedule: string }> = [];
164
+ try {
165
+ const raw = await readFile(vercelJsonPath, "utf8");
166
+ const vercelConfig = JSON.parse(raw) as { crons?: Array<{ path: string; schedule: string }> };
167
+ vercelCrons = vercelConfig.crons ?? [];
168
+ } catch {
169
+ return;
170
+ }
171
+ const vercelCronMap = new Map(
172
+ vercelCrons
173
+ .filter((c) => c.path.startsWith("/api/cron/"))
174
+ .map((c) => [decodeURIComponent(c.path.replace("/api/cron/", "")), c.schedule]),
175
+ );
176
+ const diffs: string[] = [];
177
+ for (const [jobName, job] of Object.entries(agentCrons)) {
178
+ const existing = vercelCronMap.get(jobName);
179
+ if (!existing) {
180
+ diffs.push(` + missing job "${jobName}" (${job.schedule})`);
181
+ } else if (existing !== job.schedule) {
182
+ diffs.push(` ~ "${jobName}" schedule changed: "${existing}" → "${job.schedule}"`);
183
+ }
184
+ vercelCronMap.delete(jobName);
185
+ }
186
+ for (const [jobName, schedule] of vercelCronMap) {
187
+ diffs.push(` - removed job "${jobName}" (${schedule})`);
188
+ }
189
+
190
+ // Check reminder polling cron
191
+ try {
192
+ const cfg = await loadPonchoConfig(projectDir);
193
+ const reminderCron = vercelCrons.find((c) => c.path === "/api/reminders/check");
194
+ if (cfg?.reminders?.enabled && !reminderCron) {
195
+ diffs.push(` + missing reminders polling cron`);
196
+ } else if (!cfg?.reminders?.enabled && reminderCron) {
197
+ diffs.push(` - reminders polling cron present but reminders disabled`);
198
+ } else if (cfg?.reminders?.enabled && reminderCron) {
199
+ const expected = cfg.reminders.pollSchedule ?? "*/10 * * * *";
200
+ if (reminderCron.schedule !== expected) {
201
+ diffs.push(` ~ reminders poll schedule changed: "${reminderCron.schedule}" → "${expected}"`);
202
+ }
203
+ }
204
+ } catch { /* best-effort */ }
205
+
206
+ if (diffs.length > 0) {
207
+ process.stderr.write(
208
+ `\u26A0 vercel.json crons are out of sync with AGENT.md / poncho.config.js:\n${diffs.join("\n")}\n Run \`poncho build vercel --force\` to update.\n\n`,
209
+ );
210
+ }
211
+ };
212
+
213
+ export const scaffoldDeployTarget = async (
214
+ projectDir: string,
215
+ target: DeployScaffoldTarget,
216
+ options?: { force?: boolean },
217
+ ): Promise<string[]> => {
218
+ const writtenPaths: string[] = [];
219
+ const cliVersion = await readCliVersion();
220
+ const sharedServerEntrypoint = `import { startDevServer } from "@poncho-ai/cli";
221
+
222
+ const port = Number.parseInt(process.env.PORT ?? "3000", 10);
223
+ await startDevServer(Number.isNaN(port) ? 3000 : port, { workingDir: process.cwd() });
224
+ `;
225
+
226
+ if (target === "vercel") {
227
+ // Build @vercel/nft trace hints for packages that are dynamically loaded
228
+ // at runtime. Bare `import("pkg")` with a string literal is enough for
229
+ // nft to include the package in the bundle. Using async import() avoids
230
+ // blocking the module graph at cold start; .catch() prevents errors when
231
+ // an optional package isn't installed.
232
+ const traceHints: string[] = [];
233
+
234
+ let browserEnabled = false;
235
+ try {
236
+ const cfg = await loadPonchoConfig(projectDir);
237
+ browserEnabled = !!cfg?.browser;
238
+ } catch { /* best-effort */ }
239
+
240
+ if (browserEnabled) {
241
+ traceHints.push(`import("@poncho-ai/browser").catch(() => {});`);
242
+
243
+ const projectPkgPath = resolve(projectDir, "package.json");
244
+ try {
245
+ const raw = await readFile(projectPkgPath, "utf8");
246
+ const pkg = JSON.parse(raw) as { dependencies?: Record<string, string> };
247
+ if (pkg.dependencies?.["@sparticuz/chromium"]) {
248
+ traceHints.push(`import("@sparticuz/chromium").catch(() => {});`);
249
+ }
250
+ } catch { /* best-effort */ }
251
+ }
252
+
253
+ const traceBlock = traceHints.length > 0
254
+ ? `\n${traceHints.join("\n")}\n`
255
+ : "";
256
+
257
+ const entryPath = resolve(projectDir, "api", "index.mjs");
258
+ await writeScaffoldFile(
259
+ entryPath,
260
+ `import "marked";${traceBlock}
261
+ import { createRequestHandler } from "@poncho-ai/cli";
262
+ let handlerPromise;
263
+ export default async function handler(req, res) {
264
+ try {
265
+ if (!handlerPromise) {
266
+ handlerPromise = createRequestHandler({ workingDir: process.cwd() });
267
+ }
268
+ const requestHandler = await handlerPromise;
269
+ await requestHandler(req, res);
270
+ } catch (error) {
271
+ console.error("Handler error:", error);
272
+ if (!res.headersSent) {
273
+ res.writeHead(500, { "Content-Type": "application/json" });
274
+ res.end(JSON.stringify({ error: "Internal server error", message: error?.message || "Unknown error" }));
275
+ }
276
+ }
277
+ }
278
+ `,
279
+ { force: options?.force, writtenPaths, baseDir: projectDir },
280
+ );
281
+ const vercelConfigPath = resolve(projectDir, "vercel.json");
282
+ let vercelCrons: Array<{ path: string; schedule: string }> | undefined;
283
+ try {
284
+ const agentMd = await readFile(resolve(projectDir, "AGENT.md"), "utf8");
285
+ const parsed = parseAgentMarkdown(agentMd);
286
+ if (parsed.frontmatter.cron) {
287
+ vercelCrons = Object.entries(parsed.frontmatter.cron).map(
288
+ ([jobName, job]) => ({
289
+ path: `/api/cron/${encodeURIComponent(jobName)}`,
290
+ schedule: job.schedule,
291
+ }),
292
+ );
293
+ }
294
+ } catch {
295
+ // AGENT.md may not exist yet during init; skip cron generation
296
+ }
297
+ let existingVercelConfig: Record<string, unknown> = {};
298
+ try {
299
+ const raw = await readFile(vercelConfigPath, "utf8");
300
+ existingVercelConfig = JSON.parse(raw) as Record<string, unknown>;
301
+ } catch {
302
+ // No existing vercel.json or invalid JSON — start fresh
303
+ }
304
+ const existingFunctions = (existingVercelConfig.functions ?? {}) as Record<string, Record<string, unknown>>;
305
+ const existingApiEntry = existingFunctions["api/index.mjs"] ?? {};
306
+ const vercelConfig: Record<string, unknown> = {
307
+ ...existingVercelConfig,
308
+ version: 2,
309
+ functions: {
310
+ ...existingFunctions,
311
+ "api/index.mjs": {
312
+ ...existingApiEntry,
313
+ includeFiles:
314
+ "{AGENT.md,poncho.config.js,skills/**,tests/**,node_modules/.pnpm/marked@*/node_modules/marked/lib/marked.umd.js}",
315
+ },
316
+ },
317
+ headers: [
318
+ {
319
+ source: "/api/(.*)",
320
+ headers: [
321
+ { key: "Cache-Control", value: "private, no-cache, no-store, must-revalidate" },
322
+ ],
323
+ },
324
+ ],
325
+ routes: [{ src: "/(.*)", dest: "/api/index.mjs" }],
326
+ };
327
+ // Add reminder polling cron if reminders are enabled
328
+ try {
329
+ const cfg = await loadPonchoConfig(projectDir);
330
+ if (cfg?.reminders?.enabled) {
331
+ const schedule = cfg.reminders.pollSchedule ?? "*/10 * * * *";
332
+ if (!vercelCrons) vercelCrons = [];
333
+ vercelCrons.push({ path: "/api/reminders/check", schedule });
334
+ }
335
+ } catch { /* best-effort */ }
336
+
337
+ if (vercelCrons && vercelCrons.length > 0) {
338
+ vercelConfig.crons = vercelCrons;
339
+ }
340
+ await writeScaffoldFile(
341
+ vercelConfigPath,
342
+ `${JSON.stringify(vercelConfig, null, 2)}\n`,
343
+ { force: options?.force, writtenPaths, baseDir: projectDir },
344
+ );
345
+ } else if (target === "docker") {
346
+ const dockerfilePath = resolve(projectDir, "Dockerfile");
347
+ await writeScaffoldFile(
348
+ dockerfilePath,
349
+ `FROM node:20-slim
350
+ WORKDIR /app
351
+ COPY package.json package.json
352
+ COPY AGENT.md AGENT.md
353
+ COPY poncho.config.js poncho.config.js
354
+ COPY skills skills
355
+ COPY tests tests
356
+ COPY .env.example .env.example
357
+ RUN corepack enable && npm install -g @poncho-ai/cli@^${cliVersion}
358
+ COPY server.js server.js
359
+ EXPOSE 3000
360
+ CMD ["node","server.js"]
361
+ `,
362
+ { force: options?.force, writtenPaths, baseDir: projectDir },
363
+ );
364
+ await writeScaffoldFile(resolve(projectDir, "server.js"), sharedServerEntrypoint, {
365
+ force: options?.force,
366
+ writtenPaths,
367
+ baseDir: projectDir,
368
+ });
369
+ } else if (target === "lambda") {
370
+ await writeScaffoldFile(
371
+ resolve(projectDir, "lambda-handler.js"),
372
+ `import { startDevServer } from "@poncho-ai/cli";
373
+ let serverPromise;
374
+ export const handler = async (event = {}) => {
375
+ if (!serverPromise) {
376
+ serverPromise = startDevServer(0, { workingDir: process.cwd() });
377
+ }
378
+ const body = JSON.stringify({
379
+ status: "ready",
380
+ route: event.rawPath ?? event.path ?? "/",
381
+ });
382
+ return { statusCode: 200, headers: { "content-type": "application/json" }, body };
383
+ };
384
+
385
+ // Cron jobs: use AWS EventBridge (CloudWatch Events) to trigger scheduled invocations.
386
+ // Create a rule for each cron job defined in AGENT.md that sends a GET request to:
387
+ // /api/cron/<jobName>
388
+ // Include the Authorization header with your PONCHO_AUTH_TOKEN as a Bearer token.
389
+ //
390
+ // Reminders: Create a CloudWatch Events rule that triggers GET /api/reminders/check
391
+ // every 10 minutes (or your preferred interval) with Authorization: Bearer <PONCHO_AUTH_TOKEN>.
392
+ `,
393
+ { force: options?.force, writtenPaths, baseDir: projectDir },
394
+ );
395
+ } else if (target === "fly") {
396
+ await writeScaffoldFile(
397
+ resolve(projectDir, "fly.toml"),
398
+ `app = "poncho-app"
399
+ [env]
400
+ PORT = "3000"
401
+ [http_service]
402
+ internal_port = 3000
403
+ force_https = true
404
+ auto_start_machines = true
405
+ auto_stop_machines = "stop"
406
+ min_machines_running = 0
407
+ `,
408
+ { force: options?.force, writtenPaths, baseDir: projectDir },
409
+ );
410
+ await writeScaffoldFile(
411
+ resolve(projectDir, "Dockerfile"),
412
+ `FROM node:20-slim
413
+ WORKDIR /app
414
+ COPY package.json package.json
415
+ COPY AGENT.md AGENT.md
416
+ COPY poncho.config.js poncho.config.js
417
+ COPY skills skills
418
+ COPY tests tests
419
+ RUN npm install -g @poncho-ai/cli@^${cliVersion}
420
+ COPY server.js server.js
421
+ EXPOSE 3000
422
+ CMD ["node","server.js"]
423
+ `,
424
+ { force: options?.force, writtenPaths, baseDir: projectDir },
425
+ );
426
+ await writeScaffoldFile(resolve(projectDir, "server.js"), sharedServerEntrypoint, {
427
+ force: options?.force,
428
+ writtenPaths,
429
+ baseDir: projectDir,
430
+ });
431
+ }
432
+
433
+ const config = await loadPonchoConfig(projectDir);
434
+ const { paths: packagePaths, addedDeps } = await ensureRuntimeCliDependency(
435
+ projectDir,
436
+ cliVersion,
437
+ config,
438
+ target,
439
+ );
440
+ const depNote = addedDeps.length > 0 ? ` (added ${addedDeps.join(", ")})` : "";
441
+ for (const p of packagePaths) {
442
+ if (!writtenPaths.includes(p)) {
443
+ writtenPaths.push(depNote ? `${p}${depNote}` : p);
444
+ }
445
+ }
446
+
447
+ return writtenPaths;
448
+ };
449
+
450
+ export const serializeJs = (value: unknown, indent = 0): string => {
451
+ const pad = " ".repeat(indent);
452
+ const padInner = " ".repeat(indent + 1);
453
+ if (value === null || value === undefined) return String(value);
454
+ if (typeof value === "boolean" || typeof value === "number") return String(value);
455
+ if (typeof value === "string") return JSON.stringify(value);
456
+ if (Array.isArray(value)) {
457
+ if (value.length === 0) return "[]";
458
+ const items = value.map((v) => `${padInner}${serializeJs(v, indent + 1)}`);
459
+ return `[\n${items.join(",\n")},\n${pad}]`;
460
+ }
461
+ if (typeof value === "object") {
462
+ const entries = Object.entries(value as Record<string, unknown>).filter(
463
+ ([, v]) => v !== undefined,
464
+ );
465
+ if (entries.length === 0) return "{}";
466
+ const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
467
+ const lines = entries.map(([k, v]) => {
468
+ const key = safeKey.test(k) ? k : JSON.stringify(k);
469
+ return `${padInner}${key}: ${serializeJs(v, indent + 1)}`;
470
+ });
471
+ return `{\n${lines.join(",\n")},\n${pad}}`;
472
+ }
473
+ return String(value);
474
+ };
475
+
476
+ export const renderConfigFile = (config: PonchoConfig): string =>
477
+ `export default ${serializeJs(config)}\n`;
478
+
479
+ export const writeConfigFile = async (workingDir: string, config: PonchoConfig): Promise<void> => {
480
+ const serialized = renderConfigFile(config);
481
+ await writeFile(resolve(workingDir, "poncho.config.js"), serialized, "utf8");
482
+ };
483
+
484
+ export const ensureEnvPlaceholder = async (filePath: string, key: string): Promise<boolean> => {
485
+ const normalizedKey = key.trim();
486
+ if (!normalizedKey) {
487
+ return false;
488
+ }
489
+ let content = "";
490
+ try {
491
+ content = await readFile(filePath, "utf8");
492
+ } catch {
493
+ await writeFile(filePath, `${normalizedKey}=\n`, "utf8");
494
+ return true;
495
+ }
496
+ const present = content
497
+ .split(/\r?\n/)
498
+ .some((line) => line.trimStart().startsWith(`${normalizedKey}=`));
499
+ if (present) {
500
+ return false;
501
+ }
502
+ const withTrailingNewline = content.length === 0 || content.endsWith("\n")
503
+ ? content
504
+ : `${content}\n`;
505
+ await writeFile(filePath, `${withTrailingNewline}${normalizedKey}=\n`, "utf8");
506
+ return true;
507
+ };
508
+
509
+ export const removeEnvPlaceholder = async (filePath: string, key: string): Promise<boolean> => {
510
+ const normalizedKey = key.trim();
511
+ if (!normalizedKey) {
512
+ return false;
513
+ }
514
+ let content = "";
515
+ try {
516
+ content = await readFile(filePath, "utf8");
517
+ } catch {
518
+ return false;
519
+ }
520
+ const lines = content.split(/\r?\n/);
521
+ const filtered = lines.filter((line) => !line.trimStart().startsWith(`${normalizedKey}=`));
522
+ if (filtered.length === lines.length) {
523
+ return false;
524
+ }
525
+ const nextContent = filtered.join("\n").replace(/\n+$/, "");
526
+ await writeFile(filePath, nextContent.length > 0 ? `${nextContent}\n` : "", "utf8");
527
+ return true;
528
+ };