@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.
@@ -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.
@@ -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
+ }