@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/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> <
|
|
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
|
|
17
|
-
const [command, ...rest] = argv;
|
|
24
|
+
function splitArgs(rest) {
|
|
18
25
|
const args = [];
|
|
19
26
|
const flags = new Set();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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(
|
|
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
|
|
112
|
+
for (const r of [...plan.resources, ...(plan.retired ?? [])]) {
|
|
113
|
+
out(` ${r.status.padEnd(15)}${r.target}`);
|
|
114
|
+
}
|
|
34
115
|
}
|
|
35
116
|
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
asker.
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
120
|
-
out
|
|
359
|
+
if (guide !== null) {
|
|
360
|
+
printGuide(out, guide.target, guideContent);
|
|
121
361
|
}
|
|
122
|
-
out(
|
|
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
|
+
}
|