@ingeniomaps/cauce 0.54.0 → 0.56.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/CHANGELOG.md CHANGED
@@ -14,6 +14,67 @@ desde este repositorio no va, porque el que lee no puede actuar sobre eso. Cuand
14
14
  unas pocas líneas casi siempre es porque cuenta cómo se descubrió el problema o por qué se eligió el
15
15
  diseño — eso vive en el commit y en el código.
16
16
 
17
+ ## [0.56.0] - 2026-09-03
18
+
19
+ ### Corregido
20
+
21
+ - **El upgrade que documentaba el README rompía el pin exacto que puso `init`.** La razón para no
22
+ saltear su primer paso es que «`init` fija la versión exacta, así que `npm update` no la mueve», y
23
+ el comando de al lado la desarmaba: npm guarda con caret por defecto, así que
24
+ `npm install --save-dev @ingeniomaps/cauce@latest` dejaba `^0.55.0`, y dentro de `0.x` ese caret
25
+ alcanza a los patches —que Cauce publica—. Un `npm install` en otra máquina podía dejar el motor en
26
+ una versión que `ops.config.json` no decía.
27
+
28
+ Se arregla por los dos lados. El comando lleva `--save-exact` en los tres lugares que lo dictan —el
29
+ README, el `make upgrade` de tu instancia y la salida de `upgrade --check`—, y además **`upgrade`
30
+ repone la versión exacta al terminar**, así que una instancia que ya quedó con un rango se repara
31
+ sola en la próxima actualización. **Qué cambia para vos**: `upgrade` puede tocar ahora una línea de
32
+ tu `package.json`, y lo dice cuando lo hace. El resto del manifiesto queda como estaba, y una
33
+ instancia sin `package.json` no recibe uno.
34
+
35
+ ## [0.55.0] - 2026-09-02
36
+
37
+ ### Corregido
38
+
39
+ - **`upgrade` ya no pisa lo que `init --force` conservó.** Adoptar Cauce en un repositorio que ya tiene
40
+ contenido es `init --force` y después `upgrade`. El primero conserva tus archivos y lo cumple; el
41
+ segundo los reemplazaba sin avisar e imprimía que no había tocado nada propio. El registro se grababa
42
+ hasheando el disco, así que un archivo conservado quedaba anotado como entregado por Cauce con **tu**
43
+ contenido, y la comparación no veía ninguna diferencia. Ahora se anota el digest de lo que el molde
44
+ habría escrito: tu archivo se ve como lo que es —una edición local— y `upgrade` se detiene
45
+ nombrándolo. **Qué cambia para vos**: el primer `upgrade` después de una adopción con `--force` se
46
+ detiene listando esos archivos. Resolvelos a mano, o repetí con `--force` para descartarlos, que
47
+ ahora además los enumera en la salida.
48
+ - **Tu guard propio sobrevive a `automation install`.** `AGENTS.md` te dice que lo pongas en
49
+ `automatization/hooks/guard-<nombre>.sh` y promete que sobrevive a cada actualización. Sobrevivía al
50
+ `upgrade` y no a la reinstalación del runner: lo nuestro se reconocía por la carpeta, así que
51
+ cualquier `guard-*.sh` de ahí se desregistraba antes de fusionar. El archivo quedaba en disco, el
52
+ guard dejaba de correr y lo único que se veía era una línea diciendo que se había quitado una entrada
53
+ obsoleta. Ahora se reconoce por lo que efectivamente entregamos: los guards que el motor trae, más
54
+ los que esta instancia anotó haber recibido —el manifiesto gana una entrada por runner con el wiring
55
+ que dejó puesto, y por eso una entrada nuestra se sigue retirando el día que el motor deje de traer
56
+ ese guard—. **Si moviste tu guard fuera de esa carpeta para sortearlo, ya podés devolverlo.**
57
+ - **`automation doctor` deja de llamar divergente a una configuración completa.** Un hook propio sumado
58
+ *dentro* de un grupo que escribió el toolkit dejaba a `doctor` en rojo aunque estuvieran todas las
59
+ entradas esperadas: comparaba el grupo entero serializado, así que uno con un hook de más contaba
60
+ como ausente. Ahora compara por `matcher` y contenido. Lo que falta se sigue reportando igual.
61
+ - **`upgrade --check` avisa de lo que tenés editado aunque la versión coincida.** Salía por un camino
62
+ corto que informaba «al día» sin llegar a listarlo, que es el estado normal entre actualizaciones
63
+ —`init` fija la versión exacta—: el único modo que no toca nada era también el único que no podía
64
+ anticipar el conflicto. **Qué cambia para vos**: `--check` ahora sale 1 cuando hay archivos del
65
+ sistema editados, un caso donde antes salía 0. Si lo tenés en un script, revisá esa condición.
66
+
67
+ ### Cambiado
68
+
69
+ - **`init` e `init .` eligen el mismo modo en el mismo directorio.** El default salía de si escribiste
70
+ el argumento, no de a dónde apunta: en `acme-ops/`, `init` daba `sidecar` e `init .` daba `embedded`.
71
+ No es cosmético — en `embedded` la raíz del workspace pasa a ser la carpeta ops misma, así que
72
+ `guard-workspace-boundary` deja de reconocer los repos hermanos y el wiring del runner se escribe
73
+ adentro en vez de al lado, donde abrís tu herramienta. Ahora lo decide el destino resuelto, con la
74
+ misma regla que ya elegía dónde aterrizar: una carpeta llamada `ops` o terminada en `-ops` **es** la
75
+ instancia. **Qué cambia para vos**: `init .` en una carpeta así ahora da `sidecar`, donde antes daba
76
+ `embedded`. El caso embebido sigue disponible pidiéndolo: `init . --mode embedded`.
77
+
17
78
  ## [0.54.0] - 2026-09-02
