create-nevex-agent 0.1.1 → 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/README.md CHANGED
@@ -6,6 +6,18 @@ Genera un proyecto independiente a partir del template de Nevex:
6
6
  npx create-nevex-agent coagua --display-name "Coagua"
7
7
  ```
8
8
 
9
+ Por defecto crea la instalación en modo Cloudflare (Worker + D1 + KV). Para
10
+ VPS/Node:
11
+
12
+ ```bash
13
+ npx create-nevex-agent coagua --mode node
14
+ ```
15
+
16
+ El generador deja solo el modo elegido: borra los adapters, migraciones, tests y
17
+ docs de deploy del otro (ADR-0005) y lo registra en `TEMPLATE.md`. Las
18
+ dependencias del `pnpm-lock.yaml` se conservan completas para no romper
19
+ `--frozen-lockfile`; el código que no corre ya no está.
20
+
9
21
  El CLI se publica en npm. El repositorio template que clona permanece privado
10
22
  y requiere acceso SSH a `nevex-labs/nevex-agent-template`.
11
23
 
@@ -15,6 +15,7 @@ import { execFileSync } from "node:child_process";
15
15
  import { tmpdir } from "node:os";
16
16
 
17
17
  const args = parseArgs(process.argv.slice(2));
18
+
18
19
  const projectName = args._[0];
19
20
 
20
21
  if (!projectName || args.help) {
@@ -23,14 +24,22 @@ if (!projectName || args.help) {
23
24
  }
24
25
 
25
26
  const slug = normalizeSlug(projectName);
27
+
28
+ const mode = normalizeMode(args.mode);
29
+
26
30
  const target = resolve(args.output ?? projectName);
31
+
27
32
  assertTargetIsSafe(target);
28
33
 
29
34
  const source = args["template-path"]
30
35
  ? resolve(args["template-path"])
31
- : await materializeTemplate(args["template-repo"] ?? process.env.NEVEX_AGENT_TEMPLATE_REPO);
36
+ : await materializeTemplate(
37
+ args["template-repo"] ?? process.env.NEVEX_AGENT_TEMPLATE_REPO,
38
+ args["template-ref"],
39
+ );
32
40
 
33
41
  mkdirSync(target, { recursive: true });
42
+
34
43
  cpSync(source, target, {
35
44
  recursive: true,
36
45
  filter: (entry) => !shouldSkip(entry, source),
@@ -38,61 +47,253 @@ cpSync(source, target, {
38
47
 
39
48
  customizeProject(target, {
40
49
  slug,
50
+ mode,
41
51
  displayName: args["display-name"] ?? titleCase(slug),
52
+ templateRepo:
53
+ args["template-path"] ?? args["template-repo"] ?? process.env.NEVEX_AGENT_TEMPLATE_REPO ?? null,
54
+ templateRef: args["template-path"] ? null : (args["template-ref"] ?? null),
55
+ templateCommit: args["template-path"] ? null : readTemplateCommit(source),
42
56
  });
43
57
 
44
58
  if (!args["template-path"]) rmSync(source, { recursive: true, force: true });
45
59
 
46
60
  console.log(`\nProyecto creado en ${target}`);
61
+
47
62
  console.log("\nSiguientes pasos:\n");
63
+
48
64
  console.log(` cd ${JSON.stringify(target)}`);
65
+
49
66
  console.log(" pnpm install");
67
+
50
68
  console.log(" cp .env.example .env.local");
69
+
51
70
  console.log(" pnpm dev\n");
52
71
 
53
- async function materializeTemplate(repository) {
72
+ async function materializeTemplate(repository, ref) {
54
73
  const repo = repository ?? "git@github.com:nevex-labs/nevex-agent-template.git";
55
74
  const directory = join(tmpdir(), `nevex-agent-template-${randomUUID()}`);
56
- execFileSync("git", ["clone", "--depth", "1", repo, directory], { stdio: "inherit" });
75
+ const refArgs = ref ? ["--branch", ref] : [];
76
+ execFileSync("git", ["clone", "--depth", "1", ...refArgs, repo, directory], {
77
+ stdio: "inherit",
78
+ });
79
+
57
80
  return directory;
58
81
  }
59
82
 
83
+ function readTemplateCommit(directory) {
84
+ try {
85
+ return execFileSync("git", ["rev-parse", "HEAD"], {
86
+ cwd: directory,
87
+ encoding: "utf8",
88
+ }).trim();
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
60
94
  function customizeProject(directory, values) {
95
+ applyMode(directory, values.mode);
96
+
61
97
  const packagePath = join(directory, "package.json");
62
98
  const packageJson = JSON.parse(readFileSync(packagePath, "utf8"));
63
99
  packageJson.name = values.slug;
64
100
  packageJson.private = true;
101
+ // The CLI smoke lives in the template only; installations must not keep
102
+ // a script pointing at the package the generator just excluded.
103
+ delete packageJson.scripts?.["test:cli"];
104
+
105
+ if (values.mode === "cloudflare") {
106
+ delete packageJson.scripts?.["build:node"];
107
+ delete packageJson.scripts?.start;
108
+ } else {
109
+ delete packageJson.scripts?.deploy;
110
+ delete packageJson.scripts?.["cf-typegen"];
111
+ packageJson.scripts.dev = "DEPLOY_TARGET=node dotenv -e .env.local -- vite dev";
112
+ packageJson.scripts.build = "DEPLOY_TARGET=node vite build";
113
+ packageJson.scripts.preview = "DEPLOY_TARGET=node vite preview";
114
+ }
115
+
65
116
  writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
66
117
 
118
+ replaceInFile(join(directory, ".github/workflows/ci.yml"), /^ - run: pnpm test:cli\n/m, "");
119
+
120
+ replaceInFile(join(directory, "pnpm-workspace.yaml"), /^\npackages:\n - packages\/\*\n/m, "\n");
121
+
122
+ for (const file of ["README.md", "docs/workflow.md", "docs/README.md"]) {
123
+ stripTemplateOnly(join(directory, file));
124
+ }
125
+
67
126
  replaceInFile(
68
127
  join(directory, "src/lib/config.ts"),
69
- /APP_NAME = "[^"]*"/,
70
- `APP_NAME = ${JSON.stringify(values.displayName)}`,
71
- );
72
- replaceInFile(
73
- join(directory, "wrangler.jsonc"),
74
- /"name": "[^"]*"/,
75
- `"name": ${JSON.stringify(values.slug)}`,
76
- );
77
- replaceInFile(
78
- join(directory, "wrangler.jsonc"),
79
- /"database_name": "[^"]*"/,
80
- `"database_name": ${JSON.stringify(values.slug)}`,
128
+ /^export const APP_NAME = .*;$/m,
129
+ `export const APP_NAME = ${JSON.stringify(values.displayName)};`,
81
130
  );
131
+
132
+ if (values.mode === "cloudflare") {
133
+ replaceInFile(
134
+ join(directory, "wrangler.jsonc"),
135
+ /"name": "[^"]*"/,
136
+ `"name": ${JSON.stringify(values.slug)}`,
137
+ );
138
+ replaceInFile(
139
+ join(directory, "wrangler.jsonc"),
140
+ /"database_name": "[^"]*"/,
141
+ `"database_name": ${JSON.stringify(values.slug)}`,
142
+ );
143
+ }
144
+
82
145
  replaceInFile(
83
146
  join(directory, "README.md"),
84
147
  /^# Nevex Agent Template/m,
85
148
  `# ${values.displayName}`,
