create-pkgbld 1.8.2 → 2.0.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 +85 -10
- package/extensions-schema.json +53 -0
- package/extensions.json +33 -0
- package/index.js +2 -2
- package/lock-schema-v1.json +26 -0
- package/package.json +23 -8
- package/src/conflicts.js +21 -0
- package/src/diff.js +83 -0
- package/src/engine.js +120 -0
- package/src/extension-cache.js +110 -0
- package/src/{get-git-root.ts → get-git-root.js} +1 -1
- package/src/index.js +257 -0
- package/src/install.js +59 -0
- package/src/inventory.js +161 -0
- package/src/package-names.js +25 -0
- package/src/package-operations.js +353 -0
- package/src/package-resolution.js +40 -0
- package/src/package-version.js +48 -0
- package/src/plugin-compatibility.js +79 -0
- package/src/project-changes.js +379 -0
- package/src/project-lock.js +74 -0
- package/src/registry.js +184 -0
- package/src/subcommands.js +345 -0
- package/src/tree.js +282 -0
- package/src/tui.js +167 -0
- package/src/types.js +28 -0
- package/src/update-engine.js +196 -0
- package/CHANGELOG.md +0 -206
- package/eslint.config.mjs +0 -42
- package/src/index.ts +0 -524
- package/src/reset.d.ts +0 -1
- package/src/types.ts +0 -33
- package/tsconfig.json +0 -13
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
import prompts from 'prompts';
|
|
6
|
+
|
|
7
|
+
import { blue, gray, green, red, white, yellow } from '@niceties/ansi';
|
|
8
|
+
import { parseArgsPlus } from '@niceties/node-parseargs-plus';
|
|
9
|
+
import { help } from '@niceties/node-parseargs-plus/help';
|
|
10
|
+
import { parameters } from '@niceties/node-parseargs-plus/parameters';
|
|
11
|
+
|
|
12
|
+
import { formatConflicts } from './conflicts.js';
|
|
13
|
+
import { renderChanges } from './diff.js';
|
|
14
|
+
import { changesAffectDependencies, detectPackageManager, runInstall } from './install.js';
|
|
15
|
+
import { openPackageOperations, PackageOperationError } from './package-operations.js';
|
|
16
|
+
import { resolveInstalledPackage } from './package-resolution.js';
|
|
17
|
+
import { ProjectChanges } from './project-changes.js';
|
|
18
|
+
import { loadRegistry } from './registry.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {import('./types.js').OptionsValue} OptionsValue
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const builtinRegistryPath = path.resolve(__dirname, '..', 'extensions.json');
|
|
26
|
+
|
|
27
|
+
const commonOptions = {
|
|
28
|
+
quiet: { type: /** @type {'boolean'} */ ('boolean'), short: 'q', description: 'Quiet mode', default: false },
|
|
29
|
+
yes: { type: /** @type {'boolean'} */ ('boolean'), short: 'y', description: 'Skip prompts, use defaults', default: false },
|
|
30
|
+
'dry-run': { type: /** @type {'boolean'} */ ('boolean'), description: 'Print changes without writing', default: false },
|
|
31
|
+
install: {
|
|
32
|
+
type: /** @type {'boolean'} */ ('boolean'),
|
|
33
|
+
description: 'Run package manager install after committing dependency changes',
|
|
34
|
+
default: false,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} version
|
|
40
|
+
* @param {string[]} argv
|
|
41
|
+
*/
|
|
42
|
+
export async function runList(version, argv) {
|
|
43
|
+
const args = parseArgsPlus({ name: 'create-pkgbld list', version, options: commonOptions, args: argv }, [help, parameters]);
|
|
44
|
+
const quiet = Boolean(args.values.quiet);
|
|
45
|
+
const projectRoot = process.cwd();
|
|
46
|
+
if (!quiet) console.log(`create-pkgbld v${version}\n`);
|
|
47
|
+
|
|
48
|
+
const packages = await openPackageOperations({ projectRoot, registry: await loadRegistry(builtinRegistryPath) });
|
|
49
|
+
printWarnings(packages.warnings);
|
|
50
|
+
const items = packages.inventory;
|
|
51
|
+
if (items.length === 0) {
|
|
52
|
+
console.log(gray('No PKG BLD packages found.'));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
for (const item of items) {
|
|
56
|
+
console.log(`${white(pad16plus(item.name))}${gray(item.description.padEnd(40))} ${formatState(item.state, item.error)}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {string} version
|
|
62
|
+
* @param {string[]} argv
|
|
63
|
+
*/
|
|
64
|
+
export async function runAdd(version, argv) {
|
|
65
|
+
return runAddOrRemove('add', version, argv);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {string} version
|
|
70
|
+
* @param {string[]} argv
|
|
71
|
+
*/
|
|
72
|
+
export async function runRemoveCmd(version, argv) {
|
|
73
|
+
return runAddOrRemove('remove', version, argv);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {string} version
|
|
78
|
+
* @param {string[]} argv
|
|
79
|
+
*/
|
|
80
|
+
export async function runUpdate(version, argv) {
|
|
81
|
+
const args = parseArgsPlus(
|
|
82
|
+
{
|
|
83
|
+
name: 'create-pkgbld update',
|
|
84
|
+
version,
|
|
85
|
+
parameters: ['<package>'],
|
|
86
|
+
options: {
|
|
87
|
+
...commonOptions,
|
|
88
|
+
'accept-conflicts': {
|
|
89
|
+
type: /** @type {'boolean'} */ ('boolean'),
|
|
90
|
+
description: 'Apply proposed replacements for migration conflicts',
|
|
91
|
+
default: false,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
args: argv,
|
|
95
|
+
},
|
|
96
|
+
[help, parameters]
|
|
97
|
+
);
|
|
98
|
+
const quiet = Boolean(args.values.quiet);
|
|
99
|
+
const yes = Boolean(args.values.yes);
|
|
100
|
+
const dryRun = Boolean(args.values['dry-run']);
|
|
101
|
+
const acceptConflicts = Boolean(args.values['accept-conflicts']);
|
|
102
|
+
const requestedName = /** @type {string} */ (args.parameters.package);
|
|
103
|
+
const projectRoot = process.cwd();
|
|
104
|
+
if (!quiet) console.log(`create-pkgbld v${version}\n`);
|
|
105
|
+
|
|
106
|
+
const packages = await openPackageOperations({ projectRoot, registry: await loadRegistry(builtinRegistryPath) });
|
|
107
|
+
printWarnings(packages.warnings);
|
|
108
|
+
let operation;
|
|
109
|
+
try {
|
|
110
|
+
operation = await packages.prepare({ package: requestedName, target: 'updated' });
|
|
111
|
+
} catch (/** @type {any} */ error) {
|
|
112
|
+
if (error instanceof PackageOperationError) {
|
|
113
|
+
console.error(red(`${error.message}.`));
|
|
114
|
+
process.exitCode = 1;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
if (operation.effect === 'none') {
|
|
120
|
+
if (!quiet) console.log(gray(`${operation.package.name} is already up to date.`));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const answers = await collectAnswers(operation.questions, yes);
|
|
125
|
+
const project = new ProjectChanges(projectRoot);
|
|
126
|
+
try {
|
|
127
|
+
await operation.stage(project, answers);
|
|
128
|
+
} catch (/** @type {any} */ error) {
|
|
129
|
+
console.error(red(`Cannot update "${operation.package.name}": ${error.message ?? String(error)}`));
|
|
130
|
+
process.exitCode = 1;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const { changes, conflicts } = project.review();
|
|
134
|
+
const versions = operation.versionChange;
|
|
135
|
+
if (!quiet) {
|
|
136
|
+
console.log(
|
|
137
|
+
`${gray(`Updating ${operation.package.name}${versions ? ` ${versions.from} -> ${versions.to}` : ''}:`)}${
|
|
138
|
+
dryRun ? ` ${blue('(dry-run)')}` : ''
|
|
139
|
+
}`
|
|
140
|
+
);
|
|
141
|
+
console.log(renderChanges(changes, { readDiskJson: p => readDiskJson(projectRoot, p) }));
|
|
142
|
+
if (conflicts.length > 0) {
|
|
143
|
+
console.log(yellow('\nConflicts detected:'));
|
|
144
|
+
for (const line of formatConflicts(conflicts)) console.log(yellow(line));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (dryRun) return;
|
|
148
|
+
|
|
149
|
+
if (conflicts.length > 0 && !acceptConflicts) {
|
|
150
|
+
let approved = false;
|
|
151
|
+
if (!yes && !quiet) {
|
|
152
|
+
const answer = await prompts({
|
|
153
|
+
type: 'confirm',
|
|
154
|
+
name: 'accept',
|
|
155
|
+
message: 'Apply the proposed replacements for these conflicts?',
|
|
156
|
+
initial: false,
|
|
157
|
+
});
|
|
158
|
+
approved = Boolean(answer.accept);
|
|
159
|
+
}
|
|
160
|
+
if (!approved) {
|
|
161
|
+
console.error(red('Update has unapplied conflicts. Review them and re-run with --accept-conflicts.'));
|
|
162
|
+
process.exitCode = 1;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const beforePkg = readDiskJson(projectRoot, 'package.json');
|
|
168
|
+
if (operation.requiresInstall) {
|
|
169
|
+
await project.commit({ lock: 'exclude' });
|
|
170
|
+
const pm = detectPackageManager(projectRoot);
|
|
171
|
+
if (!quiet) console.log(gray(`\nRunning ${pm} install...`));
|
|
172
|
+
const code = await runInstall(pm, projectRoot);
|
|
173
|
+
if (code !== 0) {
|
|
174
|
+
console.error(red(`${pm} install exited with code ${code}; the PKG BLD lock was not advanced`));
|
|
175
|
+
process.exitCode = code;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const installed = resolveInstalledPackage(operation.package.id, projectRoot)?.version;
|
|
179
|
+
if (!versions || installed !== versions.to) {
|
|
180
|
+
console.error(
|
|
181
|
+
red(
|
|
182
|
+
`Installed plugin version ${installed ?? '<unresolved>'} does not match expected ${versions?.to ?? '<unknown>'}; the PKG BLD lock was not advanced`
|
|
183
|
+
)
|
|
184
|
+
);
|
|
185
|
+
process.exitCode = 1;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
await project.commit({ lock: 'only' });
|
|
189
|
+
} else {
|
|
190
|
+
await project.commit();
|
|
191
|
+
await maybeInstallDependencies({ changes, projectRoot, beforePkg, installFlag: Boolean(args.values.install), quiet, yes });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {'add' | 'remove'} mode
|
|
197
|
+
* @param {string} version
|
|
198
|
+
* @param {string[]} argv
|
|
199
|
+
*/
|
|
200
|
+
async function runAddOrRemove(mode, version, argv) {
|
|
201
|
+
const args = parseArgsPlus(
|
|
202
|
+
{
|
|
203
|
+
name: `create-pkgbld ${mode}`,
|
|
204
|
+
version,
|
|
205
|
+
parameters: ['<package>'],
|
|
206
|
+
options: commonOptions,
|
|
207
|
+
args: argv,
|
|
208
|
+
},
|
|
209
|
+
[help, parameters]
|
|
210
|
+
);
|
|
211
|
+
const quiet = Boolean(args.values.quiet);
|
|
212
|
+
const yes = Boolean(args.values.yes);
|
|
213
|
+
const dryRun = Boolean(args.values['dry-run']);
|
|
214
|
+
const installFlag = Boolean(args.values.install);
|
|
215
|
+
const requestedName = /** @type {string} */ (args.parameters.package);
|
|
216
|
+
const projectRoot = process.cwd();
|
|
217
|
+
if (!quiet) console.log(`create-pkgbld v${version}\n`);
|
|
218
|
+
|
|
219
|
+
const packages = await openPackageOperations({ projectRoot, registry: await loadRegistry(builtinRegistryPath) });
|
|
220
|
+
printWarnings(packages.warnings);
|
|
221
|
+
const target = /** @type {import('./package-operations.js').PackageTarget} */ (mode === 'add' ? 'managed' : 'absent');
|
|
222
|
+
let operation;
|
|
223
|
+
try {
|
|
224
|
+
operation = await packages.prepare({ package: requestedName, target });
|
|
225
|
+
} catch (/** @type {any} */ error) {
|
|
226
|
+
if (error instanceof PackageOperationError) {
|
|
227
|
+
console.error(red(`${error.message}.`));
|
|
228
|
+
process.exitCode = 1;
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
if (operation.effect === 'none') {
|
|
234
|
+
if (target === 'absent') {
|
|
235
|
+
console.error(red(`PKG BLD package "${requestedName}" is not installed.`));
|
|
236
|
+
process.exitCode = 1;
|
|
237
|
+
} else if (!quiet) {
|
|
238
|
+
console.log(gray(`${operation.package.name} is already managed.`));
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const answers = await collectAnswers(operation.questions, yes);
|
|
244
|
+
const project = new ProjectChanges(projectRoot);
|
|
245
|
+
await operation.stage(project, answers);
|
|
246
|
+
const { changes, conflicts } = project.review();
|
|
247
|
+
|
|
248
|
+
if (!quiet) {
|
|
249
|
+
const verb = mode === 'add' ? 'Adding' : 'Removing';
|
|
250
|
+
console.log(`${gray(`${verb} ${operation.package.name}:`)}${dryRun ? ` ${blue('(dry-run)')}` : ''}`);
|
|
251
|
+
console.log(renderChanges(changes, { readDiskJson: p => readDiskJson(projectRoot, p) }));
|
|
252
|
+
if (conflicts.length > 0) {
|
|
253
|
+
console.log(yellow('\nConflicts detected:'));
|
|
254
|
+
for (const line of formatConflicts(conflicts)) console.log(yellow(line));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (dryRun) return;
|
|
259
|
+
|
|
260
|
+
const beforePkg = readDiskJson(projectRoot, 'package.json');
|
|
261
|
+
await project.commit();
|
|
262
|
+
|
|
263
|
+
await maybeInstallDependencies({ changes, projectRoot, beforePkg, installFlag, quiet, yes });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** @param {{ changes: readonly import('./tree.js').FileChange[], projectRoot: string, beforePkg: any, installFlag: boolean, quiet: boolean, yes: boolean }} params */
|
|
267
|
+
async function maybeInstallDependencies({ changes, projectRoot, beforePkg, installFlag, quiet, yes }) {
|
|
268
|
+
if (!changesAffectDependencies(changes, beforePkg)) return;
|
|
269
|
+
const pm = detectPackageManager(projectRoot);
|
|
270
|
+
let shouldInstall = installFlag;
|
|
271
|
+
if (!shouldInstall && !yes && !quiet) {
|
|
272
|
+
const ans = await prompts({ type: 'confirm', name: 'go', message: `Run ${pm} install now?`, initial: false });
|
|
273
|
+
shouldInstall = Boolean(ans.go);
|
|
274
|
+
}
|
|
275
|
+
if (shouldInstall) {
|
|
276
|
+
if (!quiet) console.log(gray(`\nRunning ${pm} install...`));
|
|
277
|
+
const code = await runInstall(pm, projectRoot);
|
|
278
|
+
if (code !== 0) {
|
|
279
|
+
console.error(red(`${pm} install exited with code ${code}`));
|
|
280
|
+
process.exitCode = code;
|
|
281
|
+
}
|
|
282
|
+
} else if (!quiet) {
|
|
283
|
+
console.log(gray(`\nDependencies changed. Run "${pm} install" to apply (or re-run with --install).`));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** @param {readonly string[]} warnings */
|
|
288
|
+
function printWarnings(warnings) {
|
|
289
|
+
for (const warning of warnings) console.warn(yellow(`Warning: ${warning}`));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @param {string} projectRoot
|
|
294
|
+
* @param {string} relPath
|
|
295
|
+
*/
|
|
296
|
+
function readDiskJson(projectRoot, relPath) {
|
|
297
|
+
try {
|
|
298
|
+
return JSON.parse(readFileSync(path.join(projectRoot, relPath), 'utf8'));
|
|
299
|
+
} catch {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* @param {readonly import('./types.js').Option[]} questions
|
|
306
|
+
* @param {boolean} yes
|
|
307
|
+
* @returns {Promise<OptionsValue>}
|
|
308
|
+
*/
|
|
309
|
+
async function collectAnswers(questions, yes) {
|
|
310
|
+
/** @type {OptionsValue} */
|
|
311
|
+
const out = {};
|
|
312
|
+
for (const opt of questions) {
|
|
313
|
+
const initial = 'initialValue' in opt ? opt.initialValue : undefined;
|
|
314
|
+
if (yes) {
|
|
315
|
+
out[opt.field] = /** @type {any} */ (initial);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const answer = await prompts({
|
|
319
|
+
type: /** @type {any} */ (opt.type ?? 'text'),
|
|
320
|
+
name: opt.field,
|
|
321
|
+
message: opt.title,
|
|
322
|
+
initial: /** @type {any} */ (initial),
|
|
323
|
+
});
|
|
324
|
+
out[opt.field] = answer[opt.field];
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* @param {string} value
|
|
331
|
+
* @param {number} [indent]
|
|
332
|
+
* @param {number} [offset]
|
|
333
|
+
*/
|
|
334
|
+
function pad16plus(value, indent = 4, offset = 3) {
|
|
335
|
+
return value + ''.padEnd(offset - Math.floor((value.length + indent) / 8), '\t');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** @param {import('./inventory.js').PackageState} state @param {string | null} error */
|
|
339
|
+
function formatState(state, error) {
|
|
340
|
+
if (state === 'available') return gray('[Available]');
|
|
341
|
+
if (state === 'applied') return blue('[Applied]');
|
|
342
|
+
if (state === 'installed-managed') return green('[Installed, managed]');
|
|
343
|
+
if (state === 'installed-unmanaged') return yellow('[Installed, unmanaged]');
|
|
344
|
+
return red(`[Unavailable${error ? `: ${error}` : ''}]`);
|
|
345
|
+
}
|
package/src/tree.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { toFormattedJson } from 'pkgbld/options';
|
|
6
|
+
|
|
7
|
+
import { LOCK_FILE } from './project-lock.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {'CREATE' | 'UPDATE' | 'DELETE'} ChangeAction
|
|
11
|
+
* @typedef {{ path: string, type: ChangeAction, content?: string }} FileChange
|
|
12
|
+
* @typedef {{ content: string | null, action: ChangeAction | null }} Entry
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export class Tree {
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} projectRoot
|
|
18
|
+
* @param {{ onMutation?: (mutation: { path: string, before: string | null, after: string | null }) => void }} [options]
|
|
19
|
+
*/
|
|
20
|
+
constructor(projectRoot, options = {}) {
|
|
21
|
+
this.projectRoot = projectRoot;
|
|
22
|
+
/** @type {Map<string, Entry>} */
|
|
23
|
+
this.entries = new Map();
|
|
24
|
+
/** @type {string | null} */
|
|
25
|
+
this.extensionBase = null;
|
|
26
|
+
this.onMutation = options.onMutation ?? null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Set the base directory used to resolve extension-relative file paths.
|
|
31
|
+
* @param {string | null} dir
|
|
32
|
+
*/
|
|
33
|
+
setExtensionBase(dir) {
|
|
34
|
+
this.extensionBase = dir;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} p
|
|
39
|
+
*/
|
|
40
|
+
_abs(p) {
|
|
41
|
+
const abs = path.isAbsolute(p) ? path.resolve(p) : path.resolve(this.projectRoot, p);
|
|
42
|
+
const rel = path.relative(this.projectRoot, abs);
|
|
43
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
44
|
+
throw new Error(`Path escapes project root: ${p}`);
|
|
45
|
+
}
|
|
46
|
+
return abs;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {string} p
|
|
51
|
+
*/
|
|
52
|
+
_key(p) {
|
|
53
|
+
this._abs(p);
|
|
54
|
+
return path.normalize(p);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} p
|
|
59
|
+
*/
|
|
60
|
+
_loadFromDisk(p) {
|
|
61
|
+
try {
|
|
62
|
+
return readFileSync(this._abs(p), 'utf8');
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {string} p
|
|
70
|
+
* @returns {string | null}
|
|
71
|
+
*/
|
|
72
|
+
read(p) {
|
|
73
|
+
const key = this._key(p);
|
|
74
|
+
const cached = this.entries.get(key);
|
|
75
|
+
if (cached !== undefined) {
|
|
76
|
+
if (cached.action === 'DELETE') return null;
|
|
77
|
+
return cached.content;
|
|
78
|
+
}
|
|
79
|
+
const content = this._loadFromDisk(key);
|
|
80
|
+
this.entries.set(key, { content, action: null });
|
|
81
|
+
return content;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} p
|
|
86
|
+
* @param {string} content
|
|
87
|
+
*/
|
|
88
|
+
write(p, content) {
|
|
89
|
+
const key = this._key(p);
|
|
90
|
+
const before = this.read(key);
|
|
91
|
+
const existing = /** @type {Entry} */ (this.entries.get(key));
|
|
92
|
+
/** @type {ChangeAction} */
|
|
93
|
+
let action;
|
|
94
|
+
if (existing.action === 'CREATE') {
|
|
95
|
+
action = 'CREATE';
|
|
96
|
+
} else if (existing.action === 'DELETE') {
|
|
97
|
+
action = 'UPDATE';
|
|
98
|
+
} else if (existing.content === null) {
|
|
99
|
+
action = 'CREATE';
|
|
100
|
+
} else {
|
|
101
|
+
action = 'UPDATE';
|
|
102
|
+
}
|
|
103
|
+
this.entries.set(key, { content, action });
|
|
104
|
+
this.onMutation?.({ path: key, before, after: content });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {string} p
|
|
109
|
+
*/
|
|
110
|
+
exists(p) {
|
|
111
|
+
return this.read(p) !== null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {string} p
|
|
116
|
+
*/
|
|
117
|
+
delete(p) {
|
|
118
|
+
const key = this._key(p);
|
|
119
|
+
const before = this.read(key);
|
|
120
|
+
if (before === null) return;
|
|
121
|
+
const cached = /** @type {Entry} */ (this.entries.get(key));
|
|
122
|
+
if (cached.action === 'CREATE') this.entries.delete(key);
|
|
123
|
+
else this.entries.set(key, { content: null, action: 'DELETE' });
|
|
124
|
+
this.onMutation?.({ path: key, before, after: null });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {string} oldPath
|
|
129
|
+
* @param {string} newPath
|
|
130
|
+
*/
|
|
131
|
+
rename(oldPath, newPath) {
|
|
132
|
+
const content = this.read(oldPath);
|
|
133
|
+
if (content === null) return;
|
|
134
|
+
this.delete(oldPath);
|
|
135
|
+
this.write(newPath, content);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {string} p
|
|
140
|
+
* @returns {any | null}
|
|
141
|
+
*/
|
|
142
|
+
readJson(p) {
|
|
143
|
+
const content = this.read(p);
|
|
144
|
+
if (content === null) return null;
|
|
145
|
+
return JSON.parse(content);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {string} p
|
|
150
|
+
* @param {(data: any) => any} updater
|
|
151
|
+
*/
|
|
152
|
+
updateJson(p, updater) {
|
|
153
|
+
const current = this.read(p);
|
|
154
|
+
const data = current === null ? {} : JSON.parse(current);
|
|
155
|
+
const updated = updater(data) ?? data;
|
|
156
|
+
const formatted = toFormattedJson(updated, current);
|
|
157
|
+
if (formatted === current) return;
|
|
158
|
+
this.write(p, formatted);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @param {string} name
|
|
163
|
+
* @param {string} version
|
|
164
|
+
* @param {'dependencies' | 'devDependencies'} [type]
|
|
165
|
+
*/
|
|
166
|
+
addDependency(name, version, type = 'dependencies') {
|
|
167
|
+
this.updateJson('package.json', pkg => {
|
|
168
|
+
pkg[type] ??= {};
|
|
169
|
+
pkg[type][name] = version;
|
|
170
|
+
return pkg;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {string} name
|
|
176
|
+
* @param {'dependencies' | 'devDependencies'} [type]
|
|
177
|
+
*/
|
|
178
|
+
removeDependency(name, type = 'dependencies') {
|
|
179
|
+
this.updateJson('package.json', pkg => {
|
|
180
|
+
if (pkg[type] && name in pkg[type]) {
|
|
181
|
+
delete pkg[type][name];
|
|
182
|
+
if (Object.keys(pkg[type]).length === 0) delete pkg[type];
|
|
183
|
+
}
|
|
184
|
+
return pkg;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @param {string} name
|
|
190
|
+
* @param {string} command
|
|
191
|
+
*/
|
|
192
|
+
addScript(name, command) {
|
|
193
|
+
this.updateJson('package.json', pkg => {
|
|
194
|
+
pkg.scripts ??= {};
|
|
195
|
+
pkg.scripts[name] = command;
|
|
196
|
+
return pkg;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param {string} name
|
|
202
|
+
*/
|
|
203
|
+
removeScript(name) {
|
|
204
|
+
this.updateJson('package.json', pkg => {
|
|
205
|
+
if (pkg.scripts && name in pkg.scripts) {
|
|
206
|
+
delete pkg.scripts[name];
|
|
207
|
+
if (Object.keys(pkg.scripts).length === 0) delete pkg.scripts;
|
|
208
|
+
}
|
|
209
|
+
return pkg;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @param {string} relativePath
|
|
215
|
+
*/
|
|
216
|
+
resolveExtensionFile(relativePath) {
|
|
217
|
+
if (!this.extensionBase) {
|
|
218
|
+
throw new Error('Tree.resolveExtensionFile: extension base directory is not set');
|
|
219
|
+
}
|
|
220
|
+
return path.resolve(this.extensionBase, relativePath);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* @returns {FileChange[]}
|
|
225
|
+
*/
|
|
226
|
+
listChanges() {
|
|
227
|
+
/** @type {FileChange[]} */
|
|
228
|
+
const result = [];
|
|
229
|
+
for (const [p, entry] of this.entries) {
|
|
230
|
+
if (entry.action === null) continue;
|
|
231
|
+
/** @type {FileChange} */
|
|
232
|
+
const change = { path: p, type: entry.action };
|
|
233
|
+
if (entry.action !== 'DELETE' && entry.content !== null) {
|
|
234
|
+
change.content = entry.content;
|
|
235
|
+
}
|
|
236
|
+
result.push(change);
|
|
237
|
+
}
|
|
238
|
+
result.sort((a, b) => a.path.localeCompare(b.path));
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** @returns {{ entries: Map<string, Entry>, extensionBase: string | null }} */
|
|
243
|
+
_createCheckpoint() {
|
|
244
|
+
return {
|
|
245
|
+
entries: new Map([...this.entries].map(([key, entry]) => [key, { ...entry }])),
|
|
246
|
+
extensionBase: this.extensionBase,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** @param {{ entries: Map<string, Entry>, extensionBase: string | null }} checkpoint */
|
|
251
|
+
_restoreCheckpoint(checkpoint) {
|
|
252
|
+
this.entries = new Map([...checkpoint.entries].map(([key, entry]) => [key, { ...entry }]));
|
|
253
|
+
this.extensionBase = checkpoint.extensionBase;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** @param {{ lock?: 'include' | 'exclude' | 'only' }} [options] */
|
|
257
|
+
async commit(options = {}) {
|
|
258
|
+
const lockMode = options.lock ?? 'include';
|
|
259
|
+
const changes = this.listChanges().filter(change => {
|
|
260
|
+
if (lockMode === 'exclude') return change.path !== LOCK_FILE;
|
|
261
|
+
if (lockMode === 'only') return change.path === LOCK_FILE;
|
|
262
|
+
return true;
|
|
263
|
+
});
|
|
264
|
+
const orderedChanges = [
|
|
265
|
+
...changes.filter(change => change.path !== LOCK_FILE),
|
|
266
|
+
...changes.filter(change => change.path === LOCK_FILE),
|
|
267
|
+
];
|
|
268
|
+
for (const change of orderedChanges) {
|
|
269
|
+
const abs = this._abs(change.path);
|
|
270
|
+
if (change.type === 'DELETE') {
|
|
271
|
+
try {
|
|
272
|
+
await fs.unlink(abs);
|
|
273
|
+
} catch (/** @type {any} */ err) {
|
|
274
|
+
if (err.code !== 'ENOENT') throw err;
|
|
275
|
+
}
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
279
|
+
await fs.writeFile(abs, change.content ?? '', 'utf8');
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|