18
79
 
19
80
  ### Agregado
package/README.md CHANGED
@@ -266,13 +266,15 @@ existente detiene el `upgrade` antes de pisarlo.
266
266
  Son tres pasos y `make upgrade` hace los dos primeros:
267
267
 
268
268
  ```bash
269
- npm install --save-dev @ingeniomaps/cauce@latest # trae el motor nuevo
269
+ npm install --save-exact --save-dev @ingeniomaps/cauce@latest # trae el motor nuevo
270
270
  node tools/ops.js upgrade . # aplica system/ y el runtime
271
271
  node tools/ops.js automation install . claude # el wiring del runner
272
272
  ```
273
273
 
274
274
  El primero no se puede saltear: `init` fija la versión exacta, así que `npm update` no la mueve y
275
- `upgrade` compara contra el motor instalado —lo dice en su salida—. El tercero tampoco: los workflows y
275
+ `upgrade` compara contra el motor instalado —lo dice en su salida—. Va con `--save-exact` porque npm
276
+ guarda con caret por defecto, y ese caret es justamente lo que volvería falsa la frase anterior; si
277
+ alguna vez se instaló sin él, `upgrade` repone la versión exacta al terminar. El tercero tampoco: los workflows y
276
278
  las skills viven en el runner, no en la instancia, y `upgrade` no los toca. `upgrade` lo recuerda al
277
279
  terminar.
278
280
 
@@ -8,7 +8,7 @@
8
8
  const fs = require('node:fs')
9
9
  const path = require('node:path')
10
10
  const F = require('../core/files')
11
- const { supersededGuards } = require('./hooks')
11
+ const { supersededGuards, expectedHooks } = require('./hooks')
12
12
 
