@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,205 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { isSafeRelative, hasEntry } from './paths.js';
4
+
5
+ const KNOWN_FORMAT = 'teleprompter-package@1';
6
+ const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
7
+ const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
8
+
9
+ const TOP_LEVEL_FIELDS = new Set([
10
+ 'format', 'name', 'version', 'description', 'license', 'author',
11
+ 'collection', 'packages', 'install', 'requires', 'personalization',
12
+ 'metadata',
13
+ ]);
14
+
15
+ const OBJECT_KEYS = {
16
+ author: new Set(['name', 'email', 'url']),
17
+ requires: new Set(['paths']),
18
+ requiresPath: new Set(['path', 'create']),
19
+ installEntry: new Set(['source', 'target']),
20
+ };
21
+
22
+ const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
23
+ const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0;
24
+
25
+ function checkKeys(obj, allowed, where, errors) {
26
+ for (const key of Object.keys(obj)) {
27
+ if (!allowed.has(key)) errors.push(`${where}: campo desconocido "${key}"`);
28
+ }
29
+ }
30
+
31
+ // Returns whether the value survives validation so callers can gate
32
+ // downstream checks (e.g. existence) on a well-formed path.
33
+ function checkRelativePath(value, label, errors) {
34
+ if (!isNonEmptyString(value)) {
35
+ errors.push(`${label}: debe ser una cadena no vacía`);
36
+ return false;
37
+ }
38
+ if (!isSafeRelative(value)) {
39
+ errors.push(`${label}: ruta absoluta o con ".." no permitida`);
40
+ return false;
41
+ }
42
+ return true;
43
+ }
44
+
45
+ function checkInstallEntry(entry, index, pkgDir, errors) {
46
+ const where = `install[${index}]`;
47
+ if (!isPlainObject(entry)) {
48
+ errors.push(`${where}: debe ser un objeto { "source", "target" }`);
49
+ return;
50
+ }
51
+ checkKeys(entry, OBJECT_KEYS.installEntry, where, errors);
52
+ const sourceOk = checkRelativePath(entry.source, `${where}.source`, errors);
53
+ checkRelativePath(entry.target, `${where}.target`, errors);
54
+ if (sourceOk && !hasEntry(path.join(pkgDir, entry.source))) {
55
+ errors.push(`${where}.source: no existe "${entry.source}" dentro del paquete`);
56
+ }
57
+ }
58
+
59
+ function checkRequiresEntry(entry, index, errors) {
60
+ const where = `requires.paths[${index}]`;
61
+ if (!isPlainObject(entry)) {
62
+ errors.push(`${where}: debe ser un objeto { "path", "create"? }`);
63
+ return;
64
+ }
65
+ checkKeys(entry, OBJECT_KEYS.requiresPath, where, errors);
66
+ checkRelativePath(entry.path, `${where}.path`, errors);
67
+ if (entry.create !== undefined && typeof entry.create !== 'boolean') {
68
+ errors.push(`${where}.create: debe ser booleano`);
69
+ }
70
+ }
71
+
72
+ // Each validator owns one field of the manifest. They receive the
73
+ // parsed object, the package directory and the error sink, so adding
74
+ // a field means adding one entry to VALIDATORS, not touching control
75
+ // flow.
76
+ const VALIDATORS = [
77
+ function checkCollection(manifest, _ctx, errors) {
78
+ if (manifest.collection === true) {
79
+ errors.push('el manifiesto describe una colección; las colecciones no son instalables');
80
+ } else if (manifest.collection !== undefined && manifest.collection !== false) {
81
+ errors.push('collection: debe ser booleano');
82
+ }
83
+ },
84
+ function checkFormat(manifest, _ctx, errors) {
85
+ if (manifest.format !== undefined && manifest.format !== KNOWN_FORMAT) {
86
+ errors.push(`format: desconocido "${manifest.format}" (esperado "${KNOWN_FORMAT}")`);
87
+ }
88
+ },
89
+ function checkName(manifest, { pkgDir }, errors) {
90
+ if (!isNonEmptyString(manifest.name)) {
91
+ errors.push('name: obligatorio, cadena no vacía');
92
+ return;
93
+ }
94
+ if (manifest.name.length > 64 || !NAME_RE.test(manifest.name)) {
95
+ errors.push('name: debe ser kebab-case (minúsculas, números y guiones, máx. 64)');
96
+ }
97
+ if (manifest.name !== path.basename(path.resolve(pkgDir))) {
98
+ errors.push(`name: "${manifest.name}" no coincide con el nombre del directorio`);
99
+ }
100
+ },
101
+ function checkVersion(manifest, _ctx, errors) {
102
+ if (!isNonEmptyString(manifest.version) || !SEMVER_RE.test(manifest.version)) {
103
+ errors.push('version: obligatoria, semver explícita x.y.z');
104
+ }
105
+ },
106
+ function checkInstall(manifest, { pkgDir }, errors) {
107
+ if (!Array.isArray(manifest.install) || manifest.install.length === 0) {
108
+ errors.push('install: obligatorio, lista no vacía de { "source", "target" }');
109
+ return;
110
+ }
111
+ manifest.install.forEach((entry, index) => checkInstallEntry(entry, index, pkgDir, errors));
112
+ // Target collisions make the plan ambiguous: two entries writing
113
+ // the same path, one nested under the other, or the lock file
114
+ // itself would let the second silently overwrite the first.
115
+ const targets = manifest.install
116
+ .map((e) => (typeof e?.target === 'string'
117
+ ? path.normalize(e.target).replace(/\/+$/, '')
118
+ : null))
119
+ .filter((t) => t !== null);
120
+ const seen = new Set();
121
+ for (const t of targets) {
122
+ if (t === 'teleprompter-lock.json') {
123
+ errors.push('install: "teleprompter-lock.json" es un target reservado');
124
+ } else if (seen.has(t)) {
125
+ errors.push(`install: target duplicado "${t}"`);
126
+ } else if (seen.has([...seen].find((o) => t.startsWith(`${o}/`)))) {
127
+ errors.push(`install: target "${t}" queda dentro de otro target`);
128
+ }
129
+ seen.add(t);
130
+ }
131
+ },
132
+ function checkRequires(manifest, _ctx, errors) {
133
+ if (manifest.requires === undefined) return;
134
+ if (!isPlainObject(manifest.requires)) {
135
+ errors.push('requires: debe ser un objeto');
136
+ return;
137
+ }
138
+ checkKeys(manifest.requires, OBJECT_KEYS.requires, 'requires', errors);
139
+ if (manifest.requires.paths === undefined) return;
140
+ if (!Array.isArray(manifest.requires.paths)) {
141
+ errors.push('requires.paths: debe ser una lista');
142
+ return;
143
+ }
144
+ manifest.requires.paths.forEach((entry, index) => checkRequiresEntry(entry, index, errors));
145
+ },
146
+ function checkAuthor(manifest, _ctx, errors) {
147
+ if (manifest.author === undefined) return;
148
+ if (!isPlainObject(manifest.author)) {
149
+ errors.push('author: debe ser un objeto { "name", "email"?, "url"? }');
150
+ return;
151
+ }
152
+ checkKeys(manifest.author, OBJECT_KEYS.author, 'author', errors);
153
+ if (!isNonEmptyString(manifest.author.name)) {
154
+ errors.push('author.name: obligatorio dentro de author');
155
+ }
156
+ },
157
+ function checkPersonalization(manifest, _ctx, errors) {
158
+ if (manifest.personalization === undefined) return;
159
+ checkRelativePath(manifest.personalization, 'personalization', errors);
160
+ },
161
+ function checkMetadata(manifest, _ctx, errors) {
162
+ if (manifest.metadata !== undefined && !isPlainObject(manifest.metadata)) {
163
+ errors.push('metadata: debe ser un objeto');
164
+ }
165
+ },
166
+ ...['description', 'license'].map((field) => (manifest, _ctx, errors) => {
167
+ if (manifest[field] !== undefined && typeof manifest[field] !== 'string') {
168
+ errors.push(`${field}: debe ser una cadena`);
169
+ }
170
+ }),
171
+ ];
172
+
173
+ // Never throws: every problem lands in errors so the CLI can report
174
+ // them all in one pass instead of failing on the first one.
175
+ export function loadManifest(pkgDir) {
176
+ const warnings = [];
177
+ const errors = [];
178
+ const manifestPath = path.join(pkgDir, 'teleprompter.json');
179
+
180
+ let raw;
181
+ try {
182
+ raw = fs.readFileSync(manifestPath, 'utf8');
183
+ } catch {
184
+ return { manifest: null, warnings, errors: [`no existe ${manifestPath}`] };
185
+ }
186
+
187
+ let manifest;
188
+ try {
189
+ manifest = JSON.parse(raw);
190
+ } catch (error) {
191
+ return { manifest: null, warnings, errors: [`JSON inválido en ${manifestPath}: ${error.message}`] };
192
+ }
193
+ if (!isPlainObject(manifest)) {
194
+ return { manifest: null, warnings, errors: ['el manifiesto debe ser un objeto JSON'] };
195
+ }
196
+
197
+ for (const key of Object.keys(manifest)) {
198
+ if (!TOP_LEVEL_FIELDS.has(key)) {
199
+ warnings.push(`campo desconocido ignorado: "${key}"`);
200
+ }
201
+ }
202
+ for (const validate of VALIDATORS) validate(manifest, { pkgDir }, errors);
203
+
204
+ return { manifest: errors.length === 0 ? manifest : null, warnings, errors };
205
+ }
package/src/paths.js ADDED
@@ -0,0 +1,47 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // "Exists" means a directory entry is present, whatever it points
5
+ // at: existsSync follows links, so a dangling symlink would report
6
+ // the path as free and a later write could escape through it.
7
+ export function hasEntry(p) {
8
+ try {
9
+ fs.lstatSync(p);
10
+ return true;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+
16
+ // Rejects anything that could escape the package or the destination
17
+ // root: absolute paths and ".." segments, plus Windows-style absolute
18
+ // ("C:\...") and UNC ("\\...") paths, which path.isAbsolute does not
19
+ // detect on POSIX.
20
+ export function isSafeRelative(p) {
21
+ if (path.isAbsolute(p)) return false;
22
+ if (/^[a-zA-Z]:[\\/]/.test(p) || p.startsWith('\\\\')) return false;
23
+ return !p.split('/').concat(p.split('\\')).includes('..');
24
+ }
25
+
26
+ // A relative path can still escape the root through a symlink in its
27
+ // parent chain: existsSync-style checks follow links, so the deepest
28
+ // existing ancestor must resolve — after dereferencing — to a
29
+ // directory inside the root. Anything else (dangling ancestor, an
30
+ // ancestor that is a file, a link pointing outside) means the write
31
+ // would not land where the plan claims.
32
+ export function resolvesUnder(root, dir) {
33
+ let probe = dir;
34
+ while (!hasEntry(probe)) {
35
+ const parent = path.dirname(probe);
36
+ if (parent === probe) return false;
37
+ probe = parent;
38
+ }
39
+ try {
40
+ const real = fs.realpathSync(probe);
41
+ if (!fs.statSync(real).isDirectory()) return false;
42
+ const rootReal = fs.realpathSync(root);
43
+ return real === rootReal || real.startsWith(`${rootReal}${path.sep}`);
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
package/src/plan.js ADDED
@@ -0,0 +1,54 @@
1
+ import path from 'node:path';
2
+ import { hashPath } from './hash.js';
3
+ import { hasEntry, resolvesUnder } from './paths.js';
4
+
5
+ // Classifies each install entry by comparing the destination with the
6
+ // package resource and the recorded history:
7
+ // create - the destination does not exist
8
+ // identical - same content, nothing to do
9
+ // managed-update - the destination still holds what a previous
10
+ // install wrote and the package offers an equal or
11
+ // later version, so overwriting it is safe
12
+ // conflict - different content not owned by the tool
13
+ export function buildPlan(pkgDir, manifest, destDir, creates, lock) {
14
+ const record = lock.packages[manifest.name];
15
+ const recorded = new Map((record?.files ?? []).map((f) => [f.target, f.sha256]));
16
+ const updatesAllowed = semverAtLeast(manifest.version, record?.version);
17
+ const resources = manifest.install.map(({ source, target }) => {
18
+ const dest = path.join(destDir, target);
19
+ if (!hasEntry(dest)) {
20
+ // A write whose parent chain escapes the root cannot be a
21
+ // create: the path may be free here but not where it lands.
22
+ const status = resolvesUnder(destDir, path.dirname(dest)) ? 'create' : 'conflict';
23
+ return { source, target, status };
24
+ }
25
+ const destHash = hashPath(dest);
26
+ const status = destHash === hashPath(path.join(pkgDir, source)) ? 'identical'
27
+ : updatesAllowed && recorded.get(target) === destHash ? 'managed-update'
28
+ : 'conflict';
29
+ if (status !== 'identical' && status !== 'conflict'
30
+ && !resolvesUnder(destDir, path.dirname(dest))) {
31
+ return { source, target, status: 'conflict' };
32
+ }
33
+ return { source, target, status };
34
+ });
35
+ return {
36
+ mkdirs: creates,
37
+ resources,
38
+ conflicts: resources.filter((r) => r.status === 'conflict'),
39
+ };
40
+ }
41
+
42
+ // Downgrades are not managed updates: writing older content over a
43
+ // newer recorded install must surface as a conflict, not silently
44
+ // pass as safe.
45
+ function semverAtLeast(version, recordedVersion) {
46
+ const parse = (v) => /^(\d+)\.(\d+)\.(\d+)$/.exec(v ?? '')?.slice(1).map(Number);
47
+ const a = parse(version);
48
+ const b = parse(recordedVersion);
49
+ if (!a || !b) return false;
50
+ for (let i = 0; i < 3; i++) {
51
+ if (a[i] !== b[i]) return a[i] > b[i];
52
+ }
53
+ return true;
54
+ }
package/src/prompt.js ADDED
@@ -0,0 +1,16 @@
1
+ import readline from 'node:readline/promises';
2
+
3
+ // readline wrapper kept apart from the CLI so conflict resolution is
4
+ // testable with fake streams. Answers accept the usual affirmative
5
+ // spellings; anything else means skip, because the safe default for
6
+ // overwriting someone else's work must be "no".
7
+ export function createAsker(input = process.stdin, output = process.stdout) {
8
+ const rl = readline.createInterface({ input, output });
9
+ return {
10
+ ask: async (question) => {
11
+ const answer = await rl.question(question);
12
+ return ['s', 'si', 'sí', 'y', 'yes'].includes(answer.trim().toLowerCase());
13
+ },
14
+ close: () => rl.close(),
15
+ };
16
+ }
@@ -0,0 +1,19 @@
1
+ import path from 'node:path';
2
+ import { hasEntry, resolvesUnder } from './paths.js';
3
+
4
+ // Splits missing precondition paths into hard failures and deferred
5
+ // creations: `create: true` entries become plan actions rather than
6
+ // aborting the operation — unless their parent chain escapes the
7
+ // destination root through a link, which makes the precondition
8
+ // unmeetable rather than deferrable.
9
+ export function checkRequires(manifest, destDir) {
10
+ const failures = [];
11
+ const creates = [];
12
+ for (const entry of manifest.requires?.paths ?? []) {
13
+ const p = path.join(destDir, entry.path);
14
+ if (hasEntry(p)) continue;
15
+ if (entry.create === true && resolvesUnder(destDir, p)) creates.push(entry.path);
16
+ else failures.push(entry.path);
17
+ }
18
+ return { failures, creates };
19
+ }
package/src/verify.js ADDED
@@ -0,0 +1,17 @@
1
+ import { loadManifest } from './manifest.js';
2
+ import { checkRequires } from './requires.js';
3
+
4
+ // Returns a structured result so the plan phase can consume the
5
+ // verification outcome without revalidating the manifest.
6
+ // kind: 'ok' | 'manifest' | 'requires'.
7
+ export function verifyPackage(pkgDir, destDir) {
8
+ const { manifest, warnings, errors } = loadManifest(pkgDir);
9
+ if (errors.length > 0) {
10
+ return { kind: 'manifest', warnings, errors, manifest: null, creates: [], failures: [] };
11
+ }
12
+ const { failures, creates } = checkRequires(manifest, destDir);
13
+ if (failures.length > 0) {
14
+ return { kind: 'requires', warnings, errors: [], manifest, creates, failures };
15
+ }
16
+ return { kind: 'ok', warnings, errors: [], manifest, creates, failures: [] };
17
+ }