@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/cli.js CHANGED
@@ -1,67 +1,270 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
2
3
  import { verifyPackage } from './verify.js';
3
- import { readLock, writeLock } from './lock.js';
4
- import { buildPlan } from './plan.js';
5
- import { executePlan } from './execute.js';
4
+ import { readLock, writeLock, lockEntry, lockEntries } from './lock.js';
5
+ import { buildPlan, buildUpdatePlan, resolveConflicts } from './plan.js';
6
+ import { executePlan, installPersonalization, ExecutionError } from './execute.js';
7
+ import {
8
+ resolvesUnder, personalizationTarget, resolveRecordedPath,
9
+ } from './paths.js';
10
+ import { parseRepoSpec, fetchRepoTree, isValidRef } from './fetch.js';
11
+ import { classifyResource } from './drift.js';
6
12
 
7
13
  export const EXIT_OK = 0;
8
14
  export const EXIT_MANIFEST = 1;
9
15
  export const EXIT_PLAN = 2;
10
16
  export const EXIT_EXECUTION = 3;
11
17
  export const EXIT_USAGE = 4;
18
+ export const EXIT_FETCH = 5;
12
19
 
13
- const USAGE = 'uso: teleprompter install <paquete> <destino> [--force|--skip] [--dry-run]';
20
+ const USAGE = 'uso: teleprompter [install] <user/repo[@ref]> [destino] | --path <paquete> [destino] [--force|--skip] [--dry-run] | guide [<paquete>] | list | check | update <paquete> [<user/repo[@ref]>|--path <paquete>] [--ref <ref>] [--force|--skip] [--dry-run]';
14
21
  const KNOWN_FLAGS = new Set(['--force', '--skip', '--dry-run']);
22
+ const VALUE_OPTIONS = new Set(['--path', '--ref']);
15
23
 
