@nucleoabierto/teleprompter 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -7
- package/bin/teleprompter.js +2 -0
- package/docs/domains/001-paquete.md +12 -1
- package/docs/domains/002-instalacion.md +134 -13
- package/docs/domains/README.md +1 -1
- package/docs/especificacion-paquete.md +12 -5
- package/manual/001-instalar-un-paquete.md +50 -4
- package/manual/002-listar-paquetes-instalados.md +44 -0
- package/manual/003-verificar-recursos-instalados.md +56 -0
- package/manual/004-actualizar-un-paquete.md +34 -0
- package/manual/README.md +15 -0
- package/manual/guia-de-uso.md +84 -6
- package/manual/index.md +16 -0
- package/manual/referencia-check.md +47 -0
- package/manual/referencia-guide.md +33 -0
- package/manual/referencia-install.md +45 -9
- package/manual/referencia-list.md +42 -0
- package/manual/referencia-update.md +49 -0
- package/package.json +4 -1
- package/src/cli.js +473 -66
- package/src/drift.js +28 -0
- package/src/execute.js +53 -8
- package/src/fetch.js +85 -0
- package/src/lock.js +63 -9
- package/src/manifest.js +32 -5
- package/src/paths.js +41 -0
- package/src/plan.js +118 -4
package/src/execute.js
CHANGED
|
@@ -1,15 +1,33 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { hashPath } from './hash.js';
|
|
4
|
-
import { resolvesUnder } from './paths.js';
|
|
4
|
+
import { resolvesUnder, personalizationTarget } from './paths.js';
|
|
5
|
+
|
|
6
|
+
// A failed execution reports what it already did: the thrown error
|
|
7
|
+
// is a declared type carrying the applied actions and the original
|
|
8
|
+
// error as `cause`, instead of mutating whatever the try happened
|
|
9
|
+
// to catch.
|
|
10
|
+
export class ExecutionError extends Error {
|
|
11
|
+
constructor(applied, cause) {
|
|
12
|
+
super(cause.message, { cause });
|
|
13
|
+
this.name = 'ExecutionError';
|
|
14
|
+
this.applied = applied;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
5
17
|
|
|
6
18
|
const ACTION = {
|
|
7
19
|
create: 'create',
|
|
8
20
|
identical: 'identical',
|
|
9
21
|
'managed-update': 'overwrite',
|
|
22
|
+
update: 'overwrite',
|
|
23
|
+
retire: 'remove',
|
|
10
24
|
conflict: null, // taken from the resource's resolution
|
|
11
25
|
};
|
|
12
26
|
|
|
27
|
+
// A conflict marked `removal` resolves to remove or keep instead of
|
|
28
|
+
// overwrite or skip — there is no incoming resource to write.
|
|
29
|
+
const REMOVAL_RESOLUTION = { overwrite: 'remove', skip: 'keep' };
|
|
30
|
+
|
|
13
31
|
function copyResource(source, dest) {
|
|
14
32
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
15
33
|
const stat = fs.lstatSync(source);
|
|
@@ -30,8 +48,8 @@ function copyResource(source, dest) {
|
|
|
30
48
|
// write —including the rm that precedes an overwrite— first proves the
|
|
31
49
|
// parent chain resolves under the destination root, so a symlinked
|
|
32
50
|
// directory can never redirect a write outside it.
|
|
33
|
-
// If
|
|
34
|
-
// already performed — so the caller can report them.
|
|
51
|
+
// If anything throws midway, the ExecutionError carries `applied` —
|
|
52
|
+
// the actions already performed — so the caller can report them.
|
|
35
53
|
export function executePlan(pkgDir, destDir, plan) {
|
|
36
54
|
const applied = [];
|
|
37
55
|
try {
|
|
@@ -43,13 +61,21 @@ export function executePlan(pkgDir, destDir, plan) {
|
|
|
43
61
|
fs.mkdirSync(dest, { recursive: true });
|
|
44
62
|
applied.push({ target: dir, action: 'mkdir' });
|
|
45
63
|
}
|
|
46
|
-
for (const r of plan.resources) {
|
|
47
|
-
const action =
|
|
64
|
+
for (const r of [...plan.resources, ...(plan.retired ?? [])]) {
|
|
65
|
+
const action = r.removal === true
|
|
66
|
+
? REMOVAL_RESOLUTION[r.resolution]
|
|
67
|
+
: ACTION[r.status] ?? r.resolution;
|
|
48
68
|
if (action === undefined) {
|
|
49
69
|
throw new Error(`plan sin resolver: ${r.target}`);
|
|
50
70
|
}
|
|
51
71
|
const dest = path.join(destDir, r.target);
|
|
52
|
-
if (action === '
|
|
72
|
+
if (action === 'remove') {
|
|
73
|
+
if (!resolvesUnder(destDir, path.dirname(dest))) {
|
|
74
|
+
throw new Error(`la ruta destino escapa de la raíz: ${dest}`);
|
|
75
|
+
}
|
|
76
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
77
|
+
applied.push({ target: r.target, action });
|
|
78
|
+
} else if (action === 'create' || action === 'overwrite') {
|
|
53
79
|
if (!resolvesUnder(destDir, path.dirname(dest))) {
|
|
54
80
|
throw new Error(`la ruta destino escapa de la raíz: ${dest}`);
|
|
55
81
|
}
|
|
@@ -67,8 +93,27 @@ export function executePlan(pkgDir, destDir, plan) {
|
|
|
67
93
|
}
|
|
68
94
|
}
|
|
69
95
|
} catch (error) {
|
|
70
|
-
|
|
71
|
-
throw error;
|
|
96
|
+
throw new ExecutionError(applied, error);
|
|
72
97
|
}
|
|
73
98
|
return applied;
|
|
74
99
|
}
|
|
100
|
+
|
|
101
|
+
// Materializes the declared personalization guide into the managed
|
|
102
|
+
// namespace — not through the plan: the field already names the file,
|
|
103
|
+
// and .teleprompter/ belongs to the tool, so the write is
|
|
104
|
+
// unconditional apart from the same symlink-escape guard every write
|
|
105
|
+
// gets. Returns the target it wrote, or null when the manifest
|
|
106
|
+
// declares no guide.
|
|
107
|
+
export function installPersonalization(pkgDir, destDir, manifest) {
|
|
108
|
+
if (!manifest.personalization) return null;
|
|
109
|
+
const target = personalizationTarget(manifest.name, manifest.personalization);
|
|
110
|
+
const dest = path.join(destDir, target);
|
|
111
|
+
if (!resolvesUnder(destDir, path.dirname(dest))) {
|
|
112
|
+
throw new Error(`la ruta destino escapa de la raíz: ${dest}`);
|
|
113
|
+
}
|
|
114
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
115
|
+
// rm first: copyFileSync would write through a symlink destination.
|
|
116
|
+
fs.rmSync(dest, { force: true, recursive: true });
|
|
117
|
+
fs.copyFileSync(path.join(pkgDir, manifest.personalization), dest);
|
|
118
|
+
return { target };
|
|
119
|
+
}
|
package/src/fetch.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { extract } from 'tar';
|
|
5
|
+
|
|
6
|
+
// A ref must be a plausible git ref: no whitespace, URL delimiters or
|
|
7
|
+
// ".." (git itself forbids it); slashes are fine — tags like
|
|
8
|
+
// "cli/v2.0.0" exist.
|
|
9
|
+
export const isValidRef = (ref) =>
|
|
10
|
+
ref.length > 0 && !/[\s?#%]/.test(ref) && !ref.includes('..');
|
|
11
|
+
|
|
12
|
+
// Parses "owner/repo[@ref]". Owner and repo follow GitHub naming:
|
|
13
|
+
// alphanumerics, hyphen, underscore and dots, single slash — and
|
|
14
|
+
// neither may be a dot segment, which would confuse the extraction
|
|
15
|
+
// path. The spec splits on the first "@" because refs may contain
|
|
16
|
+
// slashes.
|
|
17
|
+
export function parseRepoSpec(spec) {
|
|
18
|
+
const at = spec.indexOf('@');
|
|
19
|
+
const repo = at === -1 ? spec : spec.slice(0, at);
|
|
20
|
+
const ref = at === -1 ? null : spec.slice(at + 1);
|
|
21
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) return null;
|
|
22
|
+
if (ref !== null && !isValidRef(ref)) return null;
|
|
23
|
+
const [owner, name] = repo.split('/');
|
|
24
|
+
if (/^\.{1,2}$/.test(owner) || /^\.{1,2}$/.test(name)) return null;
|
|
25
|
+
return { owner, name, ref };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function repoUrls({ owner, name, ref }) {
|
|
29
|
+
const o = encodeURIComponent(owner);
|
|
30
|
+
const r = encodeURIComponent(name);
|
|
31
|
+
return [
|
|
32
|
+
`https://codeload.github.com/${o}/${r}/tar.gz/${ref ?? 'HEAD'}`,
|
|
33
|
+
`https://api.github.com/repos/${o}/${r}/tarball${ref ? `/${ref}` : ''}`,
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Downloads the public archive of a GitHub repository — no git, no
|
|
38
|
+
// credentials — and extracts it under a temp dir as {tmp}/{repo},
|
|
39
|
+
// so the package directory basename matches the manifest name rule.
|
|
40
|
+
// The fetch implementation comes from the caller (the CLI injects
|
|
41
|
+
// it for tests); the extracted tree is remote content, so `tar`
|
|
42
|
+
// sanitization (no "..", no absolute paths, no symlink escapes) is
|
|
43
|
+
// the security boundary, not a courtesy.
|
|
44
|
+
// Returns { ok: true, dir, cleanup } or { ok: false, error }.
|
|
45
|
+
export async function fetchRepoTree(spec, { fetch: fetchImpl, tmpBase }) {
|
|
46
|
+
if (fetchImpl === undefined) {
|
|
47
|
+
return { ok: false, error: 'no hay implementación de fetch disponible' };
|
|
48
|
+
}
|
|
49
|
+
const root = fs.mkdtempSync(path.join(tmpBase ?? os.tmpdir(), 'teleprompter-fetch-'));
|
|
50
|
+
const cleanup = () => fs.rmSync(root, { recursive: true, force: true });
|
|
51
|
+
const dir = path.join(root, spec.name);
|
|
52
|
+
|
|
53
|
+
let response = null;
|
|
54
|
+
let unreachable = true;
|
|
55
|
+
for (const url of repoUrls(spec)) {
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetchImpl(url);
|
|
58
|
+
if (res.ok) {
|
|
59
|
+
response = res;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
unreachable = false;
|
|
63
|
+
} catch {
|
|
64
|
+
// A failed request says nothing about the next endpoint:
|
|
65
|
+
// codeload may be down while the API is reachable.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (response === null) {
|
|
69
|
+
cleanup();
|
|
70
|
+
const why = unreachable ? 'no se pudo contactar con GitHub' : 'repositorio o referencia no encontrados';
|
|
71
|
+
return { ok: false, error: `${why}: ${spec.owner}/${spec.name}${spec.ref ? `@${spec.ref}` : ''}` };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const archive = path.join(root, 'repo.tar.gz');
|
|
76
|
+
fs.writeFileSync(archive, Buffer.from(await response.arrayBuffer()));
|
|
77
|
+
fs.mkdirSync(dir);
|
|
78
|
+
await extract({ file: archive, cwd: dir, strip: 1 });
|
|
79
|
+
fs.rmSync(archive);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
cleanup();
|
|
82
|
+
return { ok: false, error: `no se pudo extraer el archivo: ${error.message}` };
|
|
83
|
+
}
|
|
84
|
+
return { ok: true, dir, cleanup };
|
|
85
|
+
}
|
package/src/lock.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { hashPath } from './hash.js';
|
|
4
|
+
import { personalizationTarget } from './paths.js';
|
|
3
5
|
|
|
4
6
|
const CORRUPT_WARNING = 'teleprompter-lock.json ilegible o corrupto: se ignora';
|
|
5
7
|
const EMPTY = () => ({ packages: {}, warnings: [] });
|
|
@@ -21,6 +23,17 @@ export function readLock(destDir) {
|
|
|
21
23
|
return { packages: data.packages ?? {}, warnings: [] };
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
// The lock is the module that owns the registry's shape: readers ask
|
|
27
|
+
// for an entry by name or walk the entries, never `lock.packages`
|
|
28
|
+
// directly, so the internal structure can change in one place.
|
|
29
|
+
export function lockEntry(lock, name) {
|
|
30
|
+
return lock.packages[name];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function lockEntries(lock) {
|
|
34
|
+
return Object.entries(lock.packages);
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
// A lock is only trustworthy if every package entry holds a files
|
|
25
38
|
// array of {target, sha256?} records — anything else is corrupt even
|
|
26
39
|
// when it parses as JSON.
|
|
@@ -36,7 +49,21 @@ function isValidLock(data) {
|
|
|
36
49
|
&& Array.isArray(p.files)
|
|
37
50
|
&& p.files.every((f) => f !== null && typeof f === 'object'
|
|
38
51
|
&& typeof f.target === 'string'
|
|
39
|
-
&& (f.sha256 === undefined || typeof f.sha256 === 'string'))
|
|
52
|
+
&& (f.sha256 === undefined || typeof f.sha256 === 'string'))
|
|
53
|
+
&& (p.origin === undefined || isValidOrigin(p.origin)));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// `origin` is optional — locks written before it existed stay
|
|
57
|
+
// valid — but when present it must name the re-fetchable source:
|
|
58
|
+
// a GitHub repo with its optional ref, or a local path.
|
|
59
|
+
function isValidOrigin(o) {
|
|
60
|
+
if (o === null || typeof o !== 'object' || Array.isArray(o)) return false;
|
|
61
|
+
if (o.type === 'github') {
|
|
62
|
+
return typeof o.repo === 'string'
|
|
63
|
+
&& (o.ref === undefined || typeof o.ref === 'string');
|
|
64
|
+
}
|
|
65
|
+
if (o.type === 'path') return typeof o.path === 'string';
|
|
66
|
+
return false;
|
|
40
67
|
}
|
|
41
68
|
|
|
42
69
|
// Merges the new install into the existing history: other packages'
|
|
@@ -45,18 +72,33 @@ function isValidLock(data) {
|
|
|
45
72
|
// `identical` keeps any previous record — the content is still ours
|
|
46
73
|
// and the recorded hash still matches — but creates none for a
|
|
47
74
|
// resource we never wrote. `mkdir` actions are plan bookkeeping, not
|
|
48
|
-
// installed files.
|
|
49
|
-
|
|
75
|
+
// installed files. `origin` is the source the install came from, in
|
|
76
|
+
// lock shape: a later operation can re-fetch the package without
|
|
77
|
+
// asking for it again.
|
|
78
|
+
export function writeLock(destDir, lock, manifest, actions, origin) {
|
|
50
79
|
const previous = new Map(
|
|
51
|
-
(lock
|
|
80
|
+
(lockEntry(lock, manifest.name)?.files ?? []).map((f) => [f.target, f]),
|
|
52
81
|
);
|
|
53
82
|
const files = actions.flatMap(({ target, action, sha256 }) => {
|
|
54
|
-
if (action === '
|
|
83
|
+
if (action === 'remove') return [];
|
|
84
|
+
if (action === 'identical' || action === 'mkdir' || action === 'keep') {
|
|
55
85
|
const prev = previous.get(target);
|
|
56
86
|
return prev === undefined ? [] : [prev];
|
|
57
87
|
}
|
|
58
88
|
return [sha256 === undefined ? { target, action } : { target, action, sha256 }];
|
|
59
89
|
});
|
|
90
|
+
// The managed guide never appears among the install actions — the
|
|
91
|
+
// manifest field already names it — but it is recorded like any
|
|
92
|
+
// other file the tool wrote.
|
|
93
|
+
let personalization;
|
|
94
|
+
if (manifest.personalization) {
|
|
95
|
+
personalization = personalizationTarget(manifest.name, manifest.personalization);
|
|
96
|
+
files.push({
|
|
97
|
+
target: personalization,
|
|
98
|
+
action: previous.has(personalization) ? 'overwrite' : 'create',
|
|
99
|
+
sha256: hashPath(path.join(destDir, personalization)),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
60
102
|
const data = {
|
|
61
103
|
packages: {
|
|
62
104
|
...lock.packages,
|
|
@@ -64,11 +106,23 @@ export function writeLock(destDir, lock, manifest, actions) {
|
|
|
64
106
|
version: manifest.version,
|
|
65
107
|
installedAt: new Date().toISOString(),
|
|
66
108
|
files,
|
|
109
|
+
...(personalization === undefined ? {} : { personalization }),
|
|
110
|
+
origin,
|
|
67
111
|
},
|
|
68
112
|
},
|
|
69
113
|
};
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
114
|
+
// Atomic write: the serialized lock goes to a temp file in the
|
|
115
|
+
// same directory — rename is only atomic within one filesystem —
|
|
116
|
+
// and is then renamed over the real name, so a crash mid-write
|
|
117
|
+
// leaves the old lock or the new one, never a truncated file. The
|
|
118
|
+
// pid suffix keeps the temp name unique across concurrent runs;
|
|
119
|
+
// the finally removes it whether the write or the rename failed.
|
|
120
|
+
const lockPath = path.join(destDir, 'teleprompter-lock.json');
|
|
121
|
+
const tmpPath = `${lockPath}.${process.pid}.tmp`;
|
|
122
|
+
try {
|
|
123
|
+
fs.writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`);
|
|
124
|
+
fs.renameSync(tmpPath, lockPath);
|
|
125
|
+
} finally {
|
|
126
|
+
fs.rmSync(tmpPath, { force: true, recursive: true });
|
|
127
|
+
}
|
|
74
128
|
}
|
package/src/manifest.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { isSafeRelative, hasEntry } from './paths.js';
|
|
3
|
+
import { isSafeRelative, hasEntry, MANAGED_DIR } from './paths.js';
|
|
4
4
|
|
|
5
5
|
const KNOWN_FORMAT = 'teleprompter-package@1';
|
|
6
6
|
const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
@@ -63,7 +63,11 @@ function checkRequiresEntry(entry, index, errors) {
|
|
|
63
63
|
return;
|
|
64
64
|
}
|
|
65
65
|
checkKeys(entry, OBJECT_KEYS.requiresPath, where, errors);
|
|
66
|
-
checkRelativePath(entry.path, `${where}.path`, errors);
|
|
66
|
+
if (!checkRelativePath(entry.path, `${where}.path`, errors)) return;
|
|
67
|
+
const t = path.normalize(entry.path);
|
|
68
|
+
if (t === MANAGED_DIR || t.startsWith(`${MANAGED_DIR}/`)) {
|
|
69
|
+
errors.push(`${where}.path: "${MANAGED_DIR}/" es un prefijo reservado`);
|
|
70
|
+
}
|
|
67
71
|
if (entry.create !== undefined && typeof entry.create !== 'boolean') {
|
|
68
72
|
errors.push(`${where}.create: debe ser booleano`);
|
|
69
73
|
}
|
|
@@ -121,9 +125,11 @@ const VALIDATORS = [
|
|
|
121
125
|
for (const t of targets) {
|
|
122
126
|
if (t === 'teleprompter-lock.json') {
|
|
123
127
|
errors.push('install: "teleprompter-lock.json" es un target reservado');
|
|
128
|
+
} else if (t === MANAGED_DIR || t.startsWith(`${MANAGED_DIR}/`)) {
|
|
129
|
+
errors.push(`install: "${MANAGED_DIR}/" es un prefijo reservado`);
|
|
124
130
|
} else if (seen.has(t)) {
|
|
125
131
|
errors.push(`install: target duplicado "${t}"`);
|
|
126
|
-
} else if (
|
|
132
|
+
} else if ([...seen].some((o) => t.startsWith(`${o}/`))) {
|
|
127
133
|
errors.push(`install: target "${t}" queda dentro de otro target`);
|
|
128
134
|
}
|
|
129
135
|
seen.add(t);
|
|
@@ -154,9 +160,30 @@ const VALIDATORS = [
|
|
|
154
160
|
errors.push('author.name: obligatorio dentro de author');
|
|
155
161
|
}
|
|
156
162
|
},
|
|
157
|
-
function checkPersonalization(manifest,
|
|
163
|
+
function checkPersonalization(manifest, { pkgDir }, errors) {
|
|
158
164
|
if (manifest.personalization === undefined) return;
|
|
159
|
-
checkRelativePath(manifest.personalization, 'personalization', errors);
|
|
165
|
+
if (!checkRelativePath(manifest.personalization, 'personalization', errors)) return;
|
|
166
|
+
const full = path.join(pkgDir, manifest.personalization);
|
|
167
|
+
if (!hasEntry(full)) {
|
|
168
|
+
errors.push(`personalization: no existe "${manifest.personalization}" dentro del paquete`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
// A guide must be a file: directories cannot be displayed, and a
|
|
172
|
+
// dangling symlink resolves to nothing. statSync follows links.
|
|
173
|
+
let isFile = false;
|
|
174
|
+
try {
|
|
175
|
+
isFile = fs.statSync(full).isFile();
|
|
176
|
+
} catch { /* dangling link or unreadable entry */ }
|
|
177
|
+
if (!isFile) {
|
|
178
|
+
errors.push(`personalization: "${manifest.personalization}" no es un archivo`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// A symlink inside the package is fine, but its target must stay
|
|
182
|
+
// inside: materializing the guide copies real content.
|
|
183
|
+
const pkgReal = fs.realpathSync(pkgDir);
|
|
184
|
+
if (!fs.realpathSync(full).startsWith(`${pkgReal}${path.sep}`)) {
|
|
185
|
+
errors.push(`personalization: "${manifest.personalization}" apunta fuera del paquete`);
|
|
186
|
+
}
|
|
160
187
|
},
|
|
161
188
|
function checkMetadata(manifest, _ctx, errors) {
|
|
162
189
|
if (manifest.metadata !== undefined && !isPlainObject(manifest.metadata)) {
|
package/src/paths.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
+
// Reserved managed namespace: the tool, not the package, owns where
|
|
5
|
+
// its own files land — same rule as teleprompter-lock.json. Install
|
|
6
|
+
// targets may not point inside it; the personalization guide is
|
|
7
|
+
// materialized under it as <name>/<basename of the declared file>.
|
|
8
|
+
export const MANAGED_DIR = '.teleprompter';
|
|
9
|
+
|
|
10
|
+
export function personalizationTarget(name, declaredPath) {
|
|
11
|
+
return `${MANAGED_DIR}/${name}/${path.basename(declaredPath)}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
4
14
|
// "Exists" means a directory entry is present, whatever it points
|
|
5
15
|
// at: existsSync follows links, so a dangling symlink would report
|
|
6
16
|
// the path as free and a later write could escape through it.
|
|
@@ -45,3 +55,34 @@ export function resolvesUnder(root, dir) {
|
|
|
45
55
|
return false;
|
|
46
56
|
}
|
|
47
57
|
}
|
|
58
|
+
|
|
59
|
+
// A recorded path is the lock's data, not trusted input: it is
|
|
60
|
+
// re-validated before use at the level the operation needs. The
|
|
61
|
+
// chain level suffices when the leaf is treated atomically — lstat,
|
|
62
|
+
// rm, hashing a link as a link — so only the parent chain must stay
|
|
63
|
+
// inside the root.
|
|
64
|
+
export function recordedChainSafe(root, rel) {
|
|
65
|
+
return isSafeRelative(rel)
|
|
66
|
+
&& resolvesUnder(root, path.dirname(path.join(root, rel)));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The leaf level of the recorded-path defense: for reads that follow
|
|
70
|
+
// the leaf itself a parent-chain proof is not enough — a recorded
|
|
71
|
+
// symlink pointing outside must not disclose its target, so the whole
|
|
72
|
+
// path is resolved and the result must land inside the root. The
|
|
73
|
+
// discriminated result keeps "unsafe" apart from "unreadable" because
|
|
74
|
+
// callers report them differently.
|
|
75
|
+
export function resolveRecordedPath(root, rel) {
|
|
76
|
+
if (!isSafeRelative(rel)) return { ok: false, reason: 'unsafe' };
|
|
77
|
+
let real;
|
|
78
|
+
try {
|
|
79
|
+
real = fs.realpathSync(path.join(root, rel));
|
|
80
|
+
const rootReal = fs.realpathSync(root);
|
|
81
|
+
if (real !== rootReal && !real.startsWith(`${rootReal}${path.sep}`)) {
|
|
82
|
+
return { ok: false, reason: 'unsafe' };
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
return { ok: false, reason: 'unreadable' };
|
|
86
|
+
}
|
|
87
|
+
return { ok: true, real };
|
|
88
|
+
}
|
package/src/plan.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { hashPath } from './hash.js';
|
|
3
|
-
import {
|
|
3
|
+
import { classifyResource } from './drift.js';
|
|
4
|
+
import { lockEntry } from './lock.js';
|
|
5
|
+
import { hasEntry, personalizationTarget, resolvesUnder } from './paths.js';
|
|
4
6
|
|
|
5
7
|
// Classifies each install entry by comparing the destination with the
|
|
6
8
|
// package resource and the recorded history:
|
|
@@ -11,8 +13,10 @@ import { hasEntry, resolvesUnder } from './paths.js';
|
|
|
11
13
|
// later version, so overwriting it is safe
|
|
12
14
|
// conflict - different content not owned by the tool
|
|
13
15
|
export function buildPlan(pkgDir, manifest, destDir, creates, lock) {
|
|
14
|
-
const record = lock
|
|
15
|
-
const recorded = new Map(
|
|
16
|
+
const record = lockEntry(lock, manifest.name);
|
|
17
|
+
const recorded = new Map(
|
|
18
|
+
(record?.files ?? []).map((f) => [path.normalize(f.target), f.sha256]),
|
|
19
|
+
);
|
|
16
20
|
const updatesAllowed = semverAtLeast(manifest.version, record?.version);
|
|
17
21
|
const resources = manifest.install.map(({ source, target }) => {
|
|
18
22
|
const dest = path.join(destDir, target);
|
|
@@ -24,7 +28,8 @@ export function buildPlan(pkgDir, manifest, destDir, creates, lock) {
|
|
|
24
28
|
}
|
|
25
29
|
const destHash = hashPath(dest);
|
|
26
30
|
const status = destHash === hashPath(path.join(pkgDir, source)) ? 'identical'
|
|
27
|
-
: updatesAllowed && recorded.get(target) === destHash
|
|
31
|
+
: updatesAllowed && recorded.get(path.normalize(target)) === destHash
|
|
32
|
+
? 'managed-update'
|
|
28
33
|
: 'conflict';
|
|
29
34
|
if (status !== 'identical' && status !== 'conflict'
|
|
30
35
|
&& !resolvesUnder(destDir, path.dirname(dest))) {
|
|
@@ -39,6 +44,115 @@ export function buildPlan(pkgDir, manifest, destDir, creates, lock) {
|
|
|
39
44
|
};
|
|
40
45
|
}
|
|
41
46
|
|
|
47
|
+
// The update plan adds a third comparison to the install plan: the
|
|
48
|
+
// incoming content against the recorded hash tells whether the
|
|
49
|
+
// version itself changed the resource, so each entry classifies by
|
|
50
|
+
// what the version brings and what the user did with it:
|
|
51
|
+
// create - the destination does not exist: a resource new in
|
|
52
|
+
// the version or one the user deleted — it is written
|
|
53
|
+
// again either way
|
|
54
|
+
// identical - the destination already holds the incoming content
|
|
55
|
+
// update - the version changed the resource (incoming differs
|
|
56
|
+
// from the recorded hash) and the destination is still
|
|
57
|
+
// intact, so overwriting it is safe
|
|
58
|
+
// conflict - the destination holds content that is not provably
|
|
59
|
+
// ours (a local edit or foreign) and differs from what
|
|
60
|
+
// the version offers — the user decides
|
|
61
|
+
// Recorded targets the incoming manifest no longer ships are
|
|
62
|
+
// retired: intact ones are removed (`retire`) — what is ours and
|
|
63
|
+
// untouched is managed — while modified or unverifiable ones
|
|
64
|
+
// degrade to conflicts marked `removal`, where `overwrite` means
|
|
65
|
+
// remove and `skip` means keep; removal conflicts carry no `source`
|
|
66
|
+
// — the executor must check `removal` before any copy. A retired
|
|
67
|
+
// target already gone leaves the plan silently. Entries never
|
|
68
|
+
// written (`skip`) and the managed guide — rewritten on every
|
|
69
|
+
// install — never retire. One exception to removal conflicts: a
|
|
70
|
+
// recorded guide the incoming version replaces — it declares its
|
|
71
|
+
// own personalization — retires even with drift, because it stops
|
|
72
|
+
// being the guide and keeping it would leave a file `guide` can
|
|
73
|
+
// never show; an unverifiable one stays a conflict, since forcing
|
|
74
|
+
// removal of an unsafe path would turn a resolvable decision into
|
|
75
|
+
// an execution error.
|
|
76
|
+
export function buildUpdatePlan(pkgDir, manifest, destDir, creates, lock) {
|
|
77
|
+
const record = lockEntry(lock, manifest.name);
|
|
78
|
+
if (record !== undefined && record.version === manifest.version) {
|
|
79
|
+
return {
|
|
80
|
+
upToDate: true, mkdirs: [], resources: [], conflicts: [], retired: [],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
// Targets compare normalized: `./a.txt` and `a.txt` resolve to the
|
|
84
|
+
// same file, so a shipped target can never masquerade as retired.
|
|
85
|
+
const recorded = new Map(
|
|
86
|
+
(record?.files ?? []).map((f) => [path.normalize(f.target), f.sha256]),
|
|
87
|
+
);
|
|
88
|
+
const updatesAllowed = semverAtLeast(manifest.version, record?.version);
|
|
89
|
+
const resources = manifest.install.map(({ source, target }) => {
|
|
90
|
+
const dest = path.join(destDir, target);
|
|
91
|
+
if (!hasEntry(dest)) {
|
|
92
|
+
const status = resolvesUnder(destDir, path.dirname(dest)) ? 'create' : 'conflict';
|
|
93
|
+
return { source, target, status };
|
|
94
|
+
}
|
|
95
|
+
const destHash = hashPath(dest);
|
|
96
|
+
const pkgHash = hashPath(path.join(pkgDir, source));
|
|
97
|
+
const recHash = recorded.get(path.normalize(target));
|
|
98
|
+
let status;
|
|
99
|
+
if (destHash === pkgHash) status = 'identical';
|
|
100
|
+
else if (updatesAllowed && destHash === recHash && pkgHash !== recHash) {
|
|
101
|
+
status = 'update';
|
|
102
|
+
} else status = 'conflict';
|
|
103
|
+
if (status !== 'identical' && status !== 'conflict'
|
|
104
|
+
&& !resolvesUnder(destDir, path.dirname(dest))) {
|
|
105
|
+
return { source, target, status: 'conflict' };
|
|
106
|
+
}
|
|
107
|
+
return { source, target, status };
|
|
108
|
+
});
|
|
109
|
+
const shipped = new Set(manifest.install.map(({ target }) => path.normalize(target)));
|
|
110
|
+
// The managed guide is excluded from retirements only while the
|
|
111
|
+
// incoming manifest still declares it — the incoming target is the
|
|
112
|
+
// one that gets rewritten; when the version drops the field —or
|
|
113
|
+
// renames the file— the old guide retires like any other file.
|
|
114
|
+
const guideTarget = manifest.personalization === undefined
|
|
115
|
+
? undefined
|
|
116
|
+
: path.normalize(personalizationTarget(manifest.name, manifest.personalization));
|
|
117
|
+
// The lock is untrusted data: a hand-edited `personalization` that
|
|
118
|
+
// is not a string must not crash the plan.
|
|
119
|
+
const replacedGuide = guideTarget === undefined || typeof record?.personalization !== 'string'
|
|
120
|
+
? undefined
|
|
121
|
+
: path.normalize(record.personalization);
|
|
122
|
+
const retired = [];
|
|
123
|
+
const removals = (record?.files ?? [])
|
|
124
|
+
.filter((f) => f.action !== 'skip' && path.normalize(f.target) !== guideTarget
|
|
125
|
+
&& !shipped.has(path.normalize(f.target)))
|
|
126
|
+
.flatMap((f) => {
|
|
127
|
+
const drift = classifyResource(destDir, f);
|
|
128
|
+
if (drift === 'intact') return [{ target: f.target, status: 'retire' }];
|
|
129
|
+
if (drift === 'missing') return [];
|
|
130
|
+
if (drift === 'modified' && path.normalize(f.target) === replacedGuide) {
|
|
131
|
+
return [{ target: f.target, status: 'retire' }];
|
|
132
|
+
}
|
|
133
|
+
return [{ target: f.target, status: 'conflict', removal: true }];
|
|
134
|
+
});
|
|
135
|
+
for (const entry of removals) {
|
|
136
|
+
(entry.status === 'retire' ? retired : resources).push(entry);
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
upToDate: false,
|
|
140
|
+
mkdirs: creates,
|
|
141
|
+
resources,
|
|
142
|
+
conflicts: resources.filter((r) => r.status === 'conflict'),
|
|
143
|
+
retired,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Resolving is the plan's own operation: a conflict admits exactly
|
|
148
|
+
// `overwrite` or `skip` and the assignment lives here so callers
|
|
149
|
+
// decide per conflict — flag, prompt, whatever asks — without
|
|
150
|
+
// touching the entries. `decide` answers one conflict at a time, in
|
|
151
|
+
// plan order, and may return a promise.
|
|
152
|
+
export async function resolveConflicts(plan, decide) {
|
|
153
|
+
for (const r of plan.conflicts) r.resolution = await decide(r);
|
|
154
|
+
}
|
|
155
|
+
|
|
42
156
|
// Downgrades are not managed updates: writing older content over a
|
|
43
157
|
// newer recorded install must surface as a conflict, not silently
|
|
44
158
|
// pass as safe.
|