@vltpkg/cli-sdk 1.1.1 → 1.3.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.
@@ -4,12 +4,20 @@ import type { CommandFn, CommandUsage } from '../index.ts';
4
4
  import type { ViewOptions, Views } from '../view.ts';
5
5
  import { ViewClass } from '../view.ts';
6
6
  export type CacheMap = Record<string, ReturnType<CacheEntry['toJSON']>>;
7
+ /** `verify`, `prune-store`: entries checked, and why each was removed */
8
+ export type StoreResult = {
9
+ checked: number;
10
+ removed: Record<string, string>;
11
+ /** `verify <spec>`: specs not in the global store */
12
+ missing?: string[];
13
+ };
14
+ export type CacheResult = void | CacheMap | StoreResult;
7
15
  export type CacheSubcommands = keyof (typeof usageDef)['subcommands'];
8
16
  export declare class CacheView extends ViewClass {
9
17
  constructor(options: ViewOptions, conf: LoadedConfig);
10
18
  stdout(...args: unknown[]): void;
11
19
  }
12
- export declare const views: Views<void | CacheMap>;
20
+ export declare const views: Views<CacheResult>;
13
21
  declare const usageDef: {
14
22
  readonly command: "cache";
15
23
  readonly usage: "<command> [flags]";
@@ -37,12 +45,20 @@ declare const usageDef: {
37
45
  };
38
46
  readonly 'delete-before': {
39
47
  readonly usage: "<date>";
40
- readonly description: "Purge all cache items from before a given date. Date can be\n provided in any format that JavaScript can parse.";
48
+ readonly description: "Purge all cache items from before a given date, and\n global store entries written before it. Date can be\n provided in any format that JavaScript can parse.";
41
49
  };
42
50
  readonly 'delete-all': {
43
51
  readonly usage: "";
44
52
  readonly description: "Delete the entire cache folder to make vlt slower.";
45
53
  };
54
+ readonly verify: {
55
+ readonly usage: ["<package-spec> [<package-spec>...]", "--all"];
56
+ readonly description: "Check global store entries against their cached\n tarballs (file list, contents, exec bits) and remove\n any that differ or have no cached tarball, e.g. after\n a file in `node_modules` was edited in place.";
57
+ };
58
+ readonly 'prune-store': {
59
+ readonly usage: "";
60
+ readonly description: "Remove global store entries that no `node_modules`\n folder links to. Entries ever copied from (install\n scripts, `store-linker=copy`, a cache on another\n drive) stay: copies do not show use.";
61
+ };
46
62
  };
47
63
  readonly examples: {
48
64
  readonly 'vlt cache ls https://registry.npmjs.org/typescript': {
@@ -57,9 +73,17 @@ declare const usageDef: {
57
73
  readonly 'vlt cache delete-before 2025-01-01': {
58
74
  readonly description: "Delete all entries created before Jan 1, 2025";
59
75
  };
76
+ readonly 'verify --all': {
77
+ readonly description: "Check every global store entry";
78
+ };
79
+ };
80
+ readonly options: {
81
+ readonly all: {
82
+ readonly description: "With `verify`, check every global store entry.";
83
+ };
60
84
  };
61
85
  };
62
86
  export declare const needsRegistry = true;
63
87
  export declare const usage: CommandUsage;
64
- export declare const command: CommandFn<void | CacheMap>;
88
+ export declare const command: CommandFn<CacheResult>;
65
89
  export {};
@@ -1,7 +1,12 @@
1
1
  import { error } from '@vltpkg/error-cause';
2
2
  import { CacheEntry, assertOk } from '@vltpkg/registry-client';
3
3
  import { Spec } from '@vltpkg/spec';
4
+ import { removeStoreEntry, storeEntryCopied, storeEntryLinked, storeEntryNames, storeEntryTime, verifyStoreEntry, } from '@vltpkg/tar/store-entry';
5
+ import { readStoreIndex } from '@vltpkg/tar/store-index';
6
+ import { integrityHex } from '@vltpkg/types';
7
+ import { readFileSync } from 'node:fs';
4
8
  import { mkdir, rm } from 'node:fs/promises';
9
+ import { resolve } from 'node:path';
5
10
  import prettyBytes from 'pretty-bytes';
6
11
  import { commandUsage } from "../config/usage.js";
7
12
  import { stderr, stdout } from "../output.js";
@@ -55,13 +60,28 @@ const usageDef = {
55
60
  },
56
61
  'delete-before': {
57
62
  usage: '<date>',
58
- description: `Purge all cache items from before a given date. Date can be
63
+ description: `Purge all cache items from before a given date, and
64
+ global store entries written before it. Date can be
59
65
  provided in any format that JavaScript can parse.`,
60
66
  },
61
67
  'delete-all': {
62
68
  usage: '',
63
69
  description: `Delete the entire cache folder to make vlt slower.`,
64
70
  },
71
+ verify: {
72
+ usage: ['<package-spec> [<package-spec>...]', '--all'],
73
+ description: `Check global store entries against their cached
74
+ tarballs (file list, contents, exec bits) and remove
75
+ any that differ or have no cached tarball, e.g. after
76
+ a file in \`node_modules\` was edited in place.`,
77
+ },
78
+ 'prune-store': {
79
+ usage: '',
80
+ description: `Remove global store entries that no \`node_modules\`
81
+ folder links to. Entries ever copied from (install
82
+ scripts, \`store-linker=copy\`, a cache on another
83
+ drive) stay: copies do not show use.`,
84
+ },
65
85
  },