16
- function parseArgs(argv) {
17
- const [command, ...rest] = argv;
24
+ function splitArgs(rest) {
18
25
  const args = [];
19
26
  const flags = new Set();
20
- for (const arg of rest) {
21
- if (arg.startsWith('--')) flags.add(arg);
22
- else args.push(arg);
27
+ const options = {};
28
+ for (let i = 0; i < rest.length; i++) {
29
+ const arg = rest[i];
30
+ if (VALUE_OPTIONS.has(arg)) {
31
+ if (options[arg] !== undefined) return null;
32
+ const value = rest[++i];
33
+ if (value === undefined || value.startsWith('--')) return null;
34
+ options[arg] = value;
35
+ } else if (arg.startsWith('--')) {
36
+ flags.add(arg);
37
+ } else {
38
+ args.push(arg);
39
+ }
23
40
  }
24
41
  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 };
42
+ if (unknown !== undefined || (flags.has('--force') && flags.has('--skip'))) return null;
43
+ return { args, flags, options };
44
+ }
45
+
46
+ // Grammar: an optional "install" alias, then either --path <dir> or a
47
+ // positional user/repo[@ref] spec, plus an optional positional OUT
48
+ // (defaults to the working directory). --ref and @ref are mutually
49
+ // exclusive; with --path no remote spec is processed at all. `guide`,
50
+ // `list` and `check` are their own commands: all consult the working
51
+ // directory's lock — `guide` admits at most one package name, `list`
52
+ // and `check` admit nothing — no options, no destination. `update`
53
+ // takes the package name plus an optional explicit origin —a repo
54
+ // spec positional or --path— and --ref overrides the github ref.
55
+ function parseArgs(argv) {
56
+ if (argv[0] === 'guide') {
57
+ const rest = argv.slice(1);
58
+ if (rest.length > 1 || rest.some((a) => a.startsWith('-'))) return null;
59
+ return { command: 'guide', pkg: rest[0] };
60
+ }
61
+ if (argv[0] === 'list' || argv[0] === 'check') {
62
+ return argv.length === 1 ? { command: argv[0] } : null;
63
+ }
64
+ if (argv[0] === 'update') {
65
+ const split = splitArgs(argv.slice(1));
66
+ if (split === null) return null;
67
+ const { args, flags, options } = split;
68
+ if (args.length < 1 || args.length > 2) return null;
69
+ if (options['--ref'] !== undefined && !isValidRef(options['--ref'])) return null;
70
+ if (options['--path'] !== undefined) {
71
+ if (args.length > 1 || options['--ref'] !== undefined) return null;
72
+ return {
73
+ command: 'update',
74
+ pkg: args[0],
75
+ source: { kind: 'path', dir: options['--path'] },
76
+ flags,
77
+ };
78
+ }
79
+ let spec = null;
80
+ if (args[1] !== undefined) {
81
+ spec = parseRepoSpec(args[1]);
82
+ if (spec === null || (spec.ref !== null && options['--ref'] !== undefined)) return null;
83
+ spec.ref = options['--ref'] ?? spec.ref;
84
+ }
85
+ return {
86
+ command: 'update',
87
+ pkg: args[0],
88
+ source: spec === null ? null : { kind: 'repo', spec },
89
+ ref: options['--ref'],
90
+ flags,
91
+ };
92
+ }
93
+ const rest = argv[0] === 'install' ? argv.slice(1) : argv;
94
+ const split = splitArgs(rest);
95
+ if (split === null) return null;
96
+ const { args, flags, options } = split;
97
+ if (options['--path'] !== undefined) {
98
+ if (args.length > 1 || options['--ref'] !== undefined) return null;
99
+ return { command: 'install', source: { kind: 'path', dir: options['--path'] }, dest: args[0], flags };
100
+ }
101
+ if (args.length < 1 || args.length > 2) return null;
102
+ const spec = parseRepoSpec(args[0]);
103
+ if (spec === null || (spec.ref !== null && options['--ref'] !== undefined)) return null;
104
+ if (options['--ref'] !== undefined && !isValidRef(options['--ref'])) return null;
105
+ spec.ref = options['--ref'] ?? spec.ref;
106
+ return { command: 'install', source: { kind: 'repo', spec }, dest: args[1], flags };
28
107
  }
29
108
 
30
- function printPlan(plan, out) {
31
- out('plan de instalación:');
109
+ function printPlan(plan, out, title) {
110
+ out(title);
32
111
  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}`);
112
+ for (const r of [...plan.resources, ...(plan.retired ?? [])]) {
113
+ out(` ${r.status.padEnd(15)}${r.target}`);
114
+ }
34
115
  }
35
116
 
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;
117
+ // The maintainer's guide is delivered verbatim under a heading that
118
+ // names its managed location — the installer and the `guide` command
119
+ // share this block so consulting later shows what installing showed.
120
+ function printGuide(out, target, content) {
121
+ out(`personalización (${target}):`);
122
+ for (const line of content.replace(/\n$/, '').split('\n')) out(line);
123
+ }
43
124
 
44
- const parsed = parseArgs(argv);
45
- if (parsed === null) {
46
- err(USAGE);
125
+ // `guide` re-reads what the lock recorded: each installed package's
126
+ // managed guide path, then the file itself. Resolution failures are
127
+ // usage errors; a recorded file that is gone or unreadable is an
128
+ // execution failure — the install drifted, not the invocation.
129
+ function showGuide(io, out, err, pkgName) {
130
+ const destDir = io.cwd;
131
+ const lock = readLock(destDir);
132
+ for (const warning of lock.warnings) out(`aviso: ${warning}`);
133
+ if (pkgName !== undefined && lockEntry(lock, pkgName) === undefined) {
134
+ err(`el paquete "${pkgName}" no está instalado`);
47
135
  return EXIT_USAGE;
48
136
  }
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
- }
137
+ const withGuide = lockEntries(lock)
138
+ .filter(([name, entry]) => (pkgName === undefined || name === pkgName)
139
+ && typeof entry.personalization === 'string');
140
+ if (withGuide.length === 0) {
141
+ err(pkgName === undefined
142
+ ? 'ningún paquete instalado declara instrucciones de personalización'
143
+ : `el paquete "${pkgName}" no declara instrucciones de personalización`);
56
144
  return EXIT_USAGE;
57
145
  }
146
+ // The lock is repository data, not trusted memory: its recorded
147
+ // paths are re-validated before reading, like any other untrusted
148
+ // input — the leaf level of the recorded-path defense, because the
149
+ // command reads through the leaf and a recorded symlink pointing
150
+ // outside would disclose arbitrary files. All guides are read
151
+ // before printing, so a failure never leaves half the output
152
+ // behind.
153
+ const guides = [];
154
+ for (const [, entry] of withGuide) {
155
+ const rel = entry.personalization;
156
+ const resolved = resolveRecordedPath(destDir, rel);
157
+ if (!resolved.ok) {
158
+ err(resolved.reason === 'unsafe'
159
+ ? `la ruta de guía registrada no es segura: ${rel}`
160
+ : `no se puede leer la guía registrada: ${rel}`);
161
+ return EXIT_EXECUTION;
162
+ }
163
+ try {
164
+ guides.push({ target: rel, content: fs.readFileSync(resolved.real, 'utf8') });
165
+ } catch (error) {
166
+ err(`no se puede leer la guía registrada: ${rel}`);
167
+ return EXIT_EXECUTION;
168
+ }
169
+ }
170
+ for (const g of guides) printGuide(out, g.target, g.content);
171
+ return EXIT_OK;
172
+ }
58
173
 
59
- const result = verifyPackage(pkgDir, destDir);
174
+ // `list` presents what the lock recorded — the registry as product
175
+ // surface: name, version, when, and the written targets, without
176
+ // internal details like hashes or actions. A `skip` entry recorded
177
+ // the decision but wrote nothing, so it is not listed; a package
178
+ // with a managed guide points at `guide` as the way to consult it;
179
+ // an entry without `installedAt` — valid per the lock contract —
180
+ // shows no date. No recorded installations is an answer, not a
181
+ // failure.
182
+ function showList(io, out) {
183
+ const lock = readLock(io.cwd);
184
+ for (const warning of lock.warnings) out(`aviso: ${warning}`);
185
+ const entries = lockEntries(lock);
186
+ if (entries.length === 0) {
187
+ out('no hay paquetes instalados');
188
+ return EXIT_OK;
189
+ }
190
+ out('paquetes instalados:');
191
+ for (const [name, entry] of entries) {
192
+ const when = typeof entry.installedAt === 'string'
193
+ ? ` — instalado ${entry.installedAt}` : '';
194
+ out(` ${name}@${entry.version}${when}`);
195
+ if (typeof entry.personalization === 'string') {
196
+ out(` guía: teleprompter guide ${name}`);
197
+ }
198
+ for (const f of entry.files) {
199
+ if (f.action !== 'skip') out(` ${f.target}`);
200
+ }
201
+ }
202
+ return EXIT_OK;
203
+ }
204
+
205
+ // Drift marks in product language: `check` reports the state of each
206
+ // recorded resource without exposing the hashes it compared.
207
+ const DRIFT_MARKS = {
208
+ intact: 'intacto',
209
+ modified: 'modificado',
210
+ missing: 'ausente',
211
+ unverifiable: 'no verificable',
212
+ };
213
+
214
+ // `check` confronts the registry with the disk: for each resource the
215
+ // lock recorded, its drift mark — intact, modified, missing or
216
+ // unverifiable when there is no reference or the read fails. `skip`
217
+ // entries recorded a decision that wrote nothing, so they are not
218
+ // reported; drift is information, not a failure, so the command
219
+ // always exits 0.
220
+ function showCheck(io, out) {
221
+ const lock = readLock(io.cwd);
222
+ for (const warning of lock.warnings) out(`aviso: ${warning}`);
223
+ const entries = lockEntries(lock);
224
+ if (entries.length === 0) {
225
+ out('no hay paquetes instalados');
226
+ return EXIT_OK;
227
+ }
228
+ out('estado de los recursos:');
229
+ for (const [name, entry] of entries) {
230
+ out(` ${name}@${entry.version}`);
231
+ for (const f of entry.files) {
232
+ if (f.action === 'skip') continue;
233
+ const mark = DRIFT_MARKS[classifyResource(io.cwd, f)];
234
+ out(` ${mark.padEnd(15)}${f.target}`);
235
+ }
236
+ }
237
+ return EXIT_OK;
238
+ }
239
+
240
+ // Obtaining is the only phase that may leave something behind: a
241
+ // remote fetch lands in a temp dir the caller must release whatever
242
+ // happens next, so the result carries `cleanup` beside `pkgDir`.
243
+ async function obtainPackage(io, out, err, source) {
244
+ if (source.kind !== 'repo') return { ok: true, pkgDir: source.dir, cleanup: null };
245
+ const { owner, name, ref } = source.spec;
246
+ out(`obteniendo: ${owner}/${name}${ref ? `@${ref}` : ''}`);
247
+ const fetched = await fetchRepoTree(source.spec, { fetch: io.fetch, tmpBase: io.tmpBase });
248
+ if (!fetched.ok) {
249
+ err(`error de obtención: ${fetched.error}`);
250
+ return { ok: false, code: EXIT_FETCH };
251
+ }
252
+ return { ok: true, pkgDir: fetched.dir, cleanup: fetched.cleanup };
253
+ }
254
+
255
+ // Verification maps to output and exit codes. `expectedName` — only
256
+ // `update` passes it — fails an origin that publishes a different
257
+ // package before its preconditions are even looked at.
258
+ function checkVerified(result, out, err, expectedName) {
60
259
  for (const warning of result.warnings) out(`aviso: ${warning}`);
61
260
  if (result.kind === 'manifest') {
62
261
  for (const error of result.errors) err(`manifiesto inválido: ${error}`);
63
262
  return EXIT_MANIFEST;
64
263
  }
264
+ if (expectedName !== undefined && result.manifest.name !== expectedName) {
265
+ err(`el origen publica "${result.manifest.name}", no "${expectedName}"`);
266
+ return EXIT_USAGE;
267
+ }
65
268
  if (result.kind === 'requires') {
66
269
  for (const p of result.failures) {
67
270
  err(`precondición incumplida: "${p}" no existe en el destino`);
@@ -69,46 +272,83 @@ export async function main(argv, io = {}) {
69
272
  return EXIT_PLAN;
70
273
  }
71
274
  out(`verificado: ${result.manifest.name}@${result.manifest.version}`);
275
+ return null;
276
+ }
72
277
 
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;
278
+ // The D006 policy in one place: --force and --skip decide ahead, an
279
+ // interactive console asks conflict by conflict, anything else lists
280
+ // and aborts. A `removal` conflict asks about removing and reports
281
+ // the decision as remove/keep — the plan's `overwrite`/`skip`
282
+ // vocabulary stays internal.
283
+ async function settleConflicts(io, out, err, flags, plan) {
284
+ if (plan.conflicts.length === 0) return null;
285
+ if (flags.has('--force')) {
286
+ await resolveConflicts(plan, () => 'overwrite');
287
+ } else if (flags.has('--skip')) {
288
+ await resolveConflicts(plan, () => 'skip');
289
+ } else if (io.interactive && io.createAsker) {
290
+ const asker = io.createAsker();
291
+ try {
292
+ await resolveConflicts(plan, async (r) => {
293
+ const question = r.removal === true
294
+ ? `el recurso ${r.target} se retira y tiene cambios locales: ¿quitar? [s/N] `
295
+ : `colisión en ${r.target}: ¿sobrescribir? [s/N] `;
296
+ return (await asker.ask(question)) ? 'overwrite' : 'skip';
297
+ });
298
+ } finally {
299
+ asker.close();
97
300
  }
98
- for (const r of plan.conflicts) out(` ${r.target} → ${r.resolution}`);
301
+ } else {
302
+ for (const r of plan.conflicts) err(`conflicto sin resolver: ${r.target}`);
303
+ err('plan no ejecutable: colisiones sin resolver');
304
+ return EXIT_PLAN;
305
+ }
306
+ for (const r of plan.conflicts) {
307
+ const shown = r.removal === true
308
+ ? (r.resolution === 'overwrite' ? 'remove' : 'keep')
309
+ : r.resolution;
310
+ out(` ${r.target} → ${shown}`);
99
311
  }
312
+ return null;
313
+ }
100
314
 
101
- if (flags.has('--dry-run')) {
102
- out('fin del plan (--dry-run): nada se escribió');
103
- return EXIT_OK;
315
+ // The managed guide writes outside the plan's resources, but its
316
+ // destination is still provable before anything is written — an
317
+ // escaping .teleprompter/ makes the plan non-executable, not a
318
+ // mid-execution surprise (D005).
319
+ function checkGuideDestination(destDir, manifest, err) {
320
+ if (!manifest.personalization) return null;
321
+ const guideDest = path.join(destDir,
322
+ personalizationTarget(manifest.name, manifest.personalization));
323
+ if (!resolvesUnder(destDir, path.dirname(guideDest))) {
324
+ err(`la ruta destino escapa de la raíz: ${guideDest}`);
325
+ err('plan no ejecutable');
326
+ return EXIT_PLAN;
104
327
  }
328
+ return null;
329
+ }
105
330
 
331
+ // Runs the resolved plan, materializes the guide, records the install
332
+ // and reports what happened — the same ending for install and update
333
+ // apart from the final verb. On failure the report lists the actions
334
+ // already applied so the partial state is visible.
335
+ function executeAndReport(out, err, { pkgDir, destDir, lock, plan, manifest, source, verb }) {
106
336
  let actions;
337
+ let guide;
338
+ let guideContent = null;
107
339
  try {
108
340
  actions = executePlan(pkgDir, destDir, plan);
109
- writeLock(destDir, lock, result.manifest, actions);
341
+ guide = installPersonalization(pkgDir, destDir, manifest);
342
+ writeLock(destDir, lock, manifest, actions, originOf(source));
343
+ // Deliver what was installed, not the package source — the
344
+ // managed copy survives a remote fetch's cleanup and is the same
345
+ // content `guide` will show later.
346
+ if (guide !== null) {
347
+ guideContent = fs.readFileSync(path.join(destDir, guide.target), 'utf8');
348
+ }
110
349
  } catch (error) {
111
- for (const a of error.applied ?? actions) {
350
+ const applied = error instanceof ExecutionError ? error.applied : actions;
351
+ for (const a of applied) {
112
352
  out(` ${a.action.padEnd(15)}${a.target}`);
113
353
  }
114
354
  err(`error de ejecución: ${error.message}`);
@@ -116,9 +356,176 @@ export async function main(argv, io = {}) {
116
356
  }
117
357
  out('resultado:');
118
358
  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`);
359
+ if (guide !== null) {
360
+ printGuide(out, guide.target, guideContent);
121
361
  }
122
- out(`instalado: ${result.manifest.name}@${result.manifest.version}`);
362
+ out(`${verb}: ${manifest.name}@${manifest.version}`);
123
363
  return EXIT_OK;
124
364
  }
