@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/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 a copy throws midway, the error carries `applied` — the actions
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 = ACTION[r.status] ?? r.resolution;
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 === 'create' || action === 'overwrite') {
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
- error.applied = applied;
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
- export function writeLock(destDir, lock, manifest, actions) {
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.packages[manifest.name]?.files ?? []).map((f) => [f.target, f]),
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 === 'identical' || action === 'mkdir') {
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
- fs.writeFileSync(
71
- path.join(destDir, 'teleprompter-lock.json'),
72
- `${JSON.stringify(data, null, 2)}\n`,
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 (seen.has([...seen].find((o) => t.startsWith(`${o}/`)))) {
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, _ctx, errors) {
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 { hasEntry, resolvesUnder } from './paths.js';
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.packages[manifest.name];
15
- const recorded = new Map((record?.files ?? []).map((f) => [f.target, f.sha256]));
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 ? 'managed-update'
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.