66
86
  examples: {
67
87
  'vlt cache ls https://registry.npmjs.org/typescript': {
@@ -78,6 +98,14 @@ const usageDef = {
78
98
  'vlt cache delete-before 2025-01-01': {
79
99
  description: 'Delete all entries created before Jan 1, 2025',
80
100
  },
101
+ 'verify --all': {
102
+ description: 'Check every global store entry',
103
+ },
104
+ },
105
+ options: {
106
+ all: {
107
+ description: 'With `verify`, check every global store entry.',
108
+ },
81
109
  },
82
110
  };
83
111
  export const needsRegistry = true;
@@ -99,6 +127,10 @@ export const command = async (conf) => {
99
127
  return deleteBefore(conf, args, view);
100
128
  case 'delete-all':
101
129
  return deleteAll(conf, args, view);
130
+ case 'verify':
131
+ return verify(conf, args, view);
132
+ case 'prune-store':
133
+ return pruneStore(conf, args, view);
102
134
  default: {
103
135
  throw error('Unrecognized cache command', {
104
136
  code: 'EUSAGE',
@@ -212,7 +244,18 @@ const deleteBefore = async (conf, args, view) => {
212
244
  found: before,
213
245
  });
214
246
  }
215
- return deleteEntries(conf, [], entry => !!entry.date && entry.date < before, view);
247
+ const map = await deleteEntries(conf, [], entry => !!entry.date && entry.date < before, view);
248
+ const { storeRoot } = conf.options;
249
+ let count = 0;
250
+ for (const hex of storeEntryNames(storeRoot)) {
251
+ const entry = resolve(storeRoot, hex);
252
+ if (storeEntryTime(entry) < before.getTime()) {
253
+ removeStoreEntry(entry);
254
+ count++;
255
+ }
256
+ }
257
+ view?.stdout(`Removed ${entries(count)}`);
258
+ return map;
216
259
  };
217
260
  const deleteKeys = async (conf, keys, view) => {
218
261
  if (!keys.length) {
@@ -225,9 +268,91 @@ const deleteKeys = async (conf, keys, view) => {
225
268
  const deleteAll = async (conf, _, view) => {
226
269
  const { cache } = await conf.options.packageInfo.getRegistryClient();
227
270
  await rm(cache.path(), { recursive: true, force: true });
271
+ // every global store layout version, and a store root set elsewhere
272
+ const rf = { recursive: true, force: true };
273
+ await rm(resolve(conf.options.cache, 'store'), rf);
274
+ if (cache.store)
275
+ await rm(cache.store, rf);
228
276
  await mkdir(cache.path(), { recursive: true });
229
277
  view?.stdout('Deleted all cache entries.');
230
278
  };
279
+ const entries = (n) => `${n} global store entr${n === 1 ? 'y' : 'ies'}`;
280
+ /** `name@version` from the entry's sidecar, else `hex` */
281
+ const entryLabel = (entry, hex) => {
282
+ const i = readStoreIndex(entry);
283
+ return i?.name && i.version ? `${i.name}@${i.version}` : hex;
284
+ };
285
+ const verify = async (conf, specs, view) => {
286
+ const all = !!conf.values.all;
287
+ if (!specs.length && !all) {
288
+ throw error('Must provide specs to verify, or --all', {
289
+ code: 'EUSAGE',
290
+ });
291
+ }
292
+ const { packageInfo, storeRoot } = conf.options;
293
+ const cachePath = (await packageInfo.getRegistryClient()).cache.path();
294
+ const present = new Set(storeEntryNames(storeRoot));
295
+ const checks = [];
296
+ const missing = [];
297
+ if (all) {
298
+ for (const hex of present)
299
+ checks.push([hex, hex]);
300
+ }
301
+ else {
302
+ for (const spec of specs) {
303
+ const { integrity } = await packageInfo.resolve(Spec.parseArgs(spec, conf.options));
304
+ const hex = integrityHex(integrity);
305
+ if (hex && present.has(hex))
306
+ checks.push([spec, hex]);
307
+ else {
308
+ missing.push(spec);
309
+ view?.stdout('Not in the global store:', spec);
310
+ }
311
+ }
312
+ }
313
+ const removed = {};
314
+ for (const [key, hex] of checks) {
315
+ const entry = resolve(storeRoot, hex);
316
+ let tarball;
317
+ try {
318
+ tarball = CacheEntry.decode(readFileSync(resolve(cachePath, hex))).buffer();
319
+ }
320
+ catch { }
321
+ const reason = tarball ? verifyStoreEntry(entry, tarball) : 'no cached tarball';
322
+ if (reason) {
323
+ // read before the sidecar goes
324
+ const label = all ? entryLabel(entry, hex) : key;
325
+ removeStoreEntry(entry);
326
+ removed[key] = reason;
327
+ view?.stdout('-', label, reason);
328
+ }
329
+ }
330
+ const n = Object.keys(removed).length;
331
+ view?.stdout(`Checked ${entries(checks.length)}, removed ${n}`);
332
+ return {
333
+ checked: checks.length,
334
+ removed,
335
+ ...(!all && { missing }),
336
+ };
337
+ };
338
+ const pruneStore = async (conf, _, view) => {
339
+ const { storeRoot } = conf.options;
340
+ const names = storeEntryNames(storeRoot);
341
+ const removed = {};
342
+ for (const hex of names) {
343
+ const entry = resolve(storeRoot, hex);
344
+ const index = readStoreIndex(entry);
345
+ if (storeEntryCopied(entry, index) ||
346
+ storeEntryLinked(entry, index)) {
347
+ continue;
348
+ }
349
+ removeStoreEntry(entry);
350
+ removed[hex] = 'unused';
351
+ }
352
+ const n = Object.keys(removed).length;
353
+ view?.stdout(`Removed ${n} of ${entries(names.length)}`);
354
+ return { checked: names.length, removed };
355
+ };
231
356
  const add = async (conf, specs, view) => {
232
357
  if (!specs.length) {
233
358
  throw error('Must provide specs to add to the cache', {
@@ -4,7 +4,7 @@ import { minimatch } from 'minimatch';
4
4
  import { init } from '@vltpkg/init';
5
5
  import { install } from '@vltpkg/graph';
6
6
  import { load, save } from '@vltpkg/vlt-json';
7
- import { assertWSConfig, asWSConfig } from '@vltpkg/workspaces';
7
+ import { assertWSConfig, resolveWSConfig, splitNegatedPatterns, } from '@vltpkg/workspaces';
8
8
  import { commandUsage } from "../config/usage.js";
9
9
  export const usage = () => commandUsage({
10
10
  command: 'init',
@@ -55,8 +55,18 @@ export const command = async (conf) => {
55
55
  : ':not(*)';
56
56
  /* c8 ignore stop */
57
57
  if (conf.values.workspace?.length) {
58
- const workspacesConfig = load('workspaces', assertWSConfig);
59
- const parsedWSConfig = asWSConfig(workspacesConfig ?? {});
58
+ // the *effective* config, which may come from the root package.json
59
+ // rather than vlt.json
60
+ const { config: parsedWSConfig, source } = resolveWSConfig(conf.options.projectRoot, conf.options.packageJson);
61
+ // what gets written back to vlt.json. The raw vlt.json value is
62
+ // reused as-is so its existing shape is preserved. When the
63
+ // definitions live in package.json instead, seed vlt.json with them
64
+ // first -- otherwise adding a `workspaces` field here would shadow
65
+ // every workspace package.json already declares.
66
+ const workspacesConfig = load('workspaces', assertWSConfig) ??
67
+ (source === 'package.json' ?
68
+ Object.values(parsedWSConfig).flat()
69
+ : undefined);
60
70
  const results = [];
61
71
  const addToConfig = [];
62
72
  // create a new package.json file for every workspace
@@ -69,8 +79,10 @@ export const command = async (conf) => {
69
79
  // run the initialization script and collect results
70
80
  results.push(await init({ cwd }));
71
81
  // Check if this workspace path is covered by existing workspace patterns
72
- const isMatched = Object.values(parsedWSConfig).some((patterns) => {
73
- return patterns.some(pattern => minimatch(workspace, pattern));
82
+ const isMatched = Object.values(parsedWSConfig).some((group) => {
83
+ const { patterns, ignore } = splitNegatedPatterns(group);
84
+ return (patterns.some(pattern => minimatch(workspace, pattern)) &&
85
+ !ignore.some(pattern => minimatch(workspace, pattern)));
74
86
  });
75
87
  // When a workspace is not matched we track it for insertion later
76
88
  if (!isMatched) {
@@ -38,7 +38,9 @@ const App = ({ trailer }) => {
38
38
  if (state === 'start') {
39
39
  setRequests(p => p + 1);
40
40
  }
41
- else if (state === 'cache' || state === 'stale') {
41
+ else if (state === 'cache' ||
42
+ state === 'stale' ||
43
+ state === 'store') {
42
44
  setCacheHit(p => p + 1);
43
45
  }
44
46
  };
@@ -4,7 +4,8 @@ import { run } from '@vltpkg/run';
4
4
  import { commandUsage } from "../config/usage.js";
5
5
  import { packTarball } from "../pack-tarball.js";
6
6
  import assert from 'node:assert';
7
- import { dirname, resolve } from 'node:path';
7
+ import { readFileSync } from 'node:fs';
8
+ import { dirname, join, resolve } from 'node:path';
8
9
  import prettyBytes from 'pretty-bytes';
9
10
  import { actual } from '@vltpkg/graph';
10
11
  import { Query } from '@vltpkg/query';
@@ -156,6 +157,28 @@ const getPublishConfig = (manifest) => {
156
157
  }
157
158
  return undefined;
158
159
  };
160
+ /** Of several readmes at the package root, npm prefers the markdown one. */
161
+ const README_MARKDOWN_EXTENSION = /\.(?:md|markdown|mdown|mdwn|mkd|mkdn)$/i;
162
+ /**
163
+ * The readme fields npm's CLI puts in a publish payload, read out of the
164
+ * files that were just packed. Registries serve this as the package's front
165
+ * page, so a payload without it leaves the page blank even though the readme
166
+ * is sitting inside the tarball.
167
+ *
168
+ * npm takes the readme at the package root and prefers the markdown one; a
169
+ * package without a readme gets no fields at all.
170
+ */
171
+ const readPublishReadme = (packDir, files) => {
172
+ const readmes = files.filter(file => /^readme(\.[^.]*)?$/i.test(file));
173
+ const readmeFilename = readmes.find(file => README_MARKDOWN_EXTENSION.test(file)) ??
174
+ readmes[0];
175
+ if (!readmeFilename)
176
+ return undefined;
177
+ return {
178
+ readme: readFileSync(join(packDir, readmeFilename), 'utf8'),
179
+ readmeFilename,
180
+ };
181
+ };
159
182
  const commandSingle = async (location, conf) => {
160
183
  const manifestPath = conf.options.packageJson.find(location);
161
184
  assert(manifestPath, 'No package.json found');
@@ -196,7 +219,7 @@ const commandSingle = async (location, conf) => {
196
219
  const updatedManifest = conf.options.packageJson.read(manifestDir, {
197
220
  reload: true,
198
221
  });
199
- const { name, version, tarballName, tarballData, unpackedSize, files, integrity, shasum, resolvedManifest, } = await packTarball(updatedManifest, manifestDir, conf);
222
+ const { name, version, tarballName, tarballData, unpackedSize, files, integrity, shasum, resolvedManifest, packDir, } = await packTarball(updatedManifest, manifestDir, conf);
200
223
  await run({
201
224
  ...runOptions,
202
225
  arg0: 'postpack',
@@ -215,6 +238,7 @@ const commandSingle = async (location, conf) => {
215
238
  versions: {
216
239
  [version]: {
217
240
  ...resolvedManifest,
241
+ ...readPublishReadme(packDir, files),
218
242
  _id: `${name}@${version}`,
219
243
  _nodeVersion: process.versions.node,
220
244
  dist: {
@@ -301,7 +301,7 @@ export const command = async (conf) => {
301
301
  const pic = new PackageInfoClient(conf.options);
302
302
  // Fetch the full packument (needs time, maintainers) and resolved manifest
303
303
  const [packument, resolvedManifest] = await Promise.all([
304
- pic.packument(spec),
304
+ pic.packument(spec, { full: true }),
305
305
  pic.manifest(spec),
306
306
  ]);
307
307
  const manifest = resolvedManifest;
@@ -73,6 +73,8 @@ export declare const getCommand: (s?: string) => Commands[keyof Commands] | unde
73
73
  export declare const recordFields: readonly ["git-hosts", "registries", "git-host-archives", "scoped-registries", "jsr-registries"];
74
74
  export type RecordField = (typeof recordFields)[number];
75
75
  export declare const isRecordField: (s: string) => s is RecordField;
76
+ /** Valid `store-linker` values. */
77
+ export declare const storeLinkers: readonly ["auto", "hardlink", "copy", "unpack"];
76
78
  export declare const definition: import("jackspeak").Jack<{
77
79
  color: {
78
80
  short: string;
@@ -133,6 +135,12 @@ export declare const definition: import("jackspeak").Jack<{
133
135
  description: string;
134
136
  default: string;
135
137
  };
138
+ 'store-linker': {
139
+ hint: string;
140
+ description: string;
141
+ validOptions: readonly ["auto", "hardlink", "copy", "unpack"];
142
+ default: string;
143
+ };
136
144
  tag: {
137
145
  description: string;
138
146
  default: string;
@@ -159,6 +167,7 @@ export declare const definition: import("jackspeak").Jack<{
159
167
  };
160
168
  } & {
161
169
  cache: import("jackspeak").ConfigOption<"string", false, readonly string[] | undefined>;
170
+ 'store-linker': import("jackspeak").ConfigOption<"string", false, readonly string[] | undefined>;
162
171
  tag: import("jackspeak").ConfigOption<"string", false, readonly string[] | undefined>;
163
172
  before: import("jackspeak").ConfigOption<"string", false, readonly string[] | undefined>;
164
173
  os: import("jackspeak").ConfigOption<"string", false, readonly string[] | undefined>;
@@ -397,12 +406,17 @@ export declare const definition: import("jackspeak").Jack<{
397
406
  'lockfile-only': {
398
407
  description: string;
399
408
  };
409
+ 'brotli-tarballs': {
410
+ description: string;
411
+ default: true;
412
+ };
400
413
  } & {
401
414
  'dry-run': import("jackspeak").ConfigOption<"boolean", false, undefined>;
402
415
  force: import("jackspeak").ConfigOption<"boolean", false, undefined>;
403
416
  'expect-lockfile': import("jackspeak").ConfigOption<"boolean", false, undefined>;
404
417
  'frozen-lockfile': import("jackspeak").ConfigOption<"boolean", false, undefined>;
405
418
  'lockfile-only': import("jackspeak").ConfigOption<"boolean", false, undefined>;
419
+ 'brotli-tarballs': import("jackspeak").ConfigOption<"boolean", false, undefined>;
406
420
  } & {
407
421
  'allow-scripts': {
408
422
  hint: string;
@@ -109,6 +109,13 @@ export const recordFields = [
109
109
  'jsr-registries',
110
110
  ];
111
111
  export const isRecordField = (s) => recordFields.includes(s);
112
+ /** Valid `store-linker` values. */
113
+ export const storeLinkers = [
114
+ 'auto',
115
+ 'hardlink',
116
+ 'copy',
117
+ 'unpack',
118
+ ];
112
119
  const j = jack({
113
120
  envPrefix: 'VLT',
114
121
  allowPositionals: true,
@@ -308,6 +315,32 @@ export const definition = j
308
315
  `,
309
316
  default: cacheDir,
310
317
  },
318
+ 'store-linker': {
319
+ hint: 'auto | hardlink | copy | unpack',
320
+ description: `How packages are placed in \`node_modules\`. Every
321
+ linker but \`unpack\` fills a global store under
322
+ \`cache\` in the background, then places packages
323
+ from it.
324
+
325
+ - auto: Default. On Linux, hardlink from the global
326
+ store, copying any file that cannot be linked.
327
+ Elsewhere, same as \`unpack\`: a hardlink is the
328
+ slowest way to place a file on APFS, so macOS and
329
+ Windows unpack by default.
330
+ - hardlink: Hardlink from the global store on every
331
+ platform.
332
+ - copy: Copy from the global store. Use this when
333
+ editing files in \`node_modules\` in place, since
334
+ a hardlink shares its content with every project.
335
+ - unpack: Unpack each package tarball, skipping the
336
+ global store.
337
+
338
+ Packages with install scripts are always copied.
339
+ An invalid \`VLT_STORE_LINKER\` warns and uses
340
+ \`unpack\`.`,
341
+ validOptions: storeLinkers,
342
+ default: 'auto',
343
+ },
311
344
  tag: {
312
345
  description: `Default \`dist-tag\` to install or publish`,
313
346
  default: 'latest',
@@ -718,6 +751,19 @@ export const definition = j
718
751
  'lockfile-only': {
719
752
  description: 'Only update the lockfile (vlt-lock.json) and package.json files, skip all node_modules operations including package extraction and filesystem changes.',
720
753
  },
754
+ 'brotli-tarballs': {
755
+ description: `Resolve to the Brotli (\`.tar.br\`) tarball when the
756
+ registry advertises one for a version, transferring far
757
+ less than the gzip \`.tgz\`.
758
+
759
+ The \`.tar.br\` is its own artifact and hashes
760
+ differently, so a version installed this way records that
761
+ hash in \`vlt-lock.json\`. An existing lockfile keeps
762
+ whichever artifact it already pinned; set
763
+ \`--no-brotli-tarballs\` before resolving to pin the
764
+ \`.tgz\` instead.`,
765
+ default: true,
766
+ },
721
767
  })
722
768
  .opt({
723
769
  'allow-scripts': {
@@ -81,6 +81,8 @@ export type ConfigOptions = ConfigOptionsNoExtras & Pick<SpecOptions, 'catalog'
81
81
  packageJson: PackageJson;
82
82
  scurry: PathScurry;
83
83
  projectRoot: string;
84
+ /** global store root, under `cache` */
85
+ storeRoot: string;
84
86
  monorepo?: Monorepo;
85
87
  packageInfo: PackageInfoClient;
86
88
  };
@@ -23,6 +23,7 @@
23
23
  import { error } from '@vltpkg/error-cause';
24
24
  import { PackageInfoClient } from '@vltpkg/package-info';
25
25
  import { PackageJson } from '@vltpkg/package-json';
26
+ import { storeRoot } from '@vltpkg/registry-client/store-root';
26
27
  import { resetCaches } from '@vltpkg/dep-id';
27
28
  import { getOptions } from '@vltpkg/spec';
28
29
  import { assertRecordStringString, assertRecordStringT, isObject, isRecordStringString, } from '@vltpkg/types';
@@ -31,7 +32,7 @@ import { Monorepo } from '@vltpkg/workspaces';
31
32
  import { readFile, rm, writeFile } from 'node:fs/promises';
32
33
  import { dirname } from 'node:path';
33
34
  import { PathScurry } from 'path-scurry';
34
- import { commands, definition, defaultValues, getCommand, isRecordField, recordFields, } from "./definition.js";
35
+ import { commands, definition, defaultValues, getCommand, isRecordField, recordFields, storeLinkers, } from "./definition.js";
35
36
  import { merge } from "./merge.js";
36
37
  import { cloneLayer, mergeLayers } from "./merge-layers.js";
37
38
  export { commands, definition, isRecordField, recordFields, };
@@ -92,6 +93,7 @@ export const recordsToPairs = (obj) => {
92
93
  k === 'packageJson' ||
93
94
  k === 'monorepo' ||
94
95
  k === 'projectRoot' ||
96
+ k === 'storeRoot' ||
95
97
  k === 'packageInfo'))
96
98
  .map(([k, v]) => [
97
99
  k,
@@ -170,6 +172,7 @@ export class Config {
170
172
  const asRecords = pairsToRecords(this.parse().values);
171
173
  const extras = {
172
174
  projectRoot: this.projectRoot,
175
+ storeRoot: storeRoot(asRecords.cache),
173
176
  scurry,
174
177
  packageJson,
175
178
  monorepo: Monorepo.maybeLoad(this.projectRoot, {
@@ -250,6 +253,12 @@ export class Config {
250
253
  return this;
251
254
  // Store the original args for potential reload
252
255
  this.#originalArgs = [...args];
256
+ // an invalid env linker is treated as unpack
257
+ const badLinker = process.env.VLT_STORE_LINKER;
258
+ const fixLinker = badLinker !== undefined &&
259
+ !storeLinkers.includes(badLinker);
260
+ if (fixLinker)
261
+ process.env.VLT_STORE_LINKER = 'unpack';
253
262
  const envKeys = Object.keys(defaultValues).filter(k => process.env[envKey(k)] !== undefined);
254
263
  const parent = parentEnv();
255
264
  // file layer values, before env replaces them
@@ -267,6 +276,15 @@ export class Config {
267
276
  this.jack.setConfigValues(cmdPairs);
268
277
  // ok, applied cmd-specific defaults, do rest of the parse
269
278
  this.jack.applyDefaults(p);
279
+ // `auto` only hardlinks from the global store on linux: on APFS a
280
+ // hardlink is the slowest way to place a file, so darwin and win32
281
+ // unpack by default. the env layer is rewritten too, so `explicit`
282
+ // records the resolved value.
283
+ if (p.values['store-linker'] === 'auto' &&
284
+ process.platform !== 'linux') {
285
+ p.values['store-linker'] = 'unpack';
286
+ envValues['store-linker'] = 'unpack';
287
+ }
270
288
  // what was set on the cli or env for this run. record fields hold
271
289
  // only those pairs, env then cli, so later ones win per key.
272
290
  const values = p.values;
@@ -318,6 +336,11 @@ export class Config {
318
336
  if (p.values.verbose && p.values.loglevel === 'info') {
319
337
  p.values.loglevel = 'verbose';
320
338
  }
339
+ const { loglevel } = p.values;
340
+ if (fixLinker && loglevel !== 'silent' && loglevel !== 'error') {
341
+ // eslint-disable-next-line no-console
342
+ console.error(`Warning: invalid VLT_STORE_LINKER ${JSON.stringify(badLinker)}, using ${p.values['store-linker']}`);
343
+ }
321
344
  /* c8 ignore start - unpossible */
322
345
  if (!isParsed(this))
323
346
  throw error('failed to parse config');
@@ -63,7 +63,7 @@ const commandHelp = {
63
63
  defaultOrder: 4,
64
64
  },
65
65
  cache: {
66
- args: '[add|ls|info|clean|delete|delete-before|delete-all]',
66
+ args: '[add|ls|info|clean|delete|delete-before|delete-all|verify|prune-store]',
67
67
  desc: 'Manage the package cache',
68
68
  },
69
69
  ci: {
package/dist/index.js CHANGED
@@ -57,7 +57,7 @@ const run = async () => {
57
57
  if (vlt.command !== 'init' &&
58
58
  (vlt.get('workspace') || vlt.get('workspace-group')) &&
59
59
  ![...(monorepo?.values() ?? [])].length) {
60
- stderr(`Error: No matching workspaces found. Make sure the vlt.json config contains the correct workspaces.`);
60
+ stderr(`Error: No matching workspaces found. Make sure the vlt.json config, or the "workspaces" field of the root package.json, contains the correct workspaces.`);
61
61
  if (vlt.get('workspace')) {
62
62
  stderr(indent(`Workspace: ${format(vlt.get('workspace'))}`));
63
63
  }
@@ -12,6 +12,8 @@ export type PackTarballResult = {
12
12
  shasum?: string;
13
13
  /** The manifest used for packing (may differ from input when publishConfig.directory is set). */
14
14
  resolvedManifest: NormalizedManifest;
15
+ /** The directory that was packed (differs from input when publishConfig.directory is set). */
16
+ packDir: string;
15
17
  };
16
18
  /**
17
19
  * Build the tar filter function for packing.
@@ -282,6 +282,7 @@ export const packTarball = async (manifest, dir, config) => {
282
282
  integrity,
283
283
  shasum,
284
284
  resolvedManifest: processedManifest,
285
+ packDir,
285
286
  };
286
287
  }
287
288
  finally {
package/dist/print-err.js CHANGED
@@ -16,7 +16,9 @@ const formatURL = (v, format) => v instanceof URL ? v.toString() : /* c8 ignore
16
16
  // True when `v` overrides the default `Object.prototype.toString`, i.e.
17
17
  // stringifying it yields something meaningful (like a `Spec` → `next@*`)
18
18
  // rather than `[object Object]`.
19
- const hasCustomToString = (v) => isObject(v) && v.toString !== Object.prototype.toString;
19
+ const hasCustomToString = (v) => typeof v === 'object' &&
20
+ v !== null &&
21
+ v.toString !== Object.prototype.toString;
20
22
  // Render a value that has a meaningful custom `toString()` (such as a
21
23
  // `Spec`, e.g. `next@*`) as a concise string rather than dumping its
22
24
  // entire internal structure via util.inspect. Falls back to `format`
@@ -161,8 +163,10 @@ const printCode = (err, usage, stderr, format) => {
161
163
  if (from) {
162
164
  stderr(indent(`From: ${format(from)}`));
163
165
  }
164
- if (response) {
165
- stderr(indent(`Response: ${format(response)}`));
166
+ // the full response is in the error log; here only the status
167
+ const status = response?.statusCode;
168
+ if (status) {
169
+ stderr(indent(`Status: ${format(status)}`));
166
170
  }
167
171
  return { file: true };
168
172
  }
@@ -15,6 +15,7 @@ const tagStyle = (state, statusCode) => {
15
15
  switch (state) {
16
16
  case 'cache':
17
17
  case 'stale':
18
+ case 'store':
18
19
  case '304':
19
20
  return 'green';
20
21
  case 'start':
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vltpkg/cli-sdk",
3
3
  "description": "The source for the vlt CLI",
4
- "version": "1.1.1",
4
+ "version": "1.3.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/vltpkg/vltpkg.git",
@@ -14,30 +14,31 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@resvg/resvg-wasm": "^2.6.2",
17
- "@vltpkg/config": "1.1.1",
18
- "@vltpkg/dep-id": "1.1.1",
19
- "@vltpkg/dot-prop": "1.1.1",
20
- "@vltpkg/error-cause": "1.1.1",
21
- "@vltpkg/git": "1.1.1",
22
- "@vltpkg/graph": "1.1.1",
23
- "@vltpkg/graph-run": "1.1.1",
24
- "@vltpkg/init": "1.1.1",
25
- "@vltpkg/output": "1.1.1",
26
- "@vltpkg/package-info": "1.1.1",
27
- "@vltpkg/package-json": "1.1.1",
28
- "@vltpkg/promise-spawn": "1.1.1",
29
- "@vltpkg/query": "1.1.1",
30
- "@vltpkg/registry-client": "1.1.1",
31
- "@vltpkg/rollback-remove": "1.1.1",
32
- "@vltpkg/run": "1.1.1",
33
- "@vltpkg/security-archive": "1.1.1",
34
- "@vltpkg/spec": "1.1.1",
35
- "@vltpkg/types": "1.1.1",
36
- "@vltpkg/url-open": "1.1.1",
37
- "@vltpkg/vlt-json": "1.1.1",
38
- "@vltpkg/vlx": "1.1.1",
39
- "@vltpkg/workspaces": "1.1.1",
40
- "@vltpkg/xdg": "1.1.1",
17
+ "@vltpkg/config": "1.3.0",
18
+ "@vltpkg/dep-id": "1.3.0",
19
+ "@vltpkg/dot-prop": "1.3.0",
20
+ "@vltpkg/error-cause": "1.3.0",
21
+ "@vltpkg/git": "1.3.0",
22
+ "@vltpkg/graph": "1.3.0",
23
+ "@vltpkg/graph-run": "1.3.0",
24
+ "@vltpkg/init": "1.3.0",
25
+ "@vltpkg/output": "1.3.0",
26
+ "@vltpkg/package-info": "1.3.0",
27
+ "@vltpkg/package-json": "1.3.0",
28
+ "@vltpkg/promise-spawn": "1.3.0",
29
+ "@vltpkg/query": "1.3.0",
30
+ "@vltpkg/registry-client": "1.3.0",
31
+ "@vltpkg/rollback-remove": "1.3.0",
32
+ "@vltpkg/run": "1.3.0",
33
+ "@vltpkg/security-archive": "1.3.0",
34
+ "@vltpkg/spec": "1.3.0",
35
+ "@vltpkg/tar": "1.3.0",
36
+ "@vltpkg/types": "1.3.0",
37
+ "@vltpkg/url-open": "1.3.0",
38
+ "@vltpkg/vlt-json": "1.3.0",
39
+ "@vltpkg/vlx": "1.3.0",
40
+ "@vltpkg/workspaces": "1.3.0",
41
+ "@vltpkg/xdg": "1.3.0",
41
42
  "ansi-to-pre": "^1.0.6",
42
43
  "beautiful-mermaid": "^1.1.3",
43
44
  "chalk": "^5.6.2",
@@ -62,11 +63,11 @@
62
63
  "devDependencies": {
63
64
  "@eslint/js": "^9.39.1",
64
65
  "@types/node": "^22.19.2",
65
- "@types/react": "^18.3.27",
66
+ "@types/react": "^19",
66
67
  "@types/ssri": "^7.1.5",
67
68
  "eslint": "^9.39.1",
68
69
  "prettier": "^3.7.4",
69
- "tap": "^21.5.0",
70
+ "tap": "^21.8.0",
70
71
  "typedoc": "~0.27.9",
71
72
  "typescript": "5.7.3",
72
73
  "typescript-eslint": "^8.49.0"