86
149
  );
150
+
151
+ replaceInFile(
152
+ join(directory, "AGENTS.md"),
153
+ /^This repo is a template\..*$/m,
154
+ "This repo is a Nevex agent installation: its own repo, database, secrets, and WhatsApp number.",
155
+ );
156
+
157
+ if (values.mode === "node") {
158
+ replaceInFile(
159
+ join(directory, "AGENTS.md"),
160
+ /## Cloudflare resources[\s\S]*$/m,
161
+ `## Node resources
162
+
163
+ - **Host**: VPS or container, Node 22+.
164
+ - **Postgres**: database owned by the installation.
165
+ - **Redis**: claims and pauses with TTL.
166
+ - **Cron triggers**: systemd timer or the host scheduler; Cloudflare \`scheduled\` does not exist in this mode.
167
+ - **Backups**: owned by the host; document the policy once provisioned.
168
+ `,
169
+ );
170
+ }
171
+
172
+ const lines = [
173
+ "# Template origin",
174
+ "",
175
+ `- Template: ${values.templateRepo ?? "git@github.com:nevex-labs/nevex-agent-template.git"}`,
176
+ ];
177
+
178
+ if (values.templateRef) lines.push(`- Ref: ${values.templateRef}`);
179
+
180
+ if (values.templateCommit) lines.push(`- Commit: ${values.templateCommit}`);
181
+ lines.push(
182
+ `- Mode: ${values.mode}`,
183
+ `- Infrastructure: ${heldInfrastructure(values.mode)}`,
184
+ `- Generated: ${new Date().toISOString().slice(0, 10)}`,
185
+ "",
186
+ );
187
+ writeFileSync(join(directory, "TEMPLATE.md"), `${lines.join("\n")}`);
188
+ }
189
+
190
+ function heldInfrastructure(mode) {
191
+ return mode === "cloudflare" ? "Cloudflare Worker + D1 + KV" : "Node + Postgres + Redis";
192
+ }
193
+
194
+ function normalizeMode(value) {
195
+ if (value === undefined) return "cloudflare";
196
+
197
+ if (value !== "cloudflare" && value !== "node") {
198
+ throw new Error(`Modo inválido: ${value}. Usá "cloudflare" o "node".`);
199
+ }
200
+
201
+ return value;
202
+ }
203
+
204
+ function modeDrop(mode) {
205
+ return {
206
+ cloudflare: [
207
+ "compose.yaml",
208
+ "docs/node-deploy.md",
209
+ "migrations/postgres",
210
+ "src/adapters/storage/postgres-conversations.ts",
211
+ "src/adapters/storage/postgres-conversations.test.ts",
212
+ "src/adapters/storage/postgres-conversations.integration.test.ts",
213
+ "src/adapters/storage/redis.ts",
214
+ "src/adapters/storage/redis.test.ts",
215
+ "src/infrastructure/migrations.postgres.test.ts",
216
+ "src/infrastructure/node",
217
+ "src/infrastructure/runtime.node.ts",
218
+ "src/infrastructure/runtime.node.test.ts",
219
+ ],
220
+ node: [
221
+ "docs/cloudflare-deploy.md",
222
+ "wrangler.jsonc",
223
+ "worker-configuration.d.ts",
224
+ "src/adapters/storage/cloudflare-kv.test.ts",
225
+ "src/adapters/storage/cloudflare-kv.ts",
226
+ "src/adapters/storage/d1.ts",
227
+ "src/adapters/storage/d1-conversations.test.ts",
228
+ "src/adapters/storage/d1-conversations.ts",
229
+ "src/infrastructure/cloudflare",
230
+ "src/infrastructure/migrations.d1.test.ts",
231
+ "src/infrastructure/runtime.resolution.test.ts",
232
+ "src/lib/env.test.ts",
233
+ "src/test/helpers/local-d1.ts",
234
+ ],
235
+ }[mode];
236
+ }
237
+
238
+ function applyMode(directory, mode) {
239
+ for (const relative of modeDrop(mode)) {
240
+ rmSync(join(directory, relative), { recursive: true, force: true });
241
+ }
242
+
243
+ if (mode !== "node") return;
244
+
245
+ for (const file of readdirSync(join(directory, "migrations"))) {
246
+ if (file.endsWith(".sql")) rmSync(join(directory, "migrations", file), { force: true });
247
+ }
248
+
249
+ writeFileSync(
250
+ join(directory, "src/infrastructure/runtime.ts"),
251
+ 'export * from "./runtime.node";\n',
252
+ );
253
+ writeFileSync(
254
+ join(directory, "src/server.ts"),
255
+ `import handler from "@tanstack/react-start/server-entry";
256
+ import { withSecurityHeaders } from "#/lib/security-headers";
257
+
258
+ export default {
259
+ async fetch(request: Request) {
260
+ return withSecurityHeaders(request, await handler.fetch(request));
261
+ },
262
+ };
263
+ `,
264
+ );
87
265
  }
