dopus 0.0.0-stage → 0.2.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.
package/dist/cli.js ADDED
@@ -0,0 +1,977 @@
1
+ #!/usr/bin/env node
2
+ import { n as PROVIDERS, t as VERSION } from "./version-DOEBWDfi.js";
3
+ import { execFileSync, spawn } from "node:child_process";
4
+ import { parseArgs } from "node:util";
5
+ import * as p from "@clack/prompts";
6
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { dirname, join, relative, resolve } from "node:path";
8
+ import { randomBytes } from "node:crypto";
9
+ import { fileURLToPath } from "node:url";
10
+ // src/init/detect.ts
11
+ var InitError = class extends Error {
12
+ name = "InitError";
13
+ };
14
+ function readPackageJson(root) {
15
+ const file = join(root, "package.json");
16
+ if (!existsSync(file)) throw new InitError("No package.json here. Run `dopus init` in the root of your Next.js app.");
17
+ return JSON.parse(readFileSync(file, "utf8"));
18
+ }
19
+ const LOCKFILES = [
20
+ ["pnpm-lock.yaml", "pnpm"],
21
+ ["yarn.lock", "yarn"],
22
+ ["bun.lock", "bun"],
23
+ ["bun.lockb", "bun"],
24
+ ["package-lock.json", "npm"]
25
+ ];
26
+ function fromPackageManagerField(value) {
27
+ const name = typeof value === "string" ? value.split("@")[0] : void 0;
28
+ return name === "pnpm" || name === "npm" || name === "yarn" || name === "bun" ? name : void 0;
29
+ }
30
+ /**
31
+ * `packageManager` field or lockfile, searching upward so apps inside a monorepo
32
+ * use the workspace's package manager. Stops at the repository root (.git).
33
+ */
34
+ function detectPackageManager(root, declared) {
35
+ const own = fromPackageManagerField(declared);
36
+ if (own) return own;
37
+ let dir = resolve(root);
38
+ while (true) {
39
+ for (const [file, manager] of LOCKFILES) if (existsSync(join(dir, file))) return manager;
40
+ if (dir !== resolve(root) && existsSync(join(dir, "package.json"))) try {
41
+ const field = fromPackageManagerField(JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).packageManager);
42
+ if (field) return field;
43
+ } catch {}
44
+ const parent = dirname(dir);
45
+ if (parent === dir || existsSync(join(dir, ".git"))) return "npm";
46
+ dir = parent;
47
+ }
48
+ }
49
+ function detectProject(root) {
50
+ const pkg = readPackageJson(root);
51
+ const dependencies = {
52
+ ...pkg.devDependencies,
53
+ ...pkg.dependencies
54
+ };
55
+ const nextVersion = dependencies.next;
56
+ if (!nextVersion) throw new InitError("This does not look like a Next.js app (no `next` dependency in package.json).");
57
+ const appDir = existsSync(join(root, "src", "app")) ? "src/app" : existsSync(join(root, "app")) ? "app" : void 0;
58
+ if (!appDir) throw new InitError("No `app/` or `src/app/` directory found. dopus needs the App Router.");
59
+ if (!existsSync(join(root, "tsconfig.json"))) throw new InitError("No tsconfig.json found. dopus generates TypeScript files.");
60
+ return {
61
+ root,
62
+ appDir,
63
+ configDir: appDir === "src/app" ? "src" : ".",
64
+ packageManager: detectPackageManager(root, pkg.packageManager),
65
+ dependencies,
66
+ nextVersion
67
+ };
68
+ }
69
+ // src/init/env.ts
70
+ /** Files Next.js loads in development, in the order it gives them precedence. */
71
+ const ENV_FILES = [
72
+ ".env.development.local",
73
+ ".env.local",
74
+ ".env.development",
75
+ ".env"
76
+ ];
77
+ const LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)?$/;
78
+ function parseEnvKeys(content) {
79
+ const keys = /* @__PURE__ */ new Map();
80
+ for (const line of content.split(/\r?\n/)) {
81
+ const match = LINE.exec(line);
82
+ if (match?.[1]) keys.set(match[1], (match[2] ?? "").trim().replace(/^(['"])(.*)\1$/, "$2"));
83
+ }
84
+ return keys;
85
+ }
86
+ /** Which env files already define `key` with a non-empty value. Values are never returned. */
87
+ function findDefinitions(root, key) {
88
+ return ENV_FILES.filter((file) => {
89
+ const path = join(root, file);
90
+ return existsSync(path) && Boolean(parseEnvKeys(readFileSync(path, "utf8")).get(key));
91
+ });
92
+ }
93
+ function quote(value) {
94
+ return /^[A-Za-z0-9_\-.:/+=]*$/.test(value) ? value : JSON.stringify(value);
95
+ }
96
+ /**
97
+ * Sets `vars` in dotenv content. Existing non-empty values are kept unless `replace` lists the key;
98
+ * empty placeholders (`KEY=`) are filled in place. New keys are appended under a `# dopus` header.
99
+ */
100
+ function upsertEnv(content, vars, replace = []) {
101
+ const lines = content === "" ? [] : content.replace(/\n$/, "").split("\n");
102
+ const pending = new Map(Object.entries(vars));
103
+ const changed = [];
104
+ for (let i = 0; i < lines.length; i++) {
105
+ const match = LINE.exec(lines[i] ?? "");
106
+ const key = match?.[1];
107
+ if (!key || !pending.has(key)) continue;
108
+ if ((match?.[2] ?? "").trim() === "" || replace.includes(key)) {
109
+ lines[i] = `${key}=${quote(pending.get(key) ?? "")}`;
110
+ changed.push(key);
111
+ }
112
+ pending.delete(key);
113
+ }
114
+ if (pending.size > 0) {
115
+ if (lines.length > 0 && lines.at(-1) !== "") lines.push("");
116
+ lines.push("# dopus");
117
+ for (const [key, value] of pending) {
118
+ lines.push(`${key}=${quote(value)}`);
119
+ changed.push(key);
120
+ }
121
+ }
122
+ return {
123
+ content: `${lines.join("\n")}\n`,
124
+ changed
125
+ };
126
+ }
127
+ /** True if git (or, without git, .gitignore patterns) keeps `.env.local` out of the repo. */
128
+ function isEnvLocalIgnored(root) {
129
+ return isGitIgnored(root, ".env.local", [
130
+ ".env.local",
131
+ ".env*.local",
132
+ ".env*",
133
+ "*.local",
134
+ ".env.*"
135
+ ]);
136
+ }
137
+ /** True if git ignores `path`; outside a repo, falls back to exact lines in .gitignore. */
138
+ function isGitIgnored(root, path, patterns = [
139
+ path,
140
+ `${path}/`,
141
+ `/${path}`
142
+ ]) {
143
+ try {
144
+ execFileSync("git", [
145
+ "check-ignore",
146
+ "-q",
147
+ path
148
+ ], {
149
+ cwd: root,
150
+ stdio: "ignore"
151
+ });
152
+ return true;
153
+ } catch (error) {
154
+ if (error.status === 1) return false;
155
+ }
156
+ const gitignore = join(root, ".gitignore");
157
+ if (!existsSync(gitignore)) return false;
158
+ return readFileSync(gitignore, "utf8").split(/\r?\n/).map((l) => l.trim()).some((line) => patterns.includes(line));
159
+ }
160
+ // src/init/templates.ts
161
+ const PRESETS = [
162
+ {
163
+ id: "general",
164
+ label: "General assistant"
165
+ },
166
+ {
167
+ id: "customer-support",
168
+ label: "Customer support"
169
+ },
170
+ {
171
+ id: "internal-ops",
172
+ label: "Internal operations / admin"
173
+ }
174
+ ];
175
+ /** Finds the package's `templates/` directory from either `src/` (tests) or `dist/` (published). */
176
+ function templatesDir() {
177
+ let dir = dirname(fileURLToPath(import.meta.url));
178
+ for (let i = 0; i < 4; i++) {
179
+ const candidate = join(dir, "templates");
180
+ if (existsSync(join(candidate, "ui", "agent-chat.tsx"))) return candidate;
181
+ dir = dirname(dir);
182
+ }
183
+ throw new Error("dopus: templates directory not found (broken install?)");
184
+ }
185
+ function readTemplate(path) {
186
+ return readFileSync(join(templatesDir(), path), "utf8");
187
+ }
188
+ function agentConfigTemplate(provider, model) {
189
+ const info = PROVIDERS[provider];
190
+ return `import { ${info.importName} } from '${info.packageName}'
191
+ import { defineAgent, defineTool } from 'dopus'
192
+ import { z } from 'zod'
193
+ import app, { locales } from './agent/prompts/app'
194
+ import preset from './agent/prompts/preset'
195
+
196
+ /**
197
+ * Register your existing server functions as tools. They run inside the agent request,
198
+ * so cookies() / headers() see the signed-in user and your own permission checks apply.
199
+ *
200
+ * import { getOrders } from '@/app/actions/orders'
201
+ *
202
+ * getOrders: defineTool((input) => getOrders(input.status), {
203
+ * description: "Lists the signed-in user's orders.",
204
+ * usage: 'When the user asks about their orders.',
205
+ * input: z.object({ status: z.enum(['open', 'closed']).optional() }),
206
+ * }),
207
+ *
208
+ * Add \`confirm: true\` to anything that changes data; the user approves each call in the chat.
209
+ */
210
+ export default defineAgent({
211
+ model: ${info.importName}('${model}'),
212
+ tools: {
213
+ currentTime: defineTool(async () => new Date().toISOString(), {
214
+ description: 'Returns the current date and time (ISO 8601, server time).',
215
+ usage: 'When the answer depends on today’s date or the current time.',
216
+ input: z.object({}),
217
+ }),
218
+ },
219
+ // Prompt layers, stable → volatile: dopus core rules → preset → app context → per-request runtime.
220
+ prompt: { preset, app, locales },
221
+ // Per-request facts from the server (never from the browser), e.g. the user's role:
222
+ // runtime: async () => ({ role: (await getSession())?.role }),
223
+ })
224
+ `;
225
+ }
226
+ function routeTemplate(configImport) {
227
+ return `import { createAgentHandler } from 'dopus'
228
+ import agent from '${configImport}'
229
+
230
+ // Agent replies stream for a while; raise this if your platform cuts requests short.
231
+ export const maxDuration = 60
232
+
233
+ export const POST = createAgentHandler(agent)
234
+ `;
235
+ }
236
+ // src/init/run.ts
237
+ const APPROVAL_SECRET_ENV = "DOPUS_APPROVAL_SECRET";
238
+ const ADD_COMMAND = {
239
+ pnpm: ["add"],
240
+ npm: ["install"],
241
+ yarn: ["add"],
242
+ bun: ["add"]
243
+ };
244
+ function installArgs(packageManager, packages, dev = false) {
245
+ return [
246
+ ...ADD_COMMAND[packageManager],
247
+ ...dev ? ["-D"] : [],
248
+ ...packages
249
+ ];
250
+ }
251
+ function toImportPath(fromFile, toFileWithoutExt) {
252
+ const path = relative(dirname(fromFile), toFileWithoutExt).split("\\").join("/");
253
+ return path.startsWith(".") ? path : `./${path}`;
254
+ }
255
+ async function runInit(options, io) {
256
+ const project = detectProject(options.cwd);
257
+ const { root } = project;
258
+ const ask = async (message, initial) => options.yes ? initial : io.confirm(message, initial);
259
+ if (options.provider && !(options.provider in PROVIDERS)) throw new InitError(`Unknown provider "${options.provider}". Use one of: ${Object.keys(PROVIDERS).join(", ")}`);
260
+ if (options.yes && !options.provider) throw new InitError("--yes needs --provider (openai, anthropic or google).");
261
+ if (options.preset && !PRESETS.some((p) => p.id === options.preset)) throw new InitError(`Unknown preset "${options.preset}". Use one of: ${PRESETS.map((p) => p.id).join(", ")}`);
262
+ const provider = options.provider ?? await io.selectProvider();
263
+ const info = PROVIDERS[provider];
264
+ const model = options.model ?? info.defaultModel;
265
+ const preset = options.preset ?? (options.yes ? "general" : await io.selectPreset());
266
+ const at = (path) => join(project.configDir, path);
267
+ const layoutFile = join(project.appDir, "layout.tsx");
268
+ const chatFile = at("components/agent/agent-chat.tsx");
269
+ const result = {
270
+ provider,
271
+ preset,
272
+ widgetImport: toImportPath(layoutFile, chatFile.replace(/\.tsx$/, "")),
273
+ layoutFile,
274
+ created: [],
275
+ skipped: [],
276
+ overwritten: [],
277
+ envChanged: [],
278
+ installed: [],
279
+ apiKeyMissing: false
280
+ };
281
+ const envVars = {};
282
+ const replace = [];
283
+ const keyDefinedIn = findDefinitions(root, info.envVar);
284
+ if (keyDefinedIn.length > 0) {
285
+ io.info(`${info.envVar} is already set in ${keyDefinedIn.join(", ")}.`);
286
+ if (keyDefinedIn.includes(".env.local")) {
287
+ if (await ask(`Replace ${info.envVar} in .env.local?`, false)) {
288
+ const key = await io.askApiKey(info);
289
+ if (key) {
290
+ envVars[info.envVar] = key;
291
+ replace.push(info.envVar);
292
+ }
293
+ }
294
+ }
295
+ } else {
296
+ const key = options.yes ? void 0 : await io.askApiKey(info);
297
+ if (key) envVars[info.envVar] = key;
298
+ else result.apiKeyMissing = true;
299
+ }
300
+ if (findDefinitions(root, "DOPUS_APPROVAL_SECRET").length === 0) envVars[APPROVAL_SECRET_ENV] = randomBytes(32).toString("base64url");
301
+ if (Object.keys(envVars).length > 0) {
302
+ const envPath = join(root, ".env.local");
303
+ const existed = existsSync(envPath);
304
+ const { content, changed } = upsertEnv(existed ? readFileSync(envPath, "utf8") : "", envVars, replace);
305
+ if (changed.length > 0) {
306
+ writeFileSync(envPath, content, existed ? void 0 : { mode: 384 });
307
+ result.envChanged = changed;
308
+ io.success(`.env.local: set ${changed.join(", ")}`);
309
+ }
310
+ }
311
+ if (!isEnvLocalIgnored(root)) {
312
+ const gitignore = join(root, ".gitignore");
313
+ const prefix = existsSync(gitignore) && !readFileSync(gitignore, "utf8").endsWith("\n") ? "\n" : "";
314
+ appendFileSync(gitignore, `${prefix}\n# local env files (dopus)\n.env*.local\n`);
315
+ io.warn(".env.local was not git-ignored. Added `.env*.local` to .gitignore.");
316
+ }
317
+ const routeFile = join(project.appDir, "api", "agent", "route.ts");
318
+ const files = [
319
+ [
320
+ at("agent.config.ts"),
321
+ () => agentConfigTemplate(provider, model),
322
+ [at("agent.config.js"), at("agent.config.mjs")]
323
+ ],
324
+ [
325
+ at("agent/prompts/preset.ts"),
326
+ () => readTemplate(`prompts/presets/${preset}.ts`),
327
+ []
328
+ ],
329
+ [
330
+ at("agent/prompts/app.ts"),
331
+ () => readTemplate("prompts/app.ts"),
332
+ []
333
+ ],
334
+ [
335
+ routeFile,
336
+ () => routeTemplate(toImportPath(routeFile, at("agent.config"))),
337
+ [routeFile.replace(/\.ts$/, ".js")]
338
+ ],
339
+ [
340
+ chatFile,
341
+ () => readTemplate("ui/agent-chat.tsx"),
342
+ []
343
+ ],
344
+ [
345
+ at("components/agent/agent-chat.module.css"),
346
+ () => readTemplate("ui/agent-chat.module.css"),
347
+ []
348
+ ]
349
+ ];
350
+ for (const [file, content, alternatives] of files) {
351
+ const existing = [file, ...alternatives].find((f) => existsSync(join(root, f)));
352
+ if (existing) {
353
+ if (!(options.force && existing === file && await ask(`Overwrite ${file}? Your changes will be lost.`, true))) {
354
+ result.skipped.push(existing);
355
+ io.info(`${existing} exists — left unchanged.`);
356
+ continue;
357
+ }
358
+ result.overwritten.push(file);
359
+ } else result.created.push(file);
360
+ mkdirSync(dirname(join(root, file)), { recursive: true });
361
+ writeFileSync(join(root, file), content());
362
+ io.success(`${result.overwritten.includes(file) ? "Overwrote" : "Created"} ${file}`);
363
+ }
364
+ const missing = [
365
+ ["dopus", "dopus"],
366
+ ["ai", "ai@^7"],
367
+ ["zod", "zod@^4"],
368
+ [info.packageName, `${info.packageName}@^4`],
369
+ ["@ai-sdk/react", "@ai-sdk/react@^4"]
370
+ ].filter(([name]) => !project.dependencies[name]).map(([, spec]) => spec);
371
+ if (missing.length > 0) {
372
+ if (options.install === false) io.warn(`Install these yourself: ${project.packageManager} ${installArgs(project.packageManager, missing).join(" ")}`);
373
+ else if (await ask(`Install ${missing.join(", ")} with ${project.packageManager}?`, true)) {
374
+ await io.install(project.packageManager, missing, root);
375
+ result.installed = missing;
376
+ }
377
+ }
378
+ return result;
379
+ }
380
+ // src/infra/aws.ts
381
+ const OPEN_NEXT_CONFIG = `import type { OpenNextConfig } from '@opennextjs/aws/types/open-next.js'
382
+
383
+ /**
384
+ * Generated by \`dopus infra aws\`.
385
+ *
386
+ * Lambda response streaming lets /api/agent stream replies instead of buffering them. OpenNext marks the
387
+ * server as streaming in its build output; SST (sst.aws.Nextjs) reads that and sets the Function URL
388
+ * invoke mode to RESPONSE_STREAM. With your own CDK/Terraform, set InvokeMode RESPONSE_STREAM yourself.
389
+ */
390
+ const config = {
391
+ default: {
392
+ override: {
393
+ wrapper: 'aws-lambda-streaming',
394
+ },
395
+ },
396
+ } satisfies OpenNextConfig
397
+
398
+ export default config
399
+ `;
400
+ /** Which provider agent.config.ts uses, so the deploy snippet names the right env var. */
401
+ function detectConfiguredProvider(source) {
402
+ if (!source) return void 0;
403
+ return Object.values(PROVIDERS).find((info) => source.includes(`'${info.packageName}'`));
404
+ }
405
+ function secretName(envVar) {
406
+ return envVar.toLowerCase().split("_").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
407
+ }
408
+ function sstSnippet(provider) {
409
+ const keyEnv = provider?.envVar ?? "OPENAI_API_KEY";
410
+ const keySecret = secretName(keyEnv);
411
+ const approval = secretName(APPROVAL_SECRET_ENV);
412
+ return `// sst.config.ts → run()
413
+ const ${keySecret[0]?.toLowerCase()}${keySecret.slice(1)} = new sst.Secret('${keySecret}')
414
+ const ${approval[0]?.toLowerCase()}${approval.slice(1)} = new sst.Secret('${approval}')
415
+
416
+ new sst.aws.Nextjs('Web', {
417
+ server: {
418
+ // Lambda timeout; SST also uses it as the CloudFront read
419
+ // timeout (max 60 s, or up to 180 s with a quota increase).
420
+ timeout: '60 seconds',
421
+ },
422
+ environment: {
423
+ ${keyEnv}: ${keySecret[0]?.toLowerCase()}${keySecret.slice(1)}.value,
424
+ ${APPROVAL_SECRET_ENV}: ${approval[0]?.toLowerCase()}${approval.slice(1)}.value,
425
+ },
426
+ })
427
+
428
+ // then, once per stage:
429
+ // npx sst secret set ${keySecret} <your key>
430
+ // npx sst secret set ${approval} "$(openssl rand -base64 32)"`;
431
+ }
432
+ async function runInfraAws(options, io) {
433
+ const project = detectProject(options.cwd);
434
+ const { root } = project;
435
+ const ask = async (message, initial) => options.yes ? initial : io.confirm(message, initial);
436
+ const configFile = [
437
+ "open-next.config.ts",
438
+ "open-next.config.js",
439
+ "open-next.config.mjs"
440
+ ].find((f) => existsSync(join(root, f)));
441
+ const agentConfig = [join(project.configDir, "agent.config.ts"), "agent.config.ts"].map((f) => join(root, f)).find((f) => existsSync(f));
442
+ const provider = detectConfiguredProvider(agentConfig ? readFileSync(agentConfig, "utf8") : void 0);
443
+ const result = {
444
+ configFile: configFile ?? "open-next.config.ts",
445
+ created: false,
446
+ streaming: false,
447
+ provider,
448
+ installed: []
449
+ };
450
+ if (configFile) {
451
+ result.streaming = readFileSync(join(root, configFile), "utf8").includes("aws-lambda-streaming");
452
+ if (result.streaming) io.info(`${configFile} already enables Lambda streaming — left unchanged.`);
453
+ else io.warn(`${configFile} exists and does not enable streaming. Add \`override: { wrapper: 'aws-lambda-streaming' }\` to the function that serves /api/agent (or to \`default\`).`);
454
+ } else {
455
+ writeFileSync(join(root, "open-next.config.ts"), OPEN_NEXT_CONFIG);
456
+ result.created = true;
457
+ result.streaming = true;
458
+ io.success("Created open-next.config.ts (Lambda response streaming)");
459
+ }
460
+ if (!project.dependencies["@opennextjs/aws"]) {
461
+ const spec = "@opennextjs/aws@^4";
462
+ if (options.install === false) io.warn(`Install it yourself: ${project.packageManager} ${installArgs(project.packageManager, [spec], true).join(" ")}`);
463
+ else if (await ask(`Install ${spec} (dev dependency) with ${project.packageManager}?`, true)) {
464
+ await io.install(project.packageManager, [spec], root, true);
465
+ result.installed = [spec];
466
+ }
467
+ }
468
+ if (!provider) io.warn("Could not tell the provider from agent.config.ts; the snippet below assumes OpenAI.");
469
+ io.note(sstSnippet(provider), "Deploy with SST");
470
+ io.note([
471
+ "Build: npx @opennextjs/aws build (SST runs it for you).",
472
+ "Keep-alive: dopus sends an SSE comment every 15 s while a tool",
473
+ " runs. CloudFront times out on gaps between packets, not on",
474
+ " total time, so long tool calls keep the stream open.",
475
+ "Limit: a turn must finish within the Lambda timeout: 60 s",
476
+ " behind CloudFront by default, up to 15 min on a bare",
477
+ " Function URL.",
478
+ "Avoid API Gateway HTTP APIs for /api/agent (30 s cap)."
479
+ ].join("\n"), "AWS notes");
480
+ return result;
481
+ }
482
+ // src/infra/cloudflare.ts
483
+ const CLOUDFLARE_OPEN_NEXT_CONFIG = `import { defineCloudflareConfig } from '@opennextjs/cloudflare'
484
+
485
+ // Generated by \`dopus infra cloudflare\`. Add an incremental cache (R2/KV) here if your pages use ISR.
486
+ export default defineCloudflareConfig({})
487
+ `;
488
+ /** Worker names: lowercase letters, digits and dashes, at most 63 characters. */
489
+ function workerName(packageName) {
490
+ return (packageName ?? "next-app").replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63) || "next-app";
491
+ }
492
+ /** A month back, so the installed workerd always supports it. */
493
+ function compatibilityDate(now = /* @__PURE__ */ new Date()) {
494
+ return (/* @__PURE__ */ new Date(now.getTime() - 2592e6)).toISOString().slice(0, 10);
495
+ }
496
+ function wranglerConfig(name, date) {
497
+ return `{
498
+ // Generated by \`dopus infra cloudflare\` for @opennextjs/cloudflare.
499
+ "$schema": "node_modules/wrangler/config-schema.json",
500
+ "name": "${name}",
501
+ "main": ".open-next/worker.js",
502
+ "compatibility_date": "${date}",
503
+ "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
504
+ "assets": { "directory": ".open-next/assets", "binding": "ASSETS" },
505
+ "services": [{ "binding": "WORKER_SELF_REFERENCE", "service": "${name}" }],
506
+ // Waiting on the LLM does not count as CPU time, but rendering and tool code do (paid plans: 30 s default).
507
+ // "limits": { "cpu_ms": 60000 },
508
+ "observability": { "enabled": true }
509
+ }
510
+ `;
511
+ }
512
+ const SCRIPTS = {
513
+ "cf:preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
514
+ "cf:deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy"
515
+ };
516
+ async function runInfraCloudflare(options, io) {
517
+ const project = detectProject(options.cwd);
518
+ const { root } = project;
519
+ const ask = async (message, initial) => options.yes ? initial : io.confirm(message, initial);
520
+ const result = {
521
+ created: [],
522
+ skipped: [],
523
+ scripts: [],
524
+ installed: []
525
+ };
526
+ const openNext = [
527
+ "open-next.config.ts",
528
+ "open-next.config.js",
529
+ "open-next.config.mjs"
530
+ ].find((f) => existsSync(join(root, f)));
531
+ if (openNext && !readFileSync(join(root, openNext), "utf8").includes("@opennextjs/cloudflare")) throw new InitError(`${openNext} is set up for another target (AWS?). OpenNext uses one config per app: move it away and rerun.`);
532
+ const pkgPath = join(root, "package.json");
533
+ const pkgSource = readFileSync(pkgPath, "utf8");
534
+ const pkg = JSON.parse(pkgSource);
535
+ const name = workerName(pkg.name);
536
+ const agentConfig = [join(project.configDir, "agent.config.ts"), "agent.config.ts"].map((f) => join(root, f)).find((f) => existsSync(f));
537
+ const keyEnv = detectConfiguredProvider(agentConfig ? readFileSync(agentConfig, "utf8") : void 0)?.envVar ?? "OPENAI_API_KEY";
538
+ const files = [
539
+ [
540
+ "open-next.config.ts",
541
+ CLOUDFLARE_OPEN_NEXT_CONFIG,
542
+ ["open-next.config.js", "open-next.config.mjs"]
543
+ ],
544
+ [
545
+ "wrangler.jsonc",
546
+ wranglerConfig(name, compatibilityDate()),
547
+ ["wrangler.json", "wrangler.toml"]
548
+ ],
549
+ [
550
+ ".dev.vars",
551
+ "NEXTJS_ENV=development\n",
552
+ []
553
+ ]
554
+ ];
555
+ for (const [file, content, alternatives] of files) {
556
+ const existing = [file, ...alternatives].find((f) => existsSync(join(root, f)));
557
+ if (existing) {
558
+ result.skipped.push(existing);
559
+ io.info(`${existing} exists — left unchanged.`);
560
+ continue;
561
+ }
562
+ writeFileSync(join(root, file), content, file === ".dev.vars" ? { mode: 384 } : void 0);
563
+ result.created.push(file);
564
+ io.success(`Created ${file}`);
565
+ }
566
+ const envLocal = join(root, ".env.local");
567
+ const devVars = join(root, ".dev.vars");
568
+ const local = existsSync(envLocal) ? parseEnvKeys(readFileSync(envLocal, "utf8")) : /* @__PURE__ */ new Map();
569
+ const present = existsSync(devVars) ? parseEnvKeys(readFileSync(devVars, "utf8")) : /* @__PURE__ */ new Map();
570
+ const copy = [keyEnv, APPROVAL_SECRET_ENV].filter((key) => local.get(key) && !present.get(key));
571
+ if (copy.length > 0 && await ask(`Copy ${copy.join(" and ")} from .env.local into .dev.vars for the local preview?`, true)) {
572
+ const vars = Object.fromEntries(copy.map((key) => [key, local.get(key) ?? ""]));
573
+ writeFileSync(devVars, upsertEnv(readFileSync(devVars, "utf8"), vars).content, { mode: 384 });
574
+ io.success(`.dev.vars: set ${copy.join(", ")}`);
575
+ }
576
+ if (result.skipped.some((f) => f.startsWith("wrangler."))) io.warn("Check that your wrangler config has main \".open-next/worker.js\" and the \"nodejs_compat\" flag.");
577
+ const scripts = { ...pkg.scripts };
578
+ for (const [key, command] of Object.entries(SCRIPTS)) if (!scripts[key]) {
579
+ scripts[key] = command;
580
+ result.scripts.push(key);
581
+ }
582
+ if (result.scripts.length > 0) {
583
+ const indent = /^(\s+)"/m.exec(pkgSource)?.[1] ?? " ";
584
+ writeFileSync(pkgPath, `${JSON.stringify({
585
+ ...pkg,
586
+ scripts
587
+ }, null, indent)}\n`);
588
+ io.success(`package.json: added ${result.scripts.join(", ")}`);
589
+ }
590
+ const ignore = [
591
+ ".open-next",
592
+ ".dev.vars",
593
+ ".wrangler"
594
+ ].filter((path) => !isGitIgnored(root, path));
595
+ if (ignore.length > 0) {
596
+ const gitignore = join(root, ".gitignore");
597
+ const prefix = existsSync(gitignore) && !readFileSync(gitignore, "utf8").endsWith("\n") ? "\n" : "";
598
+ appendFileSync(gitignore, `${prefix}\n# Cloudflare (dopus)\n${ignore.map((p) => `${p}\n`).join("")}`);
599
+ io.success(`.gitignore: added ${ignore.join(", ")}`);
600
+ }
601
+ const deps = [[
602
+ "@opennextjs/cloudflare",
603
+ "@opennextjs/cloudflare@^1",
604
+ false
605
+ ], [
606
+ "wrangler",
607
+ "wrangler@^4",
608
+ true
609
+ ]];
610
+ for (const dev of [false, true]) {
611
+ const missing = deps.filter(([n, , d]) => d === dev && !project.dependencies[n]).map(([, spec]) => spec);
612
+ if (missing.length === 0) continue;
613
+ if (options.install === false) io.warn(`Install yourself: ${project.packageManager} ${installArgs(project.packageManager, missing, dev).join(" ")}`);
614
+ else if (await ask(`Install ${missing.join(", ")}${dev ? " (dev)" : ""} with ${project.packageManager}?`, true)) {
615
+ await io.install(project.packageManager, missing, root, dev);
616
+ result.installed.push(...missing);
617
+ }
618
+ }
619
+ const run = project.packageManager === "npm" ? "npm run" : project.packageManager;
620
+ io.note([
621
+ `Local Workers runtime: ${run} cf:preview`,
622
+ " Wrangler reads .dev.vars (not .env.local): keep your key there too.",
623
+ "",
624
+ "Production secrets, once per Worker:",
625
+ ` npx wrangler secret put ${keyEnv}`,
626
+ ` npx wrangler secret put ${APPROVAL_SECRET_ENV}`,
627
+ `Deploy: ${run} cf:deploy`
628
+ ].join("\n"), "Deploy to Cloudflare Workers");
629
+ io.note([
630
+ "Duration: no wall-clock limit while the client is connected, and",
631
+ " waiting on the LLM is not CPU time. CPU limit: 30 s by default on",
632
+ " paid plans (limits.cpu_ms, max 5 min); the free plan allows 10 ms,",
633
+ " which a Next.js app usually exceeds.",
634
+ "Keep-alive SSE comments (15 s) keep long tool calls open.",
635
+ "Remove `export const runtime = \"edge\"`: OpenNext runs the Node runtime."
636
+ ].join("\n"), "Cloudflare notes");
637
+ return result;
638
+ }
639
+ // src/infra/gcp.ts
640
+ const LOCKFILE = {
641
+ pnpm: "pnpm-lock.yaml",
642
+ npm: "package-lock.json",
643
+ yarn: "yarn.lock"
644
+ };
645
+ /**
646
+ * The install line. pnpm/yarn come from corepack; without a `packageManager` field corepack would fetch the
647
+ * newest release, whose policies (e.g. pnpm's minimumReleaseAge) can reject a lockfile your version accepted,
648
+ * so pin the version the project uses.
649
+ */
650
+ function installCommand(packageManager, pinned) {
651
+ if (packageManager === "npm") return "npm ci";
652
+ return `${pinned ? `corepack enable ${packageManager} && corepack prepare ${packageManager}@${pinned} --activate` : `corepack enable ${packageManager}`} && ${packageManager} install --frozen-lockfile`;
653
+ }
654
+ /** Version to pin when package.json has no `packageManager` field: the one installed here. */
655
+ function localVersion(packageManager, cwd) {
656
+ try {
657
+ const version = execFileSync(packageManager, ["--version"], {
658
+ cwd,
659
+ encoding: "utf8",
660
+ stdio: [
661
+ "ignore",
662
+ "pipe",
663
+ "ignore"
664
+ ]
665
+ }).trim();
666
+ return /^\d+\.\d+\.\d+/.test(version) ? version : void 0;
667
+ } catch {
668
+ return;
669
+ }
670
+ }
671
+ /** `pin`: version to activate with corepack; leave undefined when package.json declares `packageManager`. */
672
+ function dockerfile(packageManager, pin) {
673
+ if (packageManager === "bun") throw new InitError("Bun projects: write your own Dockerfile (dopus generates npm, pnpm and yarn ones).");
674
+ return `# Generated by \`dopus infra gcp\`: Next.js standalone server for Cloud Run.
675
+ # Secrets are not baked in: .dockerignore excludes .env files; Cloud Run injects them at runtime.
676
+ FROM node:24-slim AS base
677
+ ENV NEXT_TELEMETRY_DISABLED=1
678
+
679
+ FROM base AS deps
680
+ WORKDIR /app
681
+ COPY package.json ${LOCKFILE[packageManager]} ./
682
+ RUN ${installCommand(packageManager, pin)}
683
+
684
+ FROM base AS build
685
+ WORKDIR /app
686
+ COPY --from=deps /app/node_modules ./node_modules
687
+ COPY . .
688
+ RUN node_modules/.bin/next build && mkdir -p public
689
+
690
+ FROM base AS run
691
+ WORKDIR /app
692
+ ENV NODE_ENV=production PORT=8080 HOSTNAME=0.0.0.0
693
+ RUN groupadd --system --gid 1001 nodejs && useradd --system --uid 1001 --gid nodejs nextjs
694
+ COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
695
+ COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
696
+ COPY --from=build --chown=nextjs:nodejs /app/public ./public
697
+ USER nextjs
698
+ EXPOSE 8080
699
+ CMD ["node", "server.js"]
700
+ `;
701
+ }
702
+ const DOCKERIGNORE = `# Generated by \`dopus infra gcp\`. Keep secrets and build output out of the image.
703
+ node_modules
704
+ .next
705
+ .open-next
706
+ .wrangler
707
+ .git
708
+ .env
709
+ .env.*
710
+ .dev.vars*
711
+ npm-debug.log*
712
+ Dockerfile
713
+ .dockerignore
714
+ `;
715
+ const STANDALONE = /output\s*:\s*['"]standalone['"]/;
716
+ /**
717
+ * Adds `output: 'standalone'` to the usual `const nextConfig = {…}` / `export default {…}` shapes.
718
+ * Returns undefined when the file does not match a shape it can edit safely.
719
+ */
720
+ function addStandalone(source) {
721
+ if (STANDALONE.test(source)) return source;
722
+ if (/\boutput\s*:/.test(source)) return void 0;
723
+ for (const pattern of [
724
+ /(const\s+nextConfig(?:\s*:\s*NextConfig)?\s*=\s*)\{\s*\}/,
725
+ /(const\s+nextConfig(?:\s*:\s*NextConfig)?\s*=\s*)\{/,
726
+ /(export\s+default\s*)\{\s*\}/,
727
+ /(module\.exports\s*=\s*)\{\s*\}/
728
+ ]) {
729
+ if (!pattern.test(source)) continue;
730
+ return source.replace(pattern, (match, head) => match.trimEnd().endsWith("}") ? `${head}{\n output: 'standalone',\n}` : `${head}{\n output: 'standalone',`);
731
+ }
732
+ }
733
+ function secretId(envVar) {
734
+ return envVar.toLowerCase().replaceAll("_", "-");
735
+ }
736
+ async function runInfraGcp(options, io) {
737
+ const project = detectProject(options.cwd);
738
+ const { root } = project;
739
+ const ask = async (message, initial) => options.yes ? initial : io.confirm(message, initial);
740
+ const result = {
741
+ created: [],
742
+ skipped: [],
743
+ standalone: false
744
+ };
745
+ const nextConfig = [
746
+ "next.config.ts",
747
+ "next.config.mjs",
748
+ "next.config.js"
749
+ ].find((f) => existsSync(join(root, f)));
750
+ if (!nextConfig) {
751
+ writeFileSync(join(root, "next.config.mjs"), "/** @type {import('next').NextConfig} */\nexport default { output: 'standalone' }\n");
752
+ result.created.push("next.config.mjs");
753
+ result.standalone = true;
754
+ io.success("Created next.config.mjs with output: 'standalone'");
755
+ } else {
756
+ const source = readFileSync(join(root, nextConfig), "utf8");
757
+ const patched = addStandalone(source);
758
+ if (patched === source) result.standalone = true;
759
+ else if (patched && await ask(`Set output: 'standalone' in ${nextConfig}? (needed for the container)`, true)) {
760
+ writeFileSync(join(root, nextConfig), patched);
761
+ result.standalone = true;
762
+ io.success(`${nextConfig}: set output: 'standalone'`);
763
+ } else io.warn(`Set \`output: 'standalone'\` in ${nextConfig} yourself; the Dockerfile copies .next/standalone.`);
764
+ }
765
+ const files = [["Dockerfile", () => {
766
+ const declared = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).packageManager;
767
+ return dockerfile(project.packageManager, declared ? void 0 : localVersion(project.packageManager, root));
768
+ }], [".dockerignore", () => DOCKERIGNORE]];
769
+ for (const [file, content] of files) {
770
+ if (existsSync(join(root, file))) {
771
+ result.skipped.push(file);
772
+ io.info(`${file} exists — left unchanged.`);
773
+ continue;
774
+ }
775
+ writeFileSync(join(root, file), content());
776
+ result.created.push(file);
777
+ io.success(`Created ${file}`);
778
+ }
779
+ if (result.skipped.includes(".dockerignore")) {
780
+ const ignore = readFileSync(join(root, ".dockerignore"), "utf8");
781
+ if (!/^\.env/m.test(ignore)) io.warn(".dockerignore does not exclude .env files: your secrets would end up in the image.");
782
+ }
783
+ const agentConfig = [join(project.configDir, "agent.config.ts"), "agent.config.ts"].map((f) => join(root, f)).find((f) => existsSync(f));
784
+ const keyEnv = detectConfiguredProvider(agentConfig ? readFileSync(agentConfig, "utf8") : void 0)?.envVar ?? "OPENAI_API_KEY";
785
+ const service = workerName(JSON.parse(readFileSync(join(root, "package.json"), "utf8")).name);
786
+ io.note([
787
+ "Secrets (Secret Manager), once per project:",
788
+ ` printf %s "$KEY" | gcloud secrets create ${secretId(keyEnv)} --data-file=-`,
789
+ ` openssl rand -base64 32 | tr -d '\\n' | gcloud secrets create ${secretId(APPROVAL_SECRET_ENV)} --data-file=-`,
790
+ " (the Cloud Run service account needs roles/secretmanager.secretAccessor)",
791
+ "",
792
+ "Deploy (Cloud Build uses the Dockerfile):",
793
+ ` gcloud run deploy ${service} --source . --region <region> --timeout 3600 \\`,
794
+ ` --set-secrets ${keyEnv}=${secretId(keyEnv)}:latest,${APPROVAL_SECRET_ENV}=${secretId(APPROVAL_SECRET_ENV)}:latest`,
795
+ "",
796
+ "Local check:",
797
+ ` docker build -t ${service} . && docker run --rm -p 8080:8080 --env-file .env.local ${service}`
798
+ ].join("\n"), "Deploy to Cloud Run");
799
+ io.note([
800
+ "Timeout: a request (the whole streamed reply) may last up to 60 min",
801
+ " (--timeout 3600; default 5 min). For longer work use Cloud Run Jobs",
802
+ " and pass the user explicitly (identify → ctx.user): no cookies there.",
803
+ "Instances are billed while a stream is open; keep-alive comments (15 s)",
804
+ " keep load balancers and proxies from dropping long tool calls."
805
+ ].join("\n"), "Cloud Run notes");
806
+ return result;
807
+ }
808
+ // src/cli.ts
809
+ const HELP = `dopus ${VERSION}
810
+
811
+ Usage:
812
+ dopus init [options] Add an AI agent to this Next.js app
813
+ dopus infra aws [options] Prepare the agent route for AWS Lambda (OpenNext streaming + SST snippet)
814
+ dopus infra cloudflare Prepare the app for Cloudflare Workers (@opennextjs/cloudflare)
815
+ dopus infra gcp Prepare the app for Cloud Run (standalone build + Dockerfile)
816
+
817
+ Options for init:
818
+ --provider <id> openai | anthropic | google
819
+ --model <id> Model id (default depends on the provider)
820
+ --preset <id> general | customer-support | internal-ops (default with --yes: general)
821
+ -y, --yes Non-interactive; skips the API key prompt (needs --provider)
822
+ --no-install Do not install dependencies
823
+ --force Overwrite generated files that already exist
824
+ --cwd <dir> Project root (default: current directory)
825
+
826
+ Options for infra:
827
+ -y, --yes Do not ask before installing @opennextjs/aws
828
+ --no-install Print the install command instead
829
+ --cwd <dir> Project root
830
+
831
+ -h, --help Show this help
832
+ -v, --version Show the version
833
+ `;
834
+ function cancelled(value) {
835
+ if (p.isCancel(value)) {
836
+ p.cancel("Cancelled. Files written before this step are kept.");
837
+ process.exit(130);
838
+ }
839
+ return value;
840
+ }
841
+ const io = {
842
+ async selectProvider() {
843
+ return cancelled(await p.select({
844
+ message: "Which LLM provider?",
845
+ options: Object.values(PROVIDERS).map((info) => ({
846
+ value: info.id,
847
+ label: info.label
848
+ }))
849
+ }));
850
+ },
851
+ async selectPreset() {
852
+ return cancelled(await p.select({
853
+ message: "Which prompt preset? (copied into your project; edit freely)",
854
+ options: PRESETS.map((preset) => ({
855
+ value: preset.id,
856
+ label: preset.label
857
+ }))
858
+ }));
859
+ },
860
+ async askApiKey(info) {
861
+ return cancelled(await p.password({
862
+ message: `${info.envVar} (saved to .env.local; leave empty to add it later)`,
863
+ mask: "•"
864
+ }))?.trim() || void 0;
865
+ },
866
+ async confirm(message, initial) {
867
+ return cancelled(await p.confirm({
868
+ message,
869
+ initialValue: initial
870
+ }));
871
+ },
872
+ info: (message) => p.log.info(message),
873
+ warn: (message) => p.log.warn(message),
874
+ success: (message) => p.log.success(message),
875
+ install(packageManager, packages, cwd, dev) {
876
+ return new Promise((resolve, reject) => {
877
+ const child = spawn(packageManager, installArgs(packageManager, packages, dev), {
878
+ cwd,
879
+ stdio: "inherit",
880
+ shell: process.platform === "win32"
881
+ });
882
+ child.on("error", reject);
883
+ child.on("exit", (code) => code === 0 ? resolve() : reject(new InitError(`${packageManager} exited with code ${code}`)));
884
+ });
885
+ }
886
+ };
887
+ async function init(args) {
888
+ const { values } = parseArgs({
889
+ args,
890
+ options: {
891
+ provider: { type: "string" },
892
+ model: { type: "string" },
893
+ preset: { type: "string" },
894
+ yes: {
895
+ type: "boolean",
896
+ short: "y"
897
+ },
898
+ install: {
899
+ type: "boolean",
900
+ default: true
901
+ },
902
+ "no-install": { type: "boolean" },
903
+ force: { type: "boolean" },
904
+ cwd: { type: "string" }
905
+ },
906
+ allowNegative: true
907
+ });
908
+ p.intro(`dopus ${VERSION}`);
909
+ const result = await runInit({
910
+ cwd: values.cwd ?? process.cwd(),
911
+ provider: values.provider,
912
+ model: values.model,
913
+ preset: values.preset,
914
+ yes: values.yes,
915
+ install: values["no-install"] ? false : values.install,
916
+ force: values.force
917
+ }, io);
918
+ const info = PROVIDERS[result.provider];
919
+ const steps = [
920
+ result.apiKeyMissing ? `Add ${info.envVar}=... to .env.local` : void 0,
921
+ "Register your server functions as tools in agent.config.ts",
922
+ "Describe your service in agent/prompts/app.ts",
923
+ `Show the chat: in ${result.layoutFile} add\n import { AgentWidget } from '${result.widgetImport}'\n and render <AgentWidget /> inside <body>`
924
+ ].filter(Boolean);
925
+ p.note(steps.map((s, i) => `${i + 1}. ${s}`).join("\n"), "Next steps");
926
+ p.outro("Done.");
927
+ }
928
+ async function infra(args) {
929
+ const [target, ...rest] = args;
930
+ const runners = {
931
+ aws: runInfraAws,
932
+ cloudflare: runInfraCloudflare,
933
+ gcp: runInfraGcp
934
+ };
935
+ if (!target || !(target in runners)) throw new InitError("Usage: dopus infra aws | cloudflare | gcp");
936
+ const { values } = parseArgs({
937
+ args: rest,
938
+ options: {
939
+ yes: {
940
+ type: "boolean",
941
+ short: "y"
942
+ },
943
+ install: {
944
+ type: "boolean",
945
+ default: true
946
+ },
947
+ "no-install": { type: "boolean" },
948
+ cwd: { type: "string" }
949
+ },
950
+ allowNegative: true
951
+ });
952
+ p.intro(`dopus ${VERSION} · infra ${target}`);
953
+ const run = runners[target];
954
+ await run({
955
+ cwd: values.cwd ?? process.cwd(),
956
+ yes: values.yes,
957
+ install: values["no-install"] ? false : values.install
958
+ }, {
959
+ ...io,
960
+ note: (message, title) => p.note(message, title)
961
+ });
962
+ p.outro("Done. Nothing was deployed.");
963
+ }
964
+ async function main() {
965
+ const [command, ...rest] = process.argv.slice(2);
966
+ if (command === "--version" || command === "-v") return console.log(VERSION);
967
+ if (command === "init") return init(rest);
968
+ if (command === "infra") return infra(rest);
969
+ console.log(HELP);
970
+ if (command && command !== "--help" && command !== "-h") process.exitCode = 1;
971
+ }
972
+ main().catch((error) => {
973
+ if (error instanceof InitError || error.code?.startsWith("ERR_PARSE_ARGS")) p.log.error(error.message);
974
+ else console.error(error);
975
+ process.exit(1);
976
+ });
977
+ export {};