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 +12 -0
- package/bin/create-nevex-agent.mjs +224 -15
- package/package.json +1 -1
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|