@nucleoabierto/teleprompter 0.1.1
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/LICENSE +21 -0
- package/README.md +88 -0
- package/bin/teleprompter.js +13 -0
- package/docs/domains/001-paquete.md +73 -0
- package/docs/domains/002-instalacion.md +118 -0
- package/docs/domains/README.md +9 -0
- package/docs/especificacion-paquete.md +169 -0
- package/manual/001-instalar-un-paquete.md +69 -0
- package/manual/README.md +27 -0
- package/manual/guia-de-uso.md +90 -0
- package/manual/index.md +27 -0
- package/manual/referencia-install.md +120 -0
- package/package.json +28 -0
- package/src/cli.js +124 -0
- package/src/execute.js +74 -0
- package/src/hash.js +53 -0
- package/src/lock.js +74 -0
- package/src/manifest.js +205 -0
- package/src/paths.js +47 -0
- package/src/plan.js +54 -0
- package/src/prompt.js +16 -0
- package/src/requires.js +19 -0
- package/src/verify.js +17 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Guía de uso
|
|
2
|
+
|
|
3
|
+
Cómo instalar un paquete Teleprompter en un repositorio, paso a paso.
|
|
4
|
+
|
|
5
|
+
## Requisitos
|
|
6
|
+
|
|
7
|
+
- Node.js 22 o superior.
|
|
8
|
+
- Un paquete: un directorio con un manifiesto `teleprompter.json` en su
|
|
9
|
+
raíz. Este repositorio incluye uno de referencia en
|
|
10
|
+
`packages/ciclo-tareas/`.
|
|
11
|
+
- El directorio del repositorio destino donde se instalarán los
|
|
12
|
+
recursos.
|
|
13
|
+
|
|
14
|
+
## Instalar un paquete
|
|
15
|
+
|
|
16
|
+
Desde la raíz del repositorio destino —o dando su ruta como segundo
|
|
17
|
+
argumento—:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npx @nucleoabierto/teleprompter install /ruta/al/paquete .
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
El instalador trabaja en cuatro fases: verificación, plan, ejecución y
|
|
24
|
+
registro. Las dos primeras no escriben nada.
|
|
25
|
+
|
|
26
|
+
1. **Verificación.** Lee y valida el manifiesto del paquete y comprueba
|
|
27
|
+
sus precondiciones sobre el destino. Si algo falla, la operación
|
|
28
|
+
aborta antes de tocar el disco. Si todo va bien, verás:
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
verificado: ciclo-tareas@1.0.0
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
2. **Plan.** Se calculan todas las acciones antes de escribir y se
|
|
35
|
+
muestran recurso a recurso con una marca por línea (`create`,
|
|
36
|
+
`identical`, `conflict`, `managed-update`):
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
plan de instalación:
|
|
40
|
+
mkdir .agents/skills/
|
|
41
|
+
create .agents/skills/crear-tareas/
|
|
42
|
+
create .agents/skills/ejecutar-tareas/
|
|
43
|
+
create .agents/skills/commit/
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
3. **Ejecución.** Si el plan no tiene colisiones, se ejecuta tal como se
|
|
47
|
+
presentó. Si las hay, se resuelven antes de escribir: en una consola
|
|
48
|
+
interactiva el instalador pregunta por cada recurso en conflicto;
|
|
49
|
+
en una ejecución no interactiva la operación aborta. `--force` y
|
|
50
|
+
`--skip` resuelven todas las colisiones por adelantado (véase la
|
|
51
|
+
[referencia](referencia-install.md#colisiones)).
|
|
52
|
+
|
|
53
|
+
4. **Registro.** Terminada la ejecución se escribe
|
|
54
|
+
`teleprompter-lock.json` en la raíz del destino y se informa del
|
|
55
|
+
resultado:
|
|
56
|
+
|
|
57
|
+
```text
|
|
58
|
+
resultado:
|
|
59
|
+
mkdir .agents/skills/
|
|
60
|
+
create .agents/skills/crear-tareas/
|
|
61
|
+
create .agents/skills/ejecutar-tareas/
|
|
62
|
+
create .agents/skills/commit/
|
|
63
|
+
instalado: ciclo-tareas@1.0.0
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
El archivo de registro está pensado para versionarse con el
|
|
67
|
+
repositorio: es lo que permite a Teleprompter distinguir lo que él
|
|
68
|
+
instaló de lo que ya existía.
|
|
69
|
+
|
|
70
|
+
## Inspeccionar sin instalar
|
|
71
|
+
|
|
72
|
+
`--dry-run` ejecuta solo la verificación y el plan, y termina sin
|
|
73
|
+
escribir ni registrar nada:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
npx @nucleoabierto/teleprompter install /ruta/al/paquete . --dry-run
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Es la forma de ver qué haría una instalación —incluidas las colisiones
|
|
80
|
+
que habría que resolver— antes de decidir.
|
|
81
|
+
|
|
82
|
+
## Reinstalar
|
|
83
|
+
|
|
84
|
+
Reinstalar un paquete ya instalado es una operación válida e inocua:
|
|
85
|
+
los recursos que siguen idénticos se marcan `identical` y no se
|
|
86
|
+
escriben; los que difieren en contenido se actualizan
|
|
87
|
+
(`managed-update`) solo si el paquete ofrece una versión igual o
|
|
88
|
+
posterior a la registrada y el destino sigue conteniendo lo que la
|
|
89
|
+
instalación anterior escribió. Si un recurso instalado se modificó a
|
|
90
|
+
mano, se trata como colisión y pide resolución.
|
package/manual/index.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Teleprompter
|
|
2
|
+
|
|
3
|
+
Instalador de paquetes de configuración para repositorios.
|
|
4
|
+
|
|
5
|
+
Teleprompter lleva un paquete —un directorio con un manifiesto
|
|
6
|
+
`teleprompter.json` y sus recursos— a un repositorio con trabajo
|
|
7
|
+
previo: calcula el plan completo antes de escribir nada, resuelve las
|
|
8
|
+
colisiones con lo que ya existe y registra la instalación en
|
|
9
|
+
`teleprompter-lock.json`.
|
|
10
|
+
|
|
11
|
+
## Empieza aquí
|
|
12
|
+
|
|
13
|
+
- [Guía de uso](guia-de-uso.md) — instalar un paquete paso a paso, con
|
|
14
|
+
el paquete de referencia del repositorio como ejemplo.
|
|
15
|
+
- [Referencia de `install`](referencia-install.md) — la operación
|
|
16
|
+
completa: fases, marcas del plan, resolución de colisiones, registro
|
|
17
|
+
y códigos de salida.
|
|
18
|
+
- [Instalar un paquete](001-instalar-un-paquete.md) — la funcionalidad
|
|
19
|
+
y los escenarios que la suite de pruebas verifica.
|
|
20
|
+
|
|
21
|
+
## En el repositorio
|
|
22
|
+
|
|
23
|
+
- [Especificación del formato de paquete](https://github.com/nucleoabierto/teleprompter/blob/master/docs/especificacion-paquete.md)
|
|
24
|
+
— cómo escribir el manifiesto `teleprompter.json` de un paquete
|
|
25
|
+
propio.
|
|
26
|
+
- [Documentación de dominio](https://github.com/nucleoabierto/teleprompter/tree/master/docs/domains)
|
|
27
|
+
— el modelo del producto: qué es un paquete y qué es una instalación.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Referencia de `install`
|
|
2
|
+
|
|
3
|
+
```text
|
|
4
|
+
teleprompter install <paquete> <destino> [--force|--skip] [--dry-run]
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
Instala el paquete del directorio `<paquete>` en el repositorio cuya
|
|
8
|
+
raíz es `<destino>`. Ambos argumentos deben ser directorios existentes.
|
|
9
|
+
|
|
10
|
+
## Fases
|
|
11
|
+
|
|
12
|
+
La operación ejecuta cuatro fases en orden:
|
|
13
|
+
|
|
14
|
+
1. **Verificación.** Valida el manifiesto `teleprompter.json` contra la
|
|
15
|
+
[especificación](https://github.com/nucleoabierto/teleprompter/blob/master/docs/especificacion-paquete.md); un manifiesto
|
|
16
|
+
inválido aborta con código `1`. Después comprueba las
|
|
17
|
+
precondiciones `requires.paths` sobre el destino: cada ruta que no
|
|
18
|
+
existe y no declara `create: true` aborta con código `2`; las que
|
|
19
|
+
declaran `create: true` aparecen en el plan como acciones `mkdir`.
|
|
20
|
+
2. **Plan.** Calcula el conjunto completo de acciones y lo presenta
|
|
21
|
+
antes de escribir nada. Un plan no ejecutable —precondición
|
|
22
|
+
incumplida o colisión sin resolver— aborta la operación entera:
|
|
23
|
+
ni recursos ni registro.
|
|
24
|
+
3. **Ejecución.** Solo se ejecuta un plan válido, en el orden
|
|
25
|
+
presentado.
|
|
26
|
+
4. **Registro.** Escribe `teleprompter-lock.json` en la raíz del
|
|
27
|
+
destino. Es parte de la operación, no un efecto secundario.
|
|
28
|
+
|
|
29
|
+
## Marcas del plan
|
|
30
|
+
|
|
31
|
+
Una marca por línea, antes de cualquier escritura:
|
|
32
|
+
|
|
33
|
+
- `mkdir` — creación de una ruta de precondición con `create: true`.
|
|
34
|
+
- `create` — el destino no existe; se instalará el recurso.
|
|
35
|
+
- `identical` — el destino ya contiene exactamente el recurso del
|
|
36
|
+
paquete; no hay nada que escribir.
|
|
37
|
+
- `conflict` — el destino existe con contenido distinto y no consta
|
|
38
|
+
instalado por Teleprompter, o consta pero fue modificado localmente
|
|
39
|
+
desde entonces. Requiere resolución.
|
|
40
|
+
- `managed-update` — el destino existe con contenido distinto, pero el
|
|
41
|
+
registro lo reconoce como propio (su contenido coincide con el hash
|
|
42
|
+
anotado) y el paquete ofrece una versión igual o posterior a la
|
|
43
|
+
registrada. Se sobrescribe sin preguntar.
|
|
44
|
+
|
|
45
|
+
En el resultado final, `conflict` y `managed-update` aparecen como
|
|
46
|
+
`overwrite` cuando se escribieron, y `skip` cuando se omitieron.
|
|
47
|
+
|
|
48
|
+
## Colisiones
|
|
49
|
+
|
|
50
|
+
Una colisión es un `conflict` del plan. La primera respuesta es la
|
|
51
|
+
resolución interactiva: en una consola interactiva se pregunta por cada
|
|
52
|
+
recurso si se sobrescribe o se omite. En una invocación no interactiva
|
|
53
|
+
sin opciones, la operación lista los conflictos y aborta con código `2`
|
|
54
|
+
sin escribir nada.
|
|
55
|
+
|
|
56
|
+
Dos opciones excluyentes resuelven todas las colisiones por adelantado:
|
|
57
|
+
|
|
58
|
+
- `--force` — el recurso del paquete sobrescribe el destino en cada
|
|
59
|
+
colisión. Es destructivo y nunca es el comportamiento por defecto.
|
|
60
|
+
- `--skip` — el recurso en conflicto no se instala; el resto del plan
|
|
61
|
+
se ejecuta y la omisión queda en el registro como `skip`.
|
|
62
|
+
|
|
63
|
+
Declarar ambas es un error de invocación (código `4`). La resolución
|
|
64
|
+
decide por recurso completo: el contenido no se fusiona.
|
|
65
|
+
|
|
66
|
+
## `--dry-run`
|
|
67
|
+
|
|
68
|
+
Ejecuta las fases de verificación y plan —incluida, si procede, la
|
|
69
|
+
resolución de colisiones— y termina con código `0` sin escribir
|
|
70
|
+
recursos ni registro.
|
|
71
|
+
|
|
72
|
+
## El registro
|
|
73
|
+
|
|
74
|
+
Cada instalación escribe `teleprompter-lock.json` en la raíz del
|
|
75
|
+
destino: un JSON pensado para versionarse con el repositorio:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"packages": {
|
|
80
|
+
"ciclo-tareas": {
|
|
81
|
+
"version": "1.0.0",
|
|
82
|
+
"installedAt": "2026-09-28T10:00:00.000Z",
|
|
83
|
+
"files": [
|
|
84
|
+
{
|
|
85
|
+
"target": ".agents/skills/ejecutar-tareas/SKILL.md",
|
|
86
|
+
"action": "create",
|
|
87
|
+
"sha256": "…"
|
|
88
|
+
}
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`packages` se indexa por el `name` del manifiesto y cada entrada
|
|
96
|
+
contiene `version`, `installedAt` y `files`: una entrada por recurso
|
|
97
|
+
con su `target`, la acción realizada (`create`, `overwrite`, `skip`) y
|
|
98
|
+
el SHA-256 del contenido escrito —las entradas `skip` no llevan hash.
|
|
99
|
+
Los registros de otros paquetes instalados en el mismo destino se
|
|
100
|
+
conservan. Un registro ausente no bloquea la operación: se ignora en
|
|
101
|
+
silencio y se trata como si no hubiera instalaciones previas; un
|
|
102
|
+
registro existente pero ilegible o malformado se ignora igualmente, con
|
|
103
|
+
un aviso.
|
|
104
|
+
|
|
105
|
+
## Personalización
|
|
106
|
+
|
|
107
|
+
Si el manifiesto declara `personalization`, la salida final informa de
|
|
108
|
+
la ubicación de las instrucciones dentro del paquete. La instalación
|
|
109
|
+
las entrega y presenta; no decide su contenido ni las instala salvo que
|
|
110
|
+
también figuren en `install`.
|
|
111
|
+
|
|
112
|
+
## Códigos de salida
|
|
113
|
+
|
|
114
|
+
| Código | Significado |
|
|
115
|
+
|--------|-----------------------------------------------------------------|
|
|
116
|
+
| `0` | Éxito |
|
|
117
|
+
| `1` | Manifiesto inválido |
|
|
118
|
+
| `2` | Plan no ejecutable: precondiciones incumplidas o colisiones |
|
|
119
|
+
| `3` | Error de ejecución (informa de lo ya aplicado antes de fallar) |
|
|
120
|
+
| `4` | Error de invocación: argumentos ausentes o rutas que no existen |
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nucleoabierto/teleprompter",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Instalador de paquetes de configuración Teleprompter",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/nucleoabierto/teleprompter.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"bin": {
|
|
12
|
+
"teleprompter": "bin/teleprompter.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin/",
|
|
16
|
+
"src/",
|
|
17
|
+
"README.md",
|
|
18
|
+
"manual/",
|
|
19
|
+
"docs/domains/",
|
|
20
|
+
"docs/especificacion-paquete.md"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node --test --experimental-test-coverage --test-coverage-include='**/src/**' --test-coverage-lines=100 --test-coverage-functions=100 --test-coverage-branches=100"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { verifyPackage } from './verify.js';
|
|
3
|
+
import { readLock, writeLock } from './lock.js';
|
|
4
|
+
import { buildPlan } from './plan.js';
|
|
5
|
+
import { executePlan } from './execute.js';
|
|
6
|
+
|
|
7
|
+
export const EXIT_OK = 0;
|
|
8
|
+
export const EXIT_MANIFEST = 1;
|
|
9
|
+
export const EXIT_PLAN = 2;
|
|
10
|
+
export const EXIT_EXECUTION = 3;
|
|
11
|
+
export const EXIT_USAGE = 4;
|
|
12
|
+
|
|
13
|
+
const USAGE = 'uso: teleprompter install <paquete> <destino> [--force|--skip] [--dry-run]';
|
|
14
|
+
const KNOWN_FLAGS = new Set(['--force', '--skip', '--dry-run']);
|
|
15
|
+
|
|
16
|
+
function parseArgs(argv) {
|
|
17
|
+
const [command, ...rest] = argv;
|
|
18
|
+
const args = [];
|
|
19
|
+
const flags = new Set();
|
|
20
|
+
for (const arg of rest) {
|
|
21
|
+
if (arg.startsWith('--')) flags.add(arg);
|
|
22
|
+
else args.push(arg);
|
|
23
|
+
}
|
|
24
|
+
const unknown = [...flags].find((f) => !KNOWN_FLAGS.has(f));
|
|
25
|
+
const invalid = command !== 'install' || args.length !== 2
|
|
26
|
+
|| unknown !== undefined || (flags.has('--force') && flags.has('--skip'));
|
|
27
|
+
return invalid ? null : { args, flags };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function printPlan(plan, out) {
|
|
31
|
+
out('plan de instalación:');
|
|
32
|
+
for (const dir of plan.mkdirs) out(` ${'mkdir'.padEnd(15)}${dir}`);
|
|
33
|
+
for (const r of plan.resources) out(` ${r.status.padEnd(15)}${r.target}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Invocation layer only: parses arguments, delegates to src/ and maps
|
|
37
|
+
// the result to output and exit codes. Keeping it thin is what lets
|
|
38
|
+
// the test suite exercise the CLI without spawning processes.
|
|
39
|
+
// io: { out, err, interactive, createAsker } — injectable for tests.
|
|
40
|
+
export async function main(argv, io = {}) {
|
|
41
|
+
const out = io.out ?? console.log;
|
|
42
|
+
const err = io.err ?? console.error;
|
|
43
|
+
|
|
44
|
+
const parsed = parseArgs(argv);
|
|
45
|
+
if (parsed === null) {
|
|
46
|
+
err(USAGE);
|
|
47
|
+
return EXIT_USAGE;
|
|
48
|
+
}
|
|
49
|
+
const { args, flags } = parsed;
|
|
50
|
+
const [pkgDir, destDir] = args;
|
|
51
|
+
const isDir = (p) => fs.existsSync(p) && fs.statSync(p).isDirectory();
|
|
52
|
+
if (!isDir(pkgDir) || !isDir(destDir)) {
|
|
53
|
+
for (const arg of args) {
|
|
54
|
+
if (!isDir(arg)) err(`la ruta no es un directorio: ${arg}`);
|
|
55
|
+
}
|
|
56
|
+
return EXIT_USAGE;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const result = verifyPackage(pkgDir, destDir);
|
|
60
|
+
for (const warning of result.warnings) out(`aviso: ${warning}`);
|
|
61
|
+
if (result.kind === 'manifest') {
|
|
62
|
+
for (const error of result.errors) err(`manifiesto inválido: ${error}`);
|
|
63
|
+
return EXIT_MANIFEST;
|
|
64
|
+
}
|
|
65
|
+
if (result.kind === 'requires') {
|
|
66
|
+
for (const p of result.failures) {
|
|
67
|
+
err(`precondición incumplida: "${p}" no existe en el destino`);
|
|
68
|
+
}
|
|
69
|
+
return EXIT_PLAN;
|
|
70
|
+
}
|
|
71
|
+
out(`verificado: ${result.manifest.name}@${result.manifest.version}`);
|
|
72
|
+
|
|
73
|
+
const lock = readLock(destDir);
|
|
74
|
+
for (const warning of lock.warnings) out(`aviso: ${warning}`);
|
|
75
|
+
const plan = buildPlan(pkgDir, result.manifest, destDir, result.creates, lock);
|
|
76
|
+
printPlan(plan, out);
|
|
77
|
+
|
|
78
|
+
if (plan.conflicts.length > 0) {
|
|
79
|
+
if (flags.has('--force')) {
|
|
80
|
+
for (const r of plan.conflicts) r.resolution = 'overwrite';
|
|
81
|
+
} else if (flags.has('--skip')) {
|
|
82
|
+
for (const r of plan.conflicts) r.resolution = 'skip';
|
|
83
|
+
} else if (io.interactive && io.createAsker) {
|
|
84
|
+
const asker = io.createAsker();
|
|
85
|
+
try {
|
|
86
|
+
for (const r of plan.conflicts) {
|
|
87
|
+
const overwrite = await asker.ask(`colisión en ${r.target}: ¿sobrescribir? [s/N] `);
|
|
88
|
+
r.resolution = overwrite ? 'overwrite' : 'skip';
|
|
89
|
+
}
|
|
90
|
+
} finally {
|
|
91
|
+
asker.close();
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
for (const r of plan.conflicts) err(`conflicto sin resolver: ${r.target}`);
|
|
95
|
+
err('plan no ejecutable: colisiones sin resolver');
|
|
96
|
+
return EXIT_PLAN;
|
|
97
|
+
}
|
|
98
|
+
for (const r of plan.conflicts) out(` ${r.target} → ${r.resolution}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (flags.has('--dry-run')) {
|
|
102
|
+
out('fin del plan (--dry-run): nada se escribió');
|
|
103
|
+
return EXIT_OK;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let actions;
|
|
107
|
+
try {
|
|
108
|
+
actions = executePlan(pkgDir, destDir, plan);
|
|
109
|
+
writeLock(destDir, lock, result.manifest, actions);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
for (const a of error.applied ?? actions) {
|
|
112
|
+
out(` ${a.action.padEnd(15)}${a.target}`);
|
|
113
|
+
}
|
|
114
|
+
err(`error de ejecución: ${error.message}`);
|
|
115
|
+
return EXIT_EXECUTION;
|
|
116
|
+
}
|
|
117
|
+
out('resultado:');
|
|
118
|
+
for (const a of actions) out(` ${a.action.padEnd(15)}${a.target}`);
|
|
119
|
+
if (result.manifest.personalization) {
|
|
120
|
+
out(`personalización: instrucciones en "${result.manifest.personalization}" del paquete`);
|
|
121
|
+
}
|
|
122
|
+
out(`instalado: ${result.manifest.name}@${result.manifest.version}`);
|
|
123
|
+
return EXIT_OK;
|
|
124
|
+
}
|
package/src/execute.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { hashPath } from './hash.js';
|
|
4
|
+
import { resolvesUnder } from './paths.js';
|
|
5
|
+
|
|
6
|
+
const ACTION = {
|
|
7
|
+
create: 'create',
|
|
8
|
+
identical: 'identical',
|
|
9
|
+
'managed-update': 'overwrite',
|
|
10
|
+
conflict: null, // taken from the resource's resolution
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function copyResource(source, dest) {
|
|
14
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
15
|
+
const stat = fs.lstatSync(source);
|
|
16
|
+
if (stat.isSymbolicLink()) {
|
|
17
|
+
fs.symlinkSync(fs.readlinkSync(source), dest);
|
|
18
|
+
} else if (stat.isDirectory()) {
|
|
19
|
+
// verbatimSymlinks keeps links as links: dereferencing them would
|
|
20
|
+
// make the written copy hash differently from the package, and the
|
|
21
|
+
// next install would report a phantom conflict.
|
|
22
|
+
fs.cpSync(source, dest, { recursive: true, verbatimSymlinks: true });
|
|
23
|
+
} else {
|
|
24
|
+
fs.copyFileSync(source, dest);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Materializes a resolved plan: creates the precondition directories,
|
|
29
|
+
// then applies exactly the action each resource was assigned. Every
|
|
30
|
+
// write —including the rm that precedes an overwrite— first proves the
|
|
31
|
+
// parent chain resolves under the destination root, so a symlinked
|
|
32
|
+
// directory can never redirect a write outside it.
|
|
33
|
+
// If a copy throws midway, the error carries `applied` — the actions
|
|
34
|
+
// already performed — so the caller can report them.
|
|
35
|
+
export function executePlan(pkgDir, destDir, plan) {
|
|
36
|
+
const applied = [];
|
|
37
|
+
try {
|
|
38
|
+
for (const dir of plan.mkdirs) {
|
|
39
|
+
const dest = path.join(destDir, dir);
|
|
40
|
+
if (!resolvesUnder(destDir, dest)) {
|
|
41
|
+
throw new Error(`la ruta destino escapa de la raíz: ${dest}`);
|
|
42
|
+
}
|
|
43
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
44
|
+
applied.push({ target: dir, action: 'mkdir' });
|
|
45
|
+
}
|
|
46
|
+
for (const r of plan.resources) {
|
|
47
|
+
const action = ACTION[r.status] ?? r.resolution;
|
|
48
|
+
if (action === undefined) {
|
|
49
|
+
throw new Error(`plan sin resolver: ${r.target}`);
|
|
50
|
+
}
|
|
51
|
+
const dest = path.join(destDir, r.target);
|
|
52
|
+
if (action === 'create' || action === 'overwrite') {
|
|
53
|
+
if (!resolvesUnder(destDir, path.dirname(dest))) {
|
|
54
|
+
throw new Error(`la ruta destino escapa de la raíz: ${dest}`);
|
|
55
|
+
}
|
|
56
|
+
if (action === 'overwrite') {
|
|
57
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
// Pushed before the copy so a failed write still reports the
|
|
60
|
+
// destructive half it already ran.
|
|
61
|
+
const entry = { target: r.target, action };
|
|
62
|
+
applied.push(entry);
|
|
63
|
+
copyResource(path.join(pkgDir, r.source), dest);
|
|
64
|
+
entry.sha256 = hashPath(dest);
|
|
65
|
+
} else {
|
|
66
|
+
applied.push({ target: r.target, action });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
error.applied = applied;
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
return applied;
|
|
74
|
+
}
|
package/src/hash.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
// Each node kind hashes in its own domain ("file"/"link"/"dir"
|
|
6
|
+
// seeds) so two resources of different kinds can never collide.
|
|
7
|
+
function hashFile(file) {
|
|
8
|
+
return crypto.createHash('sha256')
|
|
9
|
+
.update('file\n')
|
|
10
|
+
.update(fs.readFileSync(file))
|
|
11
|
+
.digest('hex');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function collect(root, rel, entries) {
|
|
15
|
+
for (const name of fs.readdirSync(path.join(root, rel))) {
|
|
16
|
+
const childRel = rel ? `${rel}/${name}` : name;
|
|
17
|
+
const child = path.join(root, childRel);
|
|
18
|
+
const stat = fs.lstatSync(child);
|
|
19
|
+
if (stat.isSymbolicLink()) {
|
|
20
|
+
entries.push(`${childRel} -> ${fs.readlinkSync(child)}`);
|
|
21
|
+
} else if (stat.isDirectory()) {
|
|
22
|
+
// Directory markers keep empty subtrees significant: two trees
|
|
23
|
+
// that differ only in an empty directory must not hash equal.
|
|
24
|
+
entries.push(`${childRel}/`);
|
|
25
|
+
collect(root, childRel, entries);
|
|
26
|
+
} else {
|
|
27
|
+
entries.push(`${childRel}:${hashFile(child)}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Hashes a file, symlink, or directory tree so resources can be
|
|
33
|
+
// compared regardless of shape. lstat is deliberate: following links
|
|
34
|
+
// would crash on dangling targets and could recurse forever through
|
|
35
|
+
// a link back to a parent. The "dir" seed keeps an empty directory
|
|
36
|
+
// from hashing equal to an empty file, and sorting the entry list
|
|
37
|
+
// keeps the digest independent of traversal order.
|
|
38
|
+
export function hashPath(target) {
|
|
39
|
+
const stat = fs.lstatSync(target);
|
|
40
|
+
if (stat.isSymbolicLink()) {
|
|
41
|
+
return crypto.createHash('sha256')
|
|
42
|
+
.update(`link\n${fs.readlinkSync(target)}`)
|
|
43
|
+
.digest('hex');
|
|
44
|
+
}
|
|
45
|
+
if (stat.isDirectory()) {
|
|
46
|
+
const entries = [];
|
|
47
|
+
collect(target, '', entries);
|
|
48
|
+
const hash = crypto.createHash('sha256').update('dir\n');
|
|
49
|
+
for (const entry of entries.sort()) hash.update(`${entry}\n`);
|
|
50
|
+
return hash.digest('hex');
|
|
51
|
+
}
|
|
52
|
+
return hashFile(target);
|
|
53
|
+
}
|
package/src/lock.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const CORRUPT_WARNING = 'teleprompter-lock.json ilegible o corrupto: se ignora';
|
|
5
|
+
const EMPTY = () => ({ packages: {}, warnings: [] });
|
|
6
|
+
|
|
7
|
+
// A missing or corrupt lock means "no recorded history": the install
|
|
8
|
+
// proceeds as if nothing was ever installed. Corruption surfaces as a
|
|
9
|
+
// warning because collision detection degrades to treating everything
|
|
10
|
+
// as foreign.
|
|
11
|
+
export function readLock(destDir) {
|
|
12
|
+
const lockPath = path.join(destDir, 'teleprompter-lock.json');
|
|
13
|
+
let data;
|
|
14
|
+
try {
|
|
15
|
+
data = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (error.code === 'ENOENT') return EMPTY();
|
|
18
|
+
return { packages: {}, warnings: [CORRUPT_WARNING] };
|
|
19
|
+
}
|
|
20
|
+
if (!isValidLock(data)) return { packages: {}, warnings: [CORRUPT_WARNING] };
|
|
21
|
+
return { packages: data.packages ?? {}, warnings: [] };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A lock is only trustworthy if every package entry holds a files
|
|
25
|
+
// array of {target, sha256?} records — anything else is corrupt even
|
|
26
|
+
// when it parses as JSON.
|
|
27
|
+
function isValidLock(data) {
|
|
28
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) return false;
|
|
29
|
+
if (data.packages === undefined) return true;
|
|
30
|
+
const { packages } = data;
|
|
31
|
+
if (typeof packages !== 'object' || packages === null || Array.isArray(packages)) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return Object.values(packages).every((p) => p !== null && typeof p === 'object'
|
|
35
|
+
&& typeof p.version === 'string'
|
|
36
|
+
&& Array.isArray(p.files)
|
|
37
|
+
&& p.files.every((f) => f !== null && typeof f === 'object'
|
|
38
|
+
&& typeof f.target === 'string'
|
|
39
|
+
&& (f.sha256 === undefined || typeof f.sha256 === 'string')));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Merges the new install into the existing history: other packages'
|
|
43
|
+
// records survive untouched, while this package's entry is replaced
|
|
44
|
+
// wholesale because it describes the installation just performed.
|
|
45
|
+
// `identical` keeps any previous record — the content is still ours
|
|
46
|
+
// and the recorded hash still matches — but creates none for a
|
|
47
|
+
// resource we never wrote. `mkdir` actions are plan bookkeeping, not
|
|
48
|
+
// installed files.
|
|
49
|
+
export function writeLock(destDir, lock, manifest, actions) {
|
|
50
|
+
const previous = new Map(
|
|
51
|
+
(lock.packages[manifest.name]?.files ?? []).map((f) => [f.target, f]),
|
|
52
|
+
);
|
|
53
|
+
const files = actions.flatMap(({ target, action, sha256 }) => {
|
|
54
|
+
if (action === 'identical' || action === 'mkdir') {
|
|
55
|
+
const prev = previous.get(target);
|
|
56
|
+
return prev === undefined ? [] : [prev];
|
|
57
|
+
}
|
|
58
|
+
return [sha256 === undefined ? { target, action } : { target, action, sha256 }];
|
|
59
|
+
});
|
|
60
|
+
const data = {
|
|
61
|
+
packages: {
|
|
62
|
+
...lock.packages,
|
|
63
|
+
[manifest.name]: {
|
|
64
|
+
version: manifest.version,
|
|
65
|
+
installedAt: new Date().toISOString(),
|
|
66
|
+
files,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
fs.writeFileSync(
|
|
71
|
+
path.join(destDir, 'teleprompter-lock.json'),
|
|
72
|
+
`${JSON.stringify(data, null, 2)}\n`,
|
|
73
|
+
);
|
|
74
|
+
}
|