kcreate-scaffolding-gcp-script 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/bin/cli.js ADDED
@@ -0,0 +1,441 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * kcreate-scaffolding-gcp-script — instalador tipo npx del skill.
4
+ *
5
+ * npx --yes kcreate-scaffolding-gcp-script --target-repo /ruta/al/repo
6
+ * npx --yes kcreate-scaffolding-gcp-script uninstall --target-repo /ruta/al/repo
7
+ * npx --yes kcreate-scaffolding-gcp-script doctor
8
+ *
9
+ * Comandos: install (por defecto) · uninstall · doctor
10
+ * Flags: --target-repo RUTA · --skip-tracking · --force
11
+ * --with-tracking · --clean-gitignore · -h/--help · -v/--version
12
+ *
13
+ * Sin dependencias npm (Node >= 18). Replica exactamente el contrato de
14
+ * install.sh/uninstall.sh de este repo:
15
+ * - destino: <target-repo>/.claude/skills/<skill>/
16
+ * - copia atómica (stage + swap), nunca deja el destino a medias
17
+ * - exit 0 solo si el skill quedó instalado/actualizado (o desinstalado)
18
+ * - exit 1 si no se instaló nada (ya existía sin --force), destino inválido
19
+ * - exit 2 si los argumentos son inválidos (igual que el CLI hermano)
20
+ */
21
+ 'use strict';
22
+
23
+ const { spawnSync } = require('node:child_process');
24
+ const fs = require('node:fs');
25
+ const os = require('node:os');
26
+ const path = require('node:path');
27
+
28
+ const SKILL_NAME = 'kcreate-scaffolding-gcp-script';
29
+ const TRACKING_SKILL_NAME = 'github-proyect-tracking';
30
+ const VERSION = require('../package.json').version;
31
+ const SKILL_SRC = path.join(__dirname, '..', 'skill'); // copia empaquetada
32
+ // Dependencia vendorizada, empaquetada por bin/build.js en prepack (viene de
33
+ // vendor/github-proyect-tracking/ en la raíz del repo).
34
+ const TRACKING_SRC = path.join(__dirname, '..', 'vendor', TRACKING_SKILL_NAME);
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // utilidades
38
+ // ---------------------------------------------------------------------------
39
+
40
+ const log = (m = '') => console.log(m);
41
+ const ok = (m) => log(`\x1b[32m[OK]\x1b[0m ${m}`);
42
+ const info = (m) => log(`[INFO] ${m}`);
43
+ const warn = (m) => console.log(`\x1b[33m[WARN]\x1b[0m ${m}`);
44
+ const err = (m) => console.error(`\x1b[31m[ERROR]\x1b[0m ${m}`);
45
+ const die = (msg, code = 1) => { err(msg); process.exit(code); };
46
+
47
+ const COMMANDS = new Set(['install', 'uninstall', 'doctor']);
48
+ // Flags que exigen un valor no vacío.
49
+ const VALUE_FLAGS = new Set(['--target-repo']);
50
+ // Flags booleanos por comando.
51
+ const BOOL_FLAGS = {
52
+ install: new Set(['--skip-tracking', '--force']),
53
+ uninstall: new Set(['--with-tracking', '--clean-gitignore']),
54
+ doctor: new Set([]),
55
+ };
56
+
57
+ function parseArgs(argv) {
58
+ const out = { command: null, flags: {}, rest: [] };
59
+ for (let i = 0; i < argv.length; i++) {
60
+ const a = argv[i];
61
+ if (a === '-h' || a === '--help') out.flags.help = true;
62
+ else if (a === '-v' || a === '--version') out.flags.version = true;
63
+ else if (VALUE_FLAGS.has(a)) {
64
+ const v = argv[i + 1];
65
+ if (v === undefined || !v.trim() || v.startsWith('-')) {
66
+ die(`el flag ${a} requiere un valor no vacío (ej. ${a} /ruta/al/repo)`, 2);
67
+ }
68
+ out.flags.targetRepo = v;
69
+ i++;
70
+ } else if (a.startsWith('-')) {
71
+ out.rest.push(a);
72
+ } else if (out.command === null) {
73
+ out.command = a;
74
+ } else {
75
+ out.rest.push(a);
76
+ }
77
+ }
78
+ if (out.command === null) out.command = 'install';
79
+ return out;
80
+ }
81
+
82
+ // validateFlags <command> <flags> -> DETENTE con exit 2 si un flag no
83
+ // corresponde al comando (mismo criterio que el CLI hermano, que rechaza
84
+ // --purge-state fuera de uninstall).
85
+ function validateFlags(command, flags) {
86
+ const allowed = BOOL_FLAGS[command] || new Set();
87
+ const known = new Set([...Object.values(BOOL_FLAGS).flatMap((s) => [...s])]);
88
+ for (const a of flags.rest) {
89
+ if (!known.has(a)) die(`flag desconocido: ${a} (usa --help)`, 2);
90
+ if (!allowed.has(a)) die(`${a} no es válido con '${command}' (usa --help)`, 2);
91
+ const key = a.replace(/^--/, '').replace(/-([a-z])/g, (_, c) => c.toUpperCase());
92
+ flags[key] = true;
93
+ }
94
+ }
95
+
96
+ function requireGitRepo(p) {
97
+ if (!fs.existsSync(p) || !fs.statSync(p).isDirectory()) {
98
+ die(`no existe el directorio: ${p}`, 1);
99
+ }
100
+ if (!fs.existsSync(path.join(p, '.git'))) {
101
+ die(`${p} no es la raíz de un repo git (no tiene .git/). Verifica la ruta.`, 1);
102
+ }
103
+ }
104
+
105
+ function resolveTargetRepo(flags) {
106
+ if (!flags.targetRepo) {
107
+ die('falta --target-repo /ruta/al/repo (raíz del repo donde instalar el skill)', 2);
108
+ }
109
+ const p = path.resolve(flags.targetRepo);
110
+ requireGitRepo(p);
111
+ return p;
112
+ }
113
+
114
+ // cleanCaches <dir> — mismo criterio que install.sh (ksc_copy_skill): nunca
115
+ // copiar __pycache__ ni bytecode al destino.
116
+ function cleanCaches(dir) {
117
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
118
+ const p = path.join(dir, e.name);
119
+ if (e.isDirectory()) {
120
+ if (e.name === '__pycache__') fs.rmSync(p, { recursive: true, force: true });
121
+ else cleanCaches(p);
122
+ } else if (/\.py[co]$/.test(e.name)) {
123
+ fs.rmSync(p, { force: true });
124
+ }
125
+ }
126
+ }
127
+
128
+ function validateFrontmatter(skillDir, expectedName) {
129
+ const mdPath = path.join(skillDir, 'SKILL.md');
130
+ if (!fs.existsSync(mdPath)) throw new Error(`falta ${mdPath}`);
131
+ const md = fs.readFileSync(mdPath, 'utf8');
132
+ if (!md.startsWith('---')) throw new Error(`${mdPath}: falta "---" inicial`);
133
+ const rel = md.slice(3).search(/\n---\s*\n/);
134
+ if (rel < 0) throw new Error(`${mdPath}: falta cierre "---"`);
135
+ const fm = md.slice(3, 3 + rel);
136
+ const name = (fm.match(/^name:\s*(.+?)\s*$/m) || [])[1];
137
+ if (name !== expectedName) {
138
+ throw new Error(`${mdPath}: name=${name} (esperado ${expectedName})`);
139
+ }
140
+ }
141
+
142
+ // verifySkill <skillDir> — verificación OBLIGATORIA (no un aviso): si el
143
+ // skill está empaquetado a medias, es mejor fallar aquí que instalarlo.
144
+ function verifySkill(skillDir) {
145
+ validateFrontmatter(skillDir, SKILL_NAME);
146
+ const required = [
147
+ 'scripts/01_init_config.sh',
148
+ 'scripts/02_print_config.sh',
149
+ 'scripts/10_build_components.sh',
150
+ 'scripts/20_validate_file_parity.sh',
151
+ 'scripts/21_validate_project_pinning.sh',
152
+ 'scripts/run_tracking.sh',
153
+ 'scripts/lib/common.sh',
154
+ 'templates/scaffolding.config.template',
155
+ ];
156
+ const missing = required.filter((f) => !fs.existsSync(path.join(skillDir, f)));
157
+ if (missing.length) {
158
+ throw new Error(`paquete corrupto, faltan: ${missing.join(', ')}`);
159
+ }
160
+ }
161
+
162
+ // verifyTracking <skillDir> — la dependencia es un requisito del skill, pero
163
+ // install.sh permite --skip-tracking: se valida solo si se va a instalar.
164
+ function verifyTracking(skillDir) {
165
+ validateFrontmatter(skillDir, TRACKING_SKILL_NAME);
166
+ if (!fs.existsSync(path.join(skillDir, 'scripts', 'tracking.py'))) {
167
+ throw new Error(`paquete corrupto: falta ${skillDir}/scripts/tracking.py`);
168
+ }
169
+ }
170
+
171
+ // copyModes <src> <dest> — replica los permisos de los hijos del origen, como
172
+ // hace `cp -R` (que install.sh usa): node copia con 644/755 por defecto y eso
173
+ // dejaría los scripts sin los bits que tienen en el repo. El directorio raíz
174
+ // del stage NO se toca: install.sh lo hereda de `mktemp -d` (0700) y aquí se
175
+ // replica igual para que el resultado sea idéntico con los dos instaladores.
176
+ function copyModes(src, dest) {
177
+ let st;
178
+ try {
179
+ st = fs.lstatSync(src);
180
+ } catch { return; }
181
+ try { fs.chmodSync(dest, st.mode & 0o7777); } catch { /* noop */ }
182
+ if (!st.isDirectory()) return;
183
+ for (const name of fs.readdirSync(src)) {
184
+ copyModes(path.join(src, name), path.join(dest, name));
185
+ }
186
+ }
187
+
188
+ // copyAtomic <src> <dest> <label> — stage + swap, como ksc_copy_skill.
189
+ function copyAtomic(src, dest, label) {
190
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
191
+ const stage = fs.mkdtempSync(path.join(path.dirname(dest), '.ksc-install.'));
192
+ try {
193
+ fs.cpSync(src, stage, { recursive: true });
194
+ cleanCaches(stage);
195
+ // Permisos: los hijos según el origen; el stage conserva el 0700 de
196
+ // mkdtemp (igual que install.sh) y ese modo pasa a ser el del destino.
197
+ for (const name of fs.readdirSync(src)) {
198
+ copyModes(path.join(src, name), path.join(stage, name));
199
+ }
200
+ fs.rmSync(dest, { recursive: true, force: true });
201
+ fs.renameSync(stage, dest);
202
+ ok(`${label} instalado en ${dest}`);
203
+ } catch (e) {
204
+ fs.rmSync(stage, { recursive: true, force: true });
205
+ throw e;
206
+ }
207
+ }
208
+
209
+ // addExecBits <file> — replica exactamente `chmod +x` de install.sh (suma
210
+ // los bits de ejecución a los que ya tenía, sin normalizar a 755), para que
211
+ // los permisos del destino sean idénticos con los dos instaladores.
212
+ function addExecBits(file) {
213
+ try {
214
+ const mode = fs.statSync(file).mode & 0o7777;
215
+ fs.chmodSync(file, mode | 0o111);
216
+ } catch { /* noop */ }
217
+ }
218
+
219
+ function chmodScripts(skillDir) {
220
+ const dirs = [path.join(skillDir, 'scripts'), path.join(skillDir, 'scripts', 'lib')];
221
+ for (const d of dirs) {
222
+ if (!fs.existsSync(d)) continue;
223
+ for (const f of fs.readdirSync(d)) {
224
+ if (f.endsWith('.sh')) addExecBits(path.join(d, f));
225
+ }
226
+ }
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // comandos
231
+ // ---------------------------------------------------------------------------
232
+
233
+ function install(flags) {
234
+ const target = resolveTargetRepo(flags);
235
+ if (!fs.existsSync(path.join(target, 'live'))) {
236
+ warn(`${target} no tiene un directorio live/ — este skill asume la convención ` +
237
+ 'live/<entorno>/<componente>/terragrunt.hcl. Puede fallar.');
238
+ }
239
+
240
+ const skillsDir = path.join(target, '.claude', 'skills');
241
+ const destMain = path.join(skillsDir, SKILL_NAME);
242
+ const destTracking = path.join(skillsDir, TRACKING_SKILL_NAME);
243
+
244
+ // El skill y su dependencia se validan ANTES de tocar el destino: si el
245
+ // paquete está roto, no se deja una instalación parcial.
246
+ try {
247
+ verifySkill(SKILL_SRC);
248
+ } catch (e) {
249
+ die(e.message, 2);
250
+ }
251
+ if (!flags.skipTracking) {
252
+ try {
253
+ verifyTracking(TRACKING_SRC);
254
+ } catch (e) {
255
+ die(`no se puede instalar ${TRACKING_SKILL_NAME}: ${e.message} ` +
256
+ '(usa --skip-tracking solo si el repo destino ya lo gestiona por su cuenta)', 2);
257
+ }
258
+ }
259
+
260
+ let mainInstalled = false;
261
+ if (fs.existsSync(destMain) && !flags.force) {
262
+ err(`${SKILL_NAME} ya existe en ${destMain} — usa --force para sobreescribir.`);
263
+ } else {
264
+ copyAtomic(SKILL_SRC, destMain, SKILL_NAME);
265
+ chmodScripts(destMain);
266
+ mainInstalled = true;
267
+ }
268
+
269
+ let trackingInstalled = false;
270
+ if (flags.skipTracking) {
271
+ info(`--skip-tracking: no se tocó ${TRACKING_SKILL_NAME}`);
272
+ } else if (fs.existsSync(destTracking) && !flags.force) {
273
+ info(`${TRACKING_SKILL_NAME} ya existe en el destino — no se sobreescribe (usa --force).`);
274
+ } else {
275
+ copyAtomic(TRACKING_SRC, destTracking, TRACKING_SKILL_NAME);
276
+ addExecBits(path.join(destTracking, 'scripts', 'tracking.py'));
277
+ trackingInstalled = true;
278
+ }
279
+
280
+ log();
281
+ // Mismo contrato que install.sh: exit 0 ⇔ el skill quedó instalado.
282
+ if (!mainInstalled) {
283
+ die('No se instaló nada. Vuelve a correr con --force para sobreescribir ' +
284
+ 'la instalación existente.', 1);
285
+ }
286
+
287
+ ok(`Instalación completa en ${target}`);
288
+ if (!trackingInstalled) info(`${TRACKING_SKILL_NAME} no fue (re)instalado en esta corrida.`);
289
+ log('Siguiente paso: desde una sesión de Claude Code abierta en ese repo,');
290
+ log('invoca el skill con:');
291
+ log();
292
+ log(` /${SKILL_NAME} <SOURCE_ENV> <NEW_ENV>`);
293
+ log();
294
+ log(`Ejemplo: /${SKILL_NAME} boletera-qa xoloshop-prod`);
295
+ }
296
+
297
+ function uninstall(flags) {
298
+ const target = resolveTargetRepo(flags);
299
+ const skillsDir = path.join(target, '.claude', 'skills');
300
+ const destMain = path.join(skillsDir, SKILL_NAME);
301
+ const destTracking = path.join(skillsDir, TRACKING_SKILL_NAME);
302
+
303
+ if (fs.existsSync(destMain)) {
304
+ fs.rmSync(destMain, { recursive: true, force: true });
305
+ ok(`eliminado ${destMain}`);
306
+ } else {
307
+ info(`${SKILL_NAME} no estaba instalado en ${target}`);
308
+ }
309
+
310
+ if (flags.withTracking) {
311
+ if (fs.existsSync(destTracking)) {
312
+ fs.rmSync(destTracking, { recursive: true, force: true });
313
+ ok(`eliminado ${destTracking}`);
314
+ } else {
315
+ info(`${TRACKING_SKILL_NAME} no estaba instalado en ${target}`);
316
+ }
317
+ }
318
+
319
+ if (flags.cleanGitignore) {
320
+ const gitignore = path.join(target, '.gitignore');
321
+ const markers = [
322
+ '.kcreate-scaffolding/',
323
+ '# kcreate-scaffolding-gcp-script: configuracion de ejecucion, no comitear',
324
+ ];
325
+ if (fs.existsSync(gitignore)) {
326
+ const text = fs.readFileSync(gitignore, 'utf8');
327
+ const endsWithNewline = text.endsWith('\n');
328
+ const lines = endsWithNewline ? text.slice(0, -1).split('\n') : text.split('\n');
329
+ const kept = [];
330
+ for (const line of lines) {
331
+ if (markers.includes(line)) continue;
332
+ // colapsar líneas en blanco consecutivas, igual que `cat -s` en
333
+ // uninstall.sh (que es la implementación de referencia).
334
+ if (line === '' && kept.length && kept[kept.length - 1] === '') continue;
335
+ kept.push(line);
336
+ }
337
+ fs.writeFileSync(
338
+ gitignore,
339
+ kept.join('\n') + (endsWithNewline && kept.length ? '\n' : '')
340
+ );
341
+ ok(`quitada la línea .kcreate-scaffolding/ de ${gitignore}`);
342
+ }
343
+ const stateDir = path.join(target, '.kcreate-scaffolding');
344
+ if (fs.existsSync(stateDir)) {
345
+ fs.rmSync(stateDir, { recursive: true, force: true });
346
+ ok(`eliminado ${stateDir}/`);
347
+ }
348
+ }
349
+ log('Listo.');
350
+ }
351
+
352
+ // doctor — diagnóstico SIN tocar nada: qué hay instalado en el repo destino y
353
+ // si sus dependencias (bash/python3/terragrunt/git) están disponibles.
354
+ function doctor(flags) {
355
+ const target = resolveTargetRepo(flags);
356
+ const destMain = path.join(target, '.claude', 'skills', SKILL_NAME);
357
+ const destTracking = path.join(target, '.claude', 'skills', TRACKING_SKILL_NAME);
358
+
359
+ log(`kcreate-scaffolding-gcp-script/${VERSION} (instalador npx)`);
360
+ log(`repo destino : ${target}`);
361
+ log(`live/ : ${fs.existsSync(path.join(target, 'live')) ? 'sí' : 'NO (el skill asumirá live/<env>/...)'}`);
362
+ log(`skill : ${fs.existsSync(destMain) ? destMain : 'no instalado'}`);
363
+ log(`tracking : ${fs.existsSync(destTracking) ? destTracking : 'no instalado'}`);
364
+ log();
365
+
366
+ const checks = [
367
+ ['bash', ['--version']],
368
+ ['git', ['--version']],
369
+ ['python3', ['-c', 'import ipaddress; print("ipaddress OK")']],
370
+ ['curl', ['--version']],
371
+ ['terragrunt', ['--version']],
372
+ ];
373
+ let blocking = 0;
374
+ for (const [cmd, args] of checks) {
375
+ const r = spawnSync(cmd, args, { encoding: 'utf8' });
376
+ const available = !r.error;
377
+ const optional = cmd === 'terragrunt';
378
+ if (available) {
379
+ const first = (r.stdout || '').trim().split('\n')[0];
380
+ ok(`${cmd}: ${first}`);
381
+ } else if (optional) {
382
+ warn(`${cmd}: no disponible (opcional — omite hcl fmt/validate al construir)`);
383
+ } else {
384
+ err(`${cmd}: NO disponible (requerido por los scripts del skill)`);
385
+ blocking++;
386
+ }
387
+ }
388
+ log();
389
+ if (blocking) {
390
+ err(`${blocking} requisito(s) faltante(s) — el skill fallará sin ellos.`);
391
+ process.exit(1);
392
+ }
393
+ ok('Entorno listo para usar el skill.');
394
+ }
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // ayuda y main
398
+ // ---------------------------------------------------------------------------
399
+
400
+ function help() {
401
+ log(`${SKILL_NAME} v${VERSION} — instalador npx del skill`);
402
+ log();
403
+ log('Uso:');
404
+ log(` npx --yes ${SKILL_NAME} --target-repo /ruta/al/repo instalar (o actualizar)`);
405
+ log(` npx --yes ${SKILL_NAME} uninstall --target-repo /ruta desinstalar`);
406
+ log(` npx --yes ${SKILL_NAME} doctor --target-repo /ruta diagnóstico sin cambios`);
407
+ log();
408
+ log('Flags de install:');
409
+ log(' --target-repo RUTA raíz del repo destino (debe ser un repo git) [obligatorio]');
410
+ log(' --skip-tracking no instala/actualiza github-proyect-tracking');
411
+ log(' --force sobreescribe una instalación existente');
412
+ log();
413
+ log('Flags de uninstall:');
414
+ log(' --with-tracking elimina también github-proyect-tracking');
415
+ log(' --clean-gitignore quita .kcreate-scaffolding/ del .gitignore y borra la carpeta');
416
+ log();
417
+ log(' -h, --help esta ayuda');
418
+ log(' -v, --version versión del paquete');
419
+ log();
420
+ log('Destino: <repo>/.claude/skills/kcreate-scaffolding-gcp-script/ (+ github-proyect-tracking/).');
421
+ log('Requisitos: Node >= 18 para este instalador; bash, git, python3 3.8+, curl y');
422
+ log('GNU sed para los scripts; terragrunt opcional. Instalar el skill NO crea nada');
423
+ log('en GCP: solo prepara la herramienta.');
424
+ }
425
+
426
+ function main() {
427
+ const { command, flags, rest } = parseArgs(process.argv.slice(2));
428
+ if (flags.version) { log(`${SKILL_NAME}/${VERSION} (instalador npx)`); return; }
429
+ if (flags.help) { help(); return; }
430
+ if (!COMMANDS.has(command)) die(`comando desconocido: ${command} (usa --help)`, 2);
431
+ flags.rest = rest;
432
+ validateFlags(command, flags);
433
+
434
+ switch (command) {
435
+ case 'install': install(flags); break;
436
+ case 'uninstall': uninstall(flags); break;
437
+ case 'doctor': doctor(flags); break;
438
+ }
439
+ }
440
+
441
+ main();
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "kcreate-scaffolding-gcp-script",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Instalador npx del skill kcreate-scaffolding-gcp-script: genera el scaffolding de Terragrunt para un entorno GCP nuevo a partir de uno existente.",
5
+ "bin": {
6
+ "kcreate-scaffolding-gcp-script": "bin/cli.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "skill/",
11
+ "vendor/",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "scripts": {
19
+ "build": "node bin/build.js",
20
+ "prepack": "node bin/build.js"
21
+ },
22
+ "keywords": [
23
+ "terragrunt",
24
+ "terraform",
25
+ "gcp",
26
+ "scaffolding",
27
+ "skill",
28
+ "claude-code",
29
+ "instalador"
30
+ ],
31
+ "author": "Rommel (https://github.com/rommelmg)",
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/kronops/KCreateScaffoldingGCPScripts.git"
36
+ },
37
+ "private": false
38
+ }