13
13
  function mergeConfig(current, incoming) {
14
14
  if (Array.isArray(incoming)) {
@@ -32,19 +32,40 @@ function mergeConfig(current, incoming) {
32
32
  return incoming
33
33
  }
34
34
 
35
- // Una entrada de hook que puso Cauce se reconoce por el guard al que apunta: `automatization/hooks/`
36
- // es nuestro y ninguna empresa escribe ahí. Se saca del archivo del usuario antes de fusionar para que
37
- // el merge deje exactamente las de esta versión, ni una más.
38
- const DELIVERED = /automatization\/hooks\/guard-[a-z-]+\.sh/
35
+ // Una entrada de hook que puso Cauce se reconoce por el guard al que apunta, y lo nuestro es lo que
36
+ // efectivamente entregamos, no todo lo que vive en nuestra carpeta. Reconocer por directorio
37
+ // desregistraba el guard propio del proyecto en cada reinstalación: `template/AGENTS.md` invita a
38
+ // ponerlo justo ahí y promete que sobrevive a cada actualización, el archivo quedaba en disco, y lo
39
+ // único que se veía era una línea diciendo que se había quitado una entrada obsoleta.
40
+ // Se saca del archivo del usuario antes de fusionar para que el merge deje exactamente las de esta
41
+ // versión, ni una más.
42
+ const HOOK_PATH = /automatization\/hooks\/([a-z0-9-]+\.sh)(?:\s|$)/
39
43
 
40
- function withoutDeliveredHooks(config, live) {
44
+ // Dos fuentes, y la segunda es la que cierra el borde. `expectedHooks()` dice qué entrega el motor de
45
+ // hoy y alcanza para una instancia que nunca instaló; `delivered` es lo que esta instancia registró
46
+ // haber recibido la última vez, y es lo único que reconoce un guard que entregamos en una versión y
47
+ // retiramos en la siguiente: su nombre ya no está en la lista de hoy, así que sin el registro quedaría
48
+ // vivo en la configuración llamando a un guard que Cauce ya no mantiene. Un guard propio del proyecto
49
+ // no está en ninguna de las dos, que es exactamente por lo que sobrevive.
50
+ function isDelivered(command, delivered) {
51
+ const hit = String(command).match(HOOK_PATH)
52
+ if (!hit) return false
53
+ return expectedHooks().includes(hit[1]) || delivered.has(String(command))
54
+ }
55
+
56
+ // Lo que de un conjunto de comandos es wiring de guards nuestro, para anotarlo como entregado.
57
+ function deliveredHookCommands(commands) {
58
+ return [...commands].filter((command) => HOOK_PATH.test(String(command)))
59
+ }
60
+
61
+ function withoutDeliveredHooks(config, live, delivered = new Set()) {
41
62
  const dropped = []
42
63
  const walk = (node) => {
43
64
  if (Array.isArray(node)) {
44
65
  return node
45
66
  .filter((item) => {
46
67
  const command = item && typeof item === 'object' ? String(item.command || '') : ''
47
- if (!DELIVERED.test(command)) return true
68
+ if (!isDelivered(command, delivered)) return true
48
69
  // Sólo se anuncia lo que ya no vuelve: una entrada que el merge repone quedó igual, y decir
49
70
  // que se quitó y se puso la misma línea es ruido que esconde el caso que sí importa.
50
71
  if (!live.has(command)) dropped.push(command)
@@ -83,9 +104,20 @@ function reportRemoved(name, dropped, live, output) {
83
104
 
84
105
  function includesConfig(actual, expected) {
85
106
  if (Array.isArray(expected)) {
86
- return Array.isArray(actual) && expected.every((item) => {
87
- return actual.some((value) => JSON.stringify(value) === JSON.stringify(item))
88
- })
107
+ return Array.isArray(actual) && expected.every((item) => actual.some((value) => {
108
+ // Un grupo de hooks se compara por su contenido, no por su forma serializada. La comprobación es
109
+ // «contiene», que es lo correcto, pero con el ítem entero como unidad un grupo `{matcher, hooks}`
110
+ // con un hook de más dejaba de ser el mismo objeto y contaba como ausente: `doctor` llamaba
111
+ // divergente a una configuración que tenía todo lo esperado, y el anidamiento —donde vive la
112
+ // diferencia real— no se miraba nunca. La instalación promete conservar lo que el proyecto ya
113
+ // tenía, así que sumar un hook propio a un grupo nuestro es exactamente lo que permite.
114
+ if (item && typeof item === 'object' && Array.isArray(item.hooks)) {
115
+ return value && typeof value === 'object'
116
+ && value.matcher === item.matcher
117
+ && includesConfig(value.hooks, item.hooks)
118
+ }
119
+ return JSON.stringify(value) === JSON.stringify(item)
120
+ }))
89
121
  }
90
122
  if (expected && typeof expected === 'object') {
91
123
  return actual && typeof actual === 'object' && Object.entries(expected).every(([key, value]) => {
@@ -170,6 +202,6 @@ function blockUpToDate(file, name, content) {
170
202
 
171
203
  module.exports = {
172
204
  blockStart,
173
- mergeConfig, withoutDeliveredHooks, reportRemoved, includesConfig, hasHooks,
205
+ mergeConfig, withoutDeliveredHooks, deliveredHookCommands, reportRemoved, includesConfig, hasHooks,
174
206
  unmergeConfig, isSharedFile, withoutBlock, mergeInstruction, blockUpToDate,
175
207
  }
@@ -17,7 +17,7 @@ const {
17
17
  legacyGuardWiring, staleHooks, listHooks,
18
18
  } = require('./hooks')
19
19
  const {
20
- blockStart, mergeConfig, withoutDeliveredHooks, reportRemoved, includesConfig, hasHooks,
20
+ blockStart, mergeConfig, withoutDeliveredHooks, deliveredHookCommands, reportRemoved, includesConfig, hasHooks,
21
21
  unmergeConfig, isSharedFile, withoutBlock, mergeInstruction, blockUpToDate,
22
22
  } = require('./config')
23
23
 
@@ -226,6 +226,11 @@ function deliveryKey(name, target) {
226
226
  return `${name}/${target.split(path.sep).join('/')}`
227
227
  }
228
228
 
229
+ // La sección `runners` del manifiesto anota archivo → digest; ésta es la única entrada que guarda una
230
+ // lista, porque lo que registra no es un archivo sino las entradas que dejamos dentro del archivo de
231
+ // configuración del usuario. No colisiona con ningún `target`: ninguno se llama así.
232
+ const HOOKS_KEY = 'config.hooks'
233
+
229
234
  // En qué estado quedó un archivo que este adaptador entregó alguna vez.
230
235
  //
231
236
  // nuevo no existe todavía
@@ -277,6 +282,7 @@ function uninstall(root, name, output = console) {
277
282
 
278
283
  const items = [...(runner.instructions || []), ...(runner.artifacts || [])]
279
284
  const deliveredPaths = { ...recorded }
285
+ delete deliveredPaths[deliveryKey(name, HOOKS_KEY)]
280
286
  for (const item of items) {
281
287
  const resolved = { item, ...resolveItem(paths, root, name, item) }
282
288
  const key = deliveryKey(name, item.target)
@@ -378,7 +384,12 @@ function install(root, name, output = console, options = {}) {
378
384
  // guard que el runner intenta ejecutar y falla. Se quitan las nuestras y las vuelve a poner el merge.
379
385
  const live = new Set((JSON.stringify(incoming).match(/"command":"[^"]*"/g) || [])
380
386
  .map((entry) => JSON.parse(`{${entry}}`).command))
381
- const clean = runner.config.owned ? { config: {}, dropped: [] } : withoutDeliveredHooks(current, live)
387
+ // Lo que este adaptador anotó haber entregado la última vez. Es lo único que reconoce una entrada
388
+ // nuestra cuyo guard el motor ya no trae: por nombre no se distingue de la que agregó el proyecto.
389
+ const previous = new Set(recorded[deliveryKey(name, HOOKS_KEY)] || [])
390
+ const clean = runner.config.owned
391
+ ? { config: {}, dropped: [] }
392
+ : withoutDeliveredHooks(current, live, previous)
382
393
  reportRemoved(name, clean.dropped, live, output)
383
394
  F.atomicWriteJson(paths.configTarget, mergeConfig(clean.config, incoming))
384
395
  // Dónde aterrizó, no sólo qué archivo: en sidecar el destino no es el repo desde el que se corrió
@@ -388,6 +399,12 @@ function install(root, name, output = console, options = {}) {
388
399
  }
389
400
  output.log(`✓ ${name}: configuración instalada en ${runner.config.target}`)
390
401
  const deliveredPaths = { ...recorded }
402
+ // Qué wiring de guards dejamos puesto, para poder retirarlo el día que el motor deje de traerlo. Se
403
+ // anota lo que instalamos y no lo que quedó en el archivo: ahí conviven las entradas del proyecto, y
404
+ // registrarlas nos autorizaría a borrar lo ajeno en la instalación siguiente.
405
+ const ourHooks = deliveredHookCommands(live)
406
+ if (ourHooks.length) deliveredPaths[deliveryKey(name, HOOKS_KEY)] = ourHooks
407
+ else delete deliveredPaths[deliveryKey(name, HOOKS_KEY)]
391
408
  for (const resolved of resolvedItems) {
392
409
  const status = state.get(resolved)
393
410
  const ownFile = runner.instructions.includes(resolved.item)
@@ -0,0 +1,73 @@
1
+ 'use strict'
2
+
3
+ // Cómo una instancia declara el motor en su `package.json`: ponerlo, reponerlo y sacarlo. Vive aparte
4
+ // del ciclo de vida porque su reloj es otro —npm y el versionado, no crear/actualizar/borrar— y porque
5
+ // sus tres consumidores no se solapan: `scaffold` declara, `upgrade` repone y `destroy` saca.
6
+
7
+ const fs = require('node:fs')
8
+ const path = require('node:path')
9
+ const F = require('../core/files')
10
+ const { fail } = require('./io')
11
+
12
+ // Declara el motor como dependencia exacta: el lockfile decide qué versión corre, no una copia.
13
+ // Conserva el manifiesto existente porque el repo anfitrión puede tener el suyo.
14
+ function declareEngine(manifest, version) {
15
+ let pkg = { name: path.basename(path.dirname(manifest)), private: true, version: '0.0.0' }
16
+ if (fs.existsSync(manifest)) {
17
+ try { pkg = JSON.parse(fs.readFileSync(manifest, 'utf8')) } catch (error) {
18
+ fail(`package.json inválido en ${manifest}: ${error.message}`)
19
+ }
20
+ }
21
+ pkg.devDependencies = { ...pkg.devDependencies, '@ingeniomaps/cauce': version }
22
+ F.atomicWriteJson(manifest, pkg)
23
+ }
24
+
25
+ // Repone la versión exacta que `declareEngine` había dejado, y devuelve la que había si cambió algo.
26
+ //
27
+ // El pin no sobrevive por sí solo: npm guarda con caret, así que el `npm install @latest` del primer
28
+ // paso del upgrade lo convierte en un rango, y con eso deja de valer la razón que el README da para no
29
+ // saltear ese paso —«init fija la versión exacta, así que npm update no la mueve»—. Repararlo acá y no
30
+ // sólo documentar el comando es lo que vuelve la propiedad independiente de cómo se haya instalado.
31
+ //
32
+ // Sin manifiesto no hay nada que reponer: una instancia que no lo tiene resuelve el motor de otra
33
+ // forma, y crearle uno sería otra operación que nadie pidió en un upgrade.
34
+ function pinEngine(root, version) {
35
+ const manifest = path.join(root, 'package.json')
36
+ if (!fs.existsSync(manifest)) return null
37
+ let pkg
38
+ try { pkg = JSON.parse(fs.readFileSync(manifest, 'utf8')) } catch { return 'ilegible' }
39
+ const current = (pkg.devDependencies || {})['@ingeniomaps/cauce']
40
+ if (!current || current === version) return null
41
+ declareEngine(manifest, version)
42
+ return current
43
+ }
44
+
45
+ // La inversa exacta de `declareEngine`: saca la clave que puso y nada más. El resto del manifiesto es
46
+ // del repo anfitrión aunque hoy no tenga otra cosa —un `package.json` vacío puede ser lo que alguien
47
+ // escribió para tener scripts— así que el archivo se borra sólo si es idéntico al que `declareEngine`
48
+ // habría creado desde cero, sin dependencias, sin scripts y con su `version: 0.0.0`.
49
+ //
50
+ // Lo que no se toca nunca es `node_modules/` ni el lockfile: los escribe npm, pueden tener dependencias
51
+ // del proyecto y borrarlos por nuestra cuenta destruye trabajo ajeno. Se nombran en la salida, que es
52
+ // la mitad que faltaba: `destroy` decía «tu repositorio queda donde está» y dejaba un `package.json`
53
+ // cuya única dependencia era Cauce. En un repo Rust eso es basura conspicua y nadie avisaba.
54
+ function undeclareEngine(manifest) {
55
+ if (!fs.existsSync(manifest)) return []
56
+ let pkg
57
+ try { pkg = JSON.parse(fs.readFileSync(manifest, 'utf8')) } catch { return [] }
58
+ const dev = pkg.devDependencies || {}
59
+ if (!('@ingeniomaps/cauce' in dev)) return []
60
+ delete dev['@ingeniomaps/cauce']
61
+ pkg.devDependencies = dev
62
+ const ours = !Object.keys(dev).length && !Object.keys(pkg.dependencies || {}).length
63
+ && !Object.keys(pkg.scripts || {}).length && pkg.private === true && pkg.version === '0.0.0'
64
+ if (ours) {
65
+ fs.rmSync(manifest, { force: true })
66
+ return ['package.json (lo había creado init: sin dependencias ni scripts propios)']
67
+ }
68
+ if (!Object.keys(dev).length) delete pkg.devDependencies
69
+ F.atomicWriteJson(manifest, pkg)
70
+ return ['package.json: se quitó la dependencia del motor y el resto queda como estaba']
71
+ }
72
+
73
+ module.exports = { declareEngine, pinEngine, undeclareEngine }
@@ -16,6 +16,7 @@ const P = require('../planning/parser')
16
16
  const ST = require('../planning/state')
17
17
  const A = require('../automation')
18
18
  const { fail } = require('./io')
19
+ const { declareEngine, pinEngine, undeclareEngine } = require('./dependency')
19
20
 
20
21
  const PROJECT_ROOT = path.resolve(__dirname, '..', '..')
21
22
 
@@ -29,9 +30,14 @@ function providerNames() {
29
30
  } catch { return [] }
30
31
  }
31
32
 
33
+ // Devuelve, por archivo conservado, el digest de lo que el molde **habría** escrito. Adoptar Cauce en
34
+ // un repositorio con contenido es `init --force`, y lo que se conserva ahí lo escribió la empresa: si
35
+ // el manifiesto lo registra hasheando el disco queda declarado como entregado por Cauce con contenido
36
+ // que Cauce nunca entregó, y el `upgrade` siguiente no ve ninguna edición local y lo reemplaza.
32
37
  function copyTemplate(source, target, replacements, force, skip = [], quiet = false) {
33
38
  F.assertNoSymlinkPath(path.dirname(target), target)
34
39
  fs.mkdirSync(target, { recursive: true })
40
+ const preserved = {}
35
41
  for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
36
42
  if (skip.includes(entry.name)) continue
37
43
  const from = path.join(source, entry.name)
@@ -39,11 +45,14 @@ function copyTemplate(source, target, replacements, force, skip = [], quiet = fa
39
45
  // 11.16.0: lo deja afuera en la raíz y en subdirectorios—, así que viaja sin punto y se restituye
40
46
  // acá. Sin esto el archivo existe en el repo del toolkit y desaparece para todo consumidor real.
41
47
  const to = path.join(target, entry.name === 'gitignore' ? '.gitignore' : entry.name)
42
- if (entry.isDirectory()) copyTemplate(from, to, replacements, force, skip, quiet)
48
+ if (entry.isDirectory()) Object.assign(preserved, copyTemplate(from, to, replacements, force, skip, quiet))
43
49
  else {
44
50
  if (fs.existsSync(to)) {
45
51
  if (!force) fail(`El destino contiene ${to}. Usa un directorio vacío o --force.`)
46
52
  if (!quiet) console.log(`= conservado ${to}`)
53
+ let would = fs.readFileSync(from, 'utf8')
54
+ for (const [key, value] of Object.entries(replacements)) would = would.replaceAll(key, value)
55
+ preserved[to] = M.digestText(would)
47
56
  continue
48
57
  }
49
58
  let content = fs.readFileSync(from, 'utf8')
@@ -53,70 +62,36 @@ function copyTemplate(source, target, replacements, force, skip = [], quiet = fa
53
62
  if (!quiet) console.log(`+ ${to}`)
54
63
  }
55
64
  }
65
+ return preserved
56
66
  }
57
67
 
68
+ // Devuelve lo conservado igual que `copyTemplate`, y por la misma razón: acá el runtime no lleva
69
+ // reemplazos, así que lo que habríamos escrito es el archivo del paquete tal cual.
58
70
  function copyRuntime(source, target, preserve = false, boundary = target, skip = []) {
59
71
  F.assertNoSymlinkPath(boundary, target)
60
72
  fs.mkdirSync(target, { recursive: true })
73
+ const preserved = {}
61
74
  for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
62
75
  if (skip.includes(entry.name)) continue
63
76
  const from = path.join(source, entry.name)
64
77
  const to = path.join(target, entry.name)
65
- if (entry.isDirectory()) copyRuntime(from, to, preserve, boundary, skip)
66
- else if (preserve && fs.existsSync(to)) console.log(`= conservado ${to}`)
67
- else {
78
+ if (entry.isDirectory()) Object.assign(preserved, copyRuntime(from, to, preserve, boundary, skip))
79
+ else if (preserve && fs.existsSync(to)) {
80
+ console.log(`= conservado ${to}`)
81
+ preserved[to] = M.digest(from)
82
+ } else {
68
83
  F.assertNoSymlinkPath(boundary, to)
69
84
  fs.copyFileSync(from, to)
70
85
  }
71
86
  }
72
- }
73
-
74
- // Declara el motor como dependencia exacta: el lockfile decide qué versión corre, no una copia.
75
- // Conserva el manifiesto existente porque el repo anfitrión puede tener el suyo.
76
- function declareEngine(manifest, version) {
77
- let pkg = { name: path.basename(path.dirname(manifest)), private: true, version: '0.0.0' }
78
- if (fs.existsSync(manifest)) {
79
- try { pkg = JSON.parse(fs.readFileSync(manifest, 'utf8')) } catch (error) {
80
- fail(`package.json inválido en ${manifest}: ${error.message}`)
81
- }
82
- }
83
- pkg.devDependencies = { ...pkg.devDependencies, '@ingeniomaps/cauce': version }
84
- F.atomicWriteJson(manifest, pkg)
85
- }
86
-
87
- // La inversa exacta de `declareEngine`: saca la clave que puso y nada más. El resto del manifiesto es
88
- // del repo anfitrión aunque hoy no tenga otra cosa —un `package.json` vacío puede ser lo que alguien
89
- // escribió para tener scripts— así que el archivo se borra sólo si es idéntico al que `declareEngine`
90
- // habría creado desde cero, sin dependencias, sin scripts y con su `version: 0.0.0`.
91
- //
92
- // Lo que no se toca nunca es `node_modules/` ni el lockfile: los escribe npm, pueden tener dependencias
93
- // del proyecto y borrarlos por nuestra cuenta destruye trabajo ajeno. Se nombran en la salida, que es
94
- // la mitad que faltaba: `destroy` decía «tu repositorio queda donde está» y dejaba un `package.json`
95
- // cuya única dependencia era Cauce. En un repo Rust eso es basura conspicua y nadie avisaba.
96
- function undeclareEngine(manifest) {
97
- if (!fs.existsSync(manifest)) return []
98
- let pkg
99
- try { pkg = JSON.parse(fs.readFileSync(manifest, 'utf8')) } catch { return [] }
100
- const dev = pkg.devDependencies || {}
101
- if (!('@ingeniomaps/cauce' in dev)) return []
102
- delete dev['@ingeniomaps/cauce']
103
- pkg.devDependencies = dev
104
- const ours = !Object.keys(dev).length && !Object.keys(pkg.dependencies || {}).length
105
- && !Object.keys(pkg.scripts || {}).length && pkg.private === true && pkg.version === '0.0.0'
106
- if (ours) {
107
- fs.rmSync(manifest, { force: true })
108
- return ['package.json (lo había creado init: sin dependencias ni scripts propios)']
109
- }
110
- if (!Object.keys(dev).length) delete pkg.devDependencies
111
- F.atomicWriteJson(manifest, pkg)
112
- return ['package.json: se quitó la dependencia del motor y el resto queda como estaba']
87
+ return preserved
113
88
  }
114
89
 
115
90
  // El andamiaje de una instancia, sin leer argv. `init` es la cáscara que traduce banderas a esto, y
116
91
  // el banco de evaluación lo llama directo: crear una instancia programáticamente no puede depender de
117
92
  // cómo venga escrita la línea de comandos.
118
93
  function scaffold(root, { name, mode, force = false, quiet = false }) {
119
- copyTemplate(path.join(PROJECT_ROOT, 'template'), root, {
94
+ const preserved = copyTemplate(path.join(PROJECT_ROOT, 'template'), root, {
120
95
  '{{PROJECT_NAME}}': name,
121
96
  '{{MODE}}': mode,
122
97
  '{{WORKSPACE_PATH}}': mode === 'embedded' ? '.' : '..',
@@ -124,12 +99,12 @@ function scaffold(root, { name, mode, force = false, quiet = false }) {
124
99
  // No se copia `.github/`: `ci.yml` valida el toolkit con `npm run ci` —que una instancia no tiene— y
125
100
  // el ciclo de aprendizaje dejó de distribuirse en 0.4.0. Copiar salteando lo que no aplica dejaba
126
101
  // `.github/workflows/` vacío en cada instancia.
127
- copyRuntime(
102
+ Object.assign(preserved, copyRuntime(
128
103
  path.join(PROJECT_ROOT, 'automatization', 'hooks'),
129
104
  path.join(root, 'automatization', 'hooks'),
130
105
  force,
131
106
  root,
132
- )
107
+ ))
133
108
  const version = require(path.join(PROJECT_ROOT, 'package.json')).version
134
109
  // El motor llega como dependencia para que el lockfile fije su versión. El repo ops es un sidecar:
135
110
  // declarar npm acá no convierte en Node al servicio de Go de al lado.
@@ -140,6 +115,14 @@ function scaffold(root, { name, mode, force = false, quiet = false }) {
140
115
  if (fs.existsSync(dir)) deliveredPaths = M.record(root, relative, O.treeFiles(dir), deliveredPaths)
141
116
  }
142
117
  deliveredPaths = M.recordPaths(root, O.SYSTEM_FILES, deliveredPaths)
118
+ // Lo conservado entra con el digest del molde, no con el del disco: la diferencia entre los dos es
119
+ // justamente lo que `localChanges` tiene que ver para que `upgrade` se detenga antes de pisar el
120
+ // archivo de la empresa. Sólo se pisan claves que ya están: el manifiesto declara lo que Cauce
121
+ // rastrea, y un archivo propio fuera de esa frontera no le incumbe.
122
+ for (const [file, hash] of Object.entries(preserved)) {
123
+ const relative = path.relative(root, file).split(path.sep).join('/')
124
+ if (relative in deliveredPaths) deliveredPaths[relative] = hash
125
+ }
143
126
  M.write(root, deliveredPaths)
144
127
  // La instancia recuerda de qué versión salió: sin esto no hay actualización posible.
145
128
  const configFile = path.join(root, 'ops.config.json')
@@ -305,12 +288,23 @@ function upgrade(dir, cli) {
305
288
  const overrides = O.overrides(root)
306
289
 
307
290
  if (dry) {
291
+ // Lo editado localmente se informa siempre, antes de mirar versiones: son dos preguntas distintas
292
+ // —«¿hay algo más nuevo?» y «¿qué tengo editado que se perdería?»— y la segunda tiene respuesta
293
+ // útil aunque la primera sea que no. Sin esto, el único modo que no toca nada era también el único
294
+ // que no podía avisar del conflicto, justo en el estado normal entre actualizaciones: `init` fija
295
+ // la versión exacta, así que instancia y motor coinciden casi todo el tiempo.
296
+ for (const file of changed) console.log(` editado localmente: ${file}`)
308
297
  if (from === to) {
309
298
  // Contra el motor instalado, no contra lo publicado: la comparación es local y sin red. Decirlo
310
299
  // importa porque `init` fija la versión exacta, así que el motor no se mueve solo y esta línea,
311
300
  // a secas, se leía como «no hay nada nuevo» durante todas las versiones siguientes.
312
301
  console.log(`= ${to}: la instancia está al día con el motor instalado`)
313
- return console.log(' para traer una versión más nueva: npm install --save-dev @ingeniomaps/cauce@latest')
302
+ // Con `--save-exact` porque npm guarda con caret por defecto, y el caret es lo que volvería
303
+ // falsa la línea de arriba: dentro de 0.x alcanza a los patches, y Cauce publica patches.
304
+ console.log(' para traer una versión más nueva:'
305
+ + ' npm install --save-exact --save-dev @ingeniomaps/cauce@latest')
306
+ if (changed.length) process.exit(1)
307
+ return
314
308
  }
315
309
  // Hacia atrás también es legítimo —una versión rompió algo y se vuelve—, pero anunciarlo como «hay
316
310
  // una versión más nueva» era mentir con el número a la vista. Y lo que corresponde imprimir es lo
@@ -322,7 +316,6 @@ function upgrade(dir, cli) {
322
316
  console.log(`⚠ hay una versión más nueva: ${to} (la instancia tiene ${from || 'una previa'})`)
323
317
  printChangelog(from, to)
324
318
  }
325
- for (const file of changed) console.log(` editado localmente: ${file}`)
326
319
  process.exit(1)
327
320
  }
328
321
 
@@ -416,18 +409,27 @@ function upgrade(dir, cli) {
416
409
  const config = JSON.parse(fs.readFileSync(path.join(root, 'ops.config.json'), 'utf8'))
417
410
  config.cauceVersion = to
418
411
  F.atomicWriteJson(path.join(root, 'ops.config.json'), config)
412
+ // Acá es donde las tres versiones se saben a la vez, así que es donde pueden quedar diciendo lo mismo.
413
+ const pinned = pinEngine(root, to)
419
414
 
420
415
  console.log(`✓ Cauce ${from || '(previa)'} → ${to}`)
421
416
  // Descartar con --force es legítimo; hacerlo sin dejar rastro no. Queda en la salida del comando,
422
417
  // que es la evidencia que el protocolo pide para cualquier cambio.
423
418
  for (const file of changed) console.log(`− descartado tu cambio en ${file}`)
424
419
  for (const relative of retired) console.log(`− retirado ${relative}: Cauce ya no lo distribuye`)
420
+ // Se dice porque cambia un archivo que la empresa versiona, y porque explica un diff que si no
421
+ // aparecería sin autor. Ilegible se avisa y no se toca: el manifiesto es del repo anfitrión.
422
+ if (pinned === 'ilegible') console.log(' ⚠ package.json no se pudo leer: su versión quedó como estaba')
423
+ else if (pinned) console.log(` package.json: ${pinned} → ${to}, la versión exacta que acabás de aplicar`)
425
424
  printChangelog(from, to)
426
425
  console.log(` ${system.length} ruta(s) del sistema y ${O.RUNTIME_PATHS.length} del runtime actualizadas`)
427
426
  for (const override of overrides) {
428
427
  console.log(`= conservado ${override.collection}/${override.project}: sobrescribe ${override.system}`)
429
428
  }
430
- console.log(' planning, organization y todo lo propio quedaron intactos')
429
+ // Sólo cuando es cierto. Llegar acá con algo en `changed` significa que se corrió con --force y se
430
+ // descartó contenido de la empresa: las líneas de arriba lo enumeran, y afirmar a continuación que
431
+ // todo lo propio quedó intacto contradice a la única señal que recibe quien corrió el comando.
432
+ if (!changed.length) console.log(' planning, organization y todo lo propio quedaron intactos')
431
433
  // No se borra: sin la dependencia declarada, quitarle `.ops/` la dejaría sin motor. Se avisa y
432
434
  // decide una persona.
433
435
  if (fs.existsSync(path.join(root, '.ops', 'engine'))) {
package/engine/cli/ops.js CHANGED
@@ -21,8 +21,14 @@ const DEFAULT_TARGET = 'ops'
21
21
  // de otra— y el proyecto quedaba llamándose «acme-ops». La carpeta que ya nombra al toolkit es la
22
22
  // instancia; no hay una segunda adentro.
23
23
  function implicitTarget(cwd) {
24
- const base = path.basename(cwd)
25
- return base === DEFAULT_TARGET || base.endsWith('-ops') ? '.' : DEFAULT_TARGET
24
+ return isInstanceDir(cwd) ? '.' : DEFAULT_TARGET
25
+ }
26
+
27
+ // La misma heurística, aplicada al destino ya resuelto: una carpeta que ya nombra al toolkit **es** la
28
+ // instancia, y el código de la empresa vive al lado.
29
+ function isInstanceDir(dir) {
30
+ const base = path.basename(dir)
31
+ return base === DEFAULT_TARGET || base.endsWith('-ops')
26
32
  }
27
33
 
28
34
  // El nombre sale de la carpeta del proyecto, no de la que aloja la instancia: `ops/` y `acme-ops/`
@@ -73,7 +79,12 @@ async function init(target, cli) {
73
79
  // install` ya asume —el wiring del runner va al padre, donde se abre la herramienta—, así que lo
74
80
  // único que faltaba era que fuera lo que pasa cuando no se elige nada.
75
81
  const root = path.resolve(target || implicitTarget(process.cwd()))
76
- const mode = cli.value('--mode', target ? 'embedded' : 'sidecar')
82
+ // El modo lo decide a dónde apunta el destino, no si alguien escribió el argumento. `init` e `init .`
83
+ // resuelven el mismo directorio y elegían modos opuestos: escribir el punto —la forma más natural de
84
+ // decir «acá»— daba embedded, y ahí la raíz del workspace pasa a ser la carpeta ops misma, así que
85
+ // `guard-workspace-boundary` deja de reconocer los repos hermanos y el wiring del runner se escribe
86
+ // adentro en vez de al lado, donde el dev abre su herramienta.
87
+ const mode = cli.value('--mode', isInstanceDir(root) ? 'sidecar' : 'embedded')
77
88
  if (!['embedded', 'sidecar'].includes(mode)) fail('--mode debe ser embedded o sidecar.', 2)
78
89
  const name = cli.value('--name', defaultName(root))
79
90
  const force = cli.has('--force')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingeniomaps/cauce",
3
- "version": "0.54.0",
3
+ "version": "0.56.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "keywords": [
6
6
  "planning",
package/template/Makefile CHANGED
@@ -29,7 +29,7 @@ destroy: ## Muestra qué se pierde al borrar esta instancia (borrar exige --forc
29
29
  @node tools/ops.js destroy .
30
30
 
31
31
  upgrade: ## Trae la última versión de Cauce y la aplica, sin tocar lo del proyecto
32
- @npm install --save-dev @ingeniomaps/cauce@latest
32
+ @npm install --save-exact --save-dev @ingeniomaps/cauce@latest
33
33
  @node tools/ops.js upgrade .
34
34
 
35
35
  automation-check: ## Comprueba hooks, workflows y adaptadores