365
+
366
+ // `update` brings an installed package to the version its origin
367
+ // publishes — the recorded one unless the invocation overrides it.
368
+ // Its prologue resolves the source from the lock; from obtaining on
369
+ // it runs the same pipeline as install, over the drift-aware update
370
+ // plan whose removal conflicts ask about deleting, not overwriting.
371
+ async function runUpdate(io, out, err, parsed) {
372
+ const destDir = io.cwd;
373
+ const lock = readLock(destDir);
374
+ for (const warning of lock.warnings) out(`aviso: ${warning}`);
375
+ const record = lockEntry(lock, parsed.pkg);
376
+ if (record === undefined) {
377
+ err(`el paquete "${parsed.pkg}" no está instalado`);
378
+ return EXIT_USAGE;
379
+ }
380
+ let source = parsed.source;
381
+ if (source === null) {
382
+ const { origin } = record;
383
+ if (origin === undefined) {
384
+ err(`el paquete "${parsed.pkg}" no registra un origen: indica <user/repo[@ref]> o --path <dir>`);
385
+ return EXIT_USAGE;
386
+ }
387
+ if (origin.type === 'path') {
388
+ if (parsed.ref !== undefined) {
389
+ err('un --ref no aplica a un origen local');
390
+ return EXIT_USAGE;
391
+ }
392
+ source = { kind: 'path', dir: origin.path };
393
+ } else {
394
+ // The lock is repository data: a `repo` that cannot parse back
395
+ // into a spec is an unusable origin, not a crash.
396
+ const spec = parseRepoSpec(origin.repo);
397
+ if (spec === null) {
398
+ err(`el origen registrado no es un repositorio válido: ${origin.repo}`);
399
+ return EXIT_USAGE;
400
+ }
401
+ spec.ref = parsed.ref ?? origin.ref ?? null;
402
+ source = { kind: 'repo', spec };
403
+ }
404
+ }
405
+ if (source.kind === 'path' && !isDir(source.dir)) {
406
+ err(`la ruta no es un directorio: ${source.dir}`);
407
+ return EXIT_USAGE;
408
+ }
409
+
410
+ const obtained = await obtainPackage(io, out, err, source);
411
+ if (!obtained.ok) return obtained.code;
412
+ const { pkgDir, cleanup } = obtained;
413
+ try {
414
+ const result = verifyPackage(pkgDir, destDir);
415
+ const invalid = checkVerified(result, out, err, parsed.pkg);
416
+ if (invalid !== null) return invalid;
417
+ const { manifest } = result;
418
+
419
+ const plan = buildUpdatePlan(pkgDir, manifest, destDir, result.creates, lock);
420
+ if (plan.upToDate) {
421
+ out(`${manifest.name}@${manifest.version} ya está en esa versión`);
422
+ return EXIT_OK;
423
+ }
424
+ printPlan(plan, out, 'plan de actualización:');
425
+
426
+ const unresolved = await settleConflicts(io, out, err, parsed.flags, plan);
427
+ if (unresolved !== null) return unresolved;
428
+ const escaping = checkGuideDestination(destDir, manifest, err);
429
+ if (escaping !== null) return escaping;
430
+
431
+ if (parsed.flags.has('--dry-run')) {
432
+ out('fin del plan (--dry-run): nada se escribió');
433
+ return EXIT_OK;
434
+ }
435
+
436
+ return executeAndReport(out, err, {
437
+ pkgDir, destDir, lock, plan, manifest, source, verb: 'actualizado',
438
+ });
439
+ } finally {
440
+ if (cleanup !== null) cleanup();
441
+ }
442
+ }
443
+
444
+ const isDir = (p) => {
445
+ try {
446
+ return fs.statSync(p).isDirectory();
447
+ } catch {
448
+ return false;
449
+ }
450
+ };
451
+
452
+ // The installation's origin in lock shape: what a later operation
453
+ // needs to fetch the package again without asking. A remote source
454
+ // records owner/repo plus the ref the user gave —absent ref means
455
+ // the remote's default branch at fetch time—; a local --path is
456
+ // recorded absolute, because the relative form would die with the
457
+ // working directory of that invocation.
458
+ function originOf(source) {
459
+ if (source.kind === 'path') {
460
+ return { type: 'path', path: path.resolve(source.dir) };
461
+ }
462
+ const { owner, name, ref } = source.spec;
463
+ const repo = `${owner}/${name}`;
464
+ return ref === null ? { type: 'github', repo } : { type: 'github', repo, ref };
465
+ }
466
+
467
+ // `install` checks its invocation's paths, then runs the shared
468
+ // pipeline over the install plan — obtain, verify, plan, resolve,
469
+ // execute, register, report.
470
+ async function runInstall(io, out, err, { source, dest, flags }) {
471
+ const destDir = dest ?? io.cwd;
472
+ const badPaths = source.kind === 'path'
473
+ ? [source.dir, destDir].filter((p) => !isDir(p))
474
+ : isDir(destDir) ? [] : [destDir];
475
+ if (badPaths.length > 0) {
476
+ for (const p of badPaths) err(`la ruta no es un directorio: ${p}`);
477
+ return EXIT_USAGE;
478
+ }
479
+
480
+ const obtained = await obtainPackage(io, out, err, source);
481
+ if (!obtained.ok) return obtained.code;
482
+ const { pkgDir, cleanup } = obtained;
483
+ try {
484
+ const result = verifyPackage(pkgDir, destDir);
485
+ const invalid = checkVerified(result, out, err);
486
+ if (invalid !== null) return invalid;
487
+ const { manifest } = result;
488
+
489
+ const lock = readLock(destDir);
490
+ for (const warning of lock.warnings) out(`aviso: ${warning}`);
491
+ const plan = buildPlan(pkgDir, manifest, destDir, result.creates, lock);
492
+ printPlan(plan, out, 'plan de instalación:');
493
+
494
+ const unresolved = await settleConflicts(io, out, err, flags, plan);
495
+ if (unresolved !== null) return unresolved;
496
+ const escaping = checkGuideDestination(destDir, manifest, err);
497
+ if (escaping !== null) return escaping;
498
+
499
+ if (flags.has('--dry-run')) {
500
+ out('fin del plan (--dry-run): nada se escribió');
501
+ return EXIT_OK;
502
+ }
503
+
504
+ return executeAndReport(out, err, {
505
+ pkgDir, destDir, lock, plan, manifest, source, verb: 'instalado',
506
+ });
507
+ } finally {
508
+ if (cleanup !== null) cleanup();
509
+ }
510
+ }
511
+
512
+ // Invocation layer only: parses arguments, delegates to src/ and maps
513
+ // the result to output and exit codes. Keeping it thin is what lets
514
+ // the test suite exercise the CLI without spawning processes.
515
+ // io: { out, err, cwd, interactive, createAsker, fetch, tmpBase } —
516
+ // injectable for tests.
517
+ export async function main(argv, io = {}) {
518
+ const out = io.out ?? console.log;
519
+ const err = io.err ?? console.error;
520
+
521
+ const parsed = parseArgs(argv);
522
+ if (parsed === null) {
523
+ err(USAGE);
524
+ return EXIT_USAGE;
525
+ }
526
+ if (parsed.command === 'guide') return showGuide(io, out, err, parsed.pkg);
527
+ if (parsed.command === 'list') return showList(io, out);
528
+ if (parsed.command === 'check') return showCheck(io, out);
529
+ if (parsed.command === 'update') return runUpdate(io, out, err, parsed);
530
+ return runInstall(io, out, err, parsed);
531
+ }
package/src/drift.js ADDED
@@ -0,0 +1,28 @@
1
+ import path from 'node:path';
2
+ import { hashPath } from './hash.js';
3
+ import { recordedChainSafe } from './paths.js';
4
+
5
+ // The drift of a recorded resource: what the destination holds now
6
+ // against what the lock says was written. `missing` covers a target
7
+ // that is gone — hashPath's ENOENT is the signal. `unverifiable`
8
+ // covers entries recorded without a hash — valid per the lock
9
+ // contract —, reads that fail for any reason other than absence,
10
+ // and targets that are not safe to read at all: the lock is
11
+ // repository data, not trusted memory, so a recorded path is
12
+ // re-validated before use like any other untrusted input. The chain
13
+ // level of the recorded-path defense is enough here — a `..` segment
14
+ // or a parent chain escaping through a symlink would make the check
15
+ // read outside the destination, while the leaf itself stays unproven
16
+ // because hashPath lstats it rather than following it.
17
+ export function classifyResource(destDir, { target, sha256 }) {
18
+ if (sha256 === undefined) return 'unverifiable';
19
+ if (!recordedChainSafe(destDir, target)) return 'unverifiable';
20
+ const abs = path.join(destDir, target);
21
+ let current;
22
+ try {
23
+ current = hashPath(abs);
24
+ } catch (error) {
25
+ return error.code === 'ENOENT' ? 'missing' : 'unverifiable';
26
+ }
27
+ return current === sha256 ? 'intact' : 'modified';
28
+ }