88
266
 
89
267
  function replaceInFile(path, pattern, replacement) {
90
268
  const content = readFileSync(path, "utf8");
269
+
270
+ if (!pattern.test(content)) {
271
+ throw new Error(`El patrón ${pattern} no matcheó en ${path}.`);
272
+ }
273
+
91
274
  writeFileSync(path, content.replace(pattern, replacement));
92
275
  }
93
276
 
277
+ // Sections wrapped in template-only markers describe the generator itself.
278
+ // They make no sense in an installation, so the generator cuts them out.
279
+ function stripTemplateOnly(path) {
280
+ const content = readFileSync(path, "utf8");
281
+
282
+ const stripped = content.replace(
283
+ /<!-- template-only:start -->[\s\S]*?<!-- template-only:end -->\n?/g,
284
+ "",
285
+ );
286
+
287
+ if (stripped.includes("template-only:")) {
288
+ throw new Error(`Bloque template-only sin cerrar en ${path}.`);
289
+ }
290
+
291
+ writeFileSync(path, stripped);
292
+ }
293
+
94
294
  function shouldSkip(entry, source) {
95
295
  const relative = entry.slice(source.length + 1);
296
+
96
297
  return (
97
298
  relative === ".git" ||
98
299
  relative.startsWith(".git/") ||
@@ -110,6 +311,7 @@ function shouldSkip(entry, source) {
110
311
 
111
312
  function assertTargetIsSafe(directory) {
112
313
  if (!existsSync(directory)) return;
314
+
113
315
  if (readdirSync(directory).length > 0) {
114
316
  throw new Error(`El directorio ${directory} ya existe y no está vacío.`);
115
317
  }
@@ -120,7 +322,9 @@ function normalizeSlug(value) {
120
322
  .toLowerCase()
121
323
  .replace(/[^a-z0-9]+/g, "-")
122
324
  .replace(/^-|-$/g, "");
325
+
123
326
  if (!slug) throw new Error("El nombre del proyecto no es válido.");
327
+
124
328
  return slug;
125
329
  }
126
330
 
@@ -130,12 +334,15 @@ function titleCase(value) {
130
334
 
131
335
  function parseArgs(argv) {
132
336
  const result = { _: [] };
337
+
133
338
  for (let index = 0; index < argv.length; index += 1) {
134
339
  const value = argv[index];
340
+
135
341
  if (value === "--help" || value === "-h") result.help = true;
136
342
  else if (value.startsWith("--")) result[value.slice(2)] = argv[++index];
137
343
  else result._.push(value);
138
344
  }
345
+
139
346
  return result;
140
347
  }
141
348
 
@@ -144,8 +351,10 @@ function printHelp() {
144
351
 
145
352
  Opciones:
146
353
  --display-name <nombre> Nombre visible del agente
354
+ --mode <cloudflare|node> Infraestructura de la instalación (default: cloudflare)
147
355
  --output <directorio> Directorio de destino
148
356
  --template-path <path> Usar un template local, útil para desarrollo
149
357
  --template-repo <repo> URL o ruta del repositorio template
358
+ --template-ref <ref> Rama o tag del template (default: rama por defecto)
150
359
  -h, --help Mostrar esta ayuda`);
151
360
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nevex-agent",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Create a custom Nevex WhatsApp agent project",
5
5
  "bin": {
6
6
  "create-nevex-agent": "bin/create-nevex-agent.mjs"