@vltpkg/cli-sdk 1.0.5 → 1.0.7

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.
@@ -127,7 +127,7 @@ export const command = async (conf) => {
127
127
  cause: asError(err),
128
128
  });
129
129
  }
130
- if (response.statusCode !== 200 && response.statusCode !== 201) {
130
+ if (response.statusCode < 200 || response.statusCode >= 300) {
131
131
  throw error('failed to update deprecation status', {
132
132
  url: packageUrl,
133
133
  response,
@@ -1,4 +1,5 @@
1
1
  import { RegistryClient } from '@vltpkg/registry-client';
2
+ import { configWriteTarget } from "../config/index.js";
2
3
  import { commandUsage } from "../config/usage.js";
3
4
  import { missingRegistryError } from "../require-registry.js";
4
5
  export const usage = () => commandUsage({
@@ -10,7 +11,9 @@ export const usage = () => commandUsage({
10
11
  There is no default registry, so a registry must either
11
12
  already be configured or be provided with
12
13
  \`--registry=<url>\`. On success the registry is written to
13
- the project's \`vlt.json\`.`,
14
+ the project's \`vlt.json\`, so that it applies to this
15
+ project only. Pass \`--config=user\` to configure it for
16
+ every project instead.`,
14
17
  options: {
15
18
  registry: {
16
19
  value: '<url>',
@@ -20,6 +23,11 @@ export const usage = () => commandUsage({
20
23
  value: '<name>',
21
24
  description: 'Identity namespace used to store auth tokens.',
22
25
  },
26
+ config: {
27
+ value: '<user | project>',
28
+ description: 'Which config file to write the registry to. Defaults to ' +
29
+ '`project`.',
30
+ },
23
31
  },
24
32
  });
25
33
  export const command = async (conf) => {
@@ -31,5 +39,7 @@ export const command = async (conf) => {
31
39
  const rc = new RegistryClient(conf.options);
32
40
  await rc.login(registry);
33
41
  // persist the registry so subsequent commands are configured
34
- await conf.addConfigToFile('project', { registry });
42
+ await conf.addConfigToFile(configWriteTarget(conf, 'project'), {
43
+ registry,
44
+ });
35
45
  };
@@ -13,6 +13,7 @@ import { createHostContextsMap } from "../query-host-contexts.js";
13
13
  import { minimatch } from 'minimatch';
14
14
  import { resolveRegistry } from "../require-registry.js";
15
15
  import { registryErrorMessage } from "../registry-error-message.js";
16
+ import { stderr } from "../output.js";
16
17
  export const needsRegistry = true;
17
18
  export const usage = () => commandUsage({
18
19
  command: 'publish',
@@ -264,7 +265,10 @@ const commandSingle = async (location, conf) => {
264
265
  cause: asError(err),
265
266
  });
266
267
  }
267
- if (response.statusCode !== 200 && response.statusCode !== 201) {
268
+ // Any 2xx response is a successful publish. The npm registry may
269
+ // respond with a 202 Accepted when processing is deferred (e.g. for
270
+ // publish-time malware scanning).
271
+ if (response.statusCode < 200 || response.statusCode >= 300) {
268
272
  let advice = '';
269
273
  if (response.statusCode === 409) {
270
274
  advice = `\n⚠️ ${name}@${version} already exists in the registry. Bump the version and try again.`;
@@ -278,6 +282,12 @@ const commandSingle = async (location, conf) => {
278
282
  response,
279
283
  });
280
284
  }
285
+ // On deferred publishes the registry explains the delay in an
286
+ // npm-notice header, e.g. "Your package is being processed and may
287
+ // take a few minutes to become available."
288
+ const notice = response.getHeaderString('npm-notice');
289
+ if (notice)
290
+ stderr(`⚠️ ${notice}`);
281
291
  }
282
292
  await run({
283
293
  ...runOptions,
@@ -17,6 +17,11 @@ export type SetupResult = {
17
17
  which: 'user' | 'project';
18
18
  /** the alias -> url map that was staged into config */
19
19
  registries: Record<string, string>;
20
+ /**
21
+ * set when the config we wrote to is outranked for registry selection by
22
+ * the project's `vlt.json`, so the result won't take effect here.
23
+ */
24
+ shadowedByProject?: boolean;
20
25
  };
21
26
  export declare const views: {
22
27
  readonly human: (result: SetupResult) => string;
@@ -2,6 +2,8 @@ import { error } from '@vltpkg/error-cause';
2
2
  import { RegistryClient } from '@vltpkg/registry-client';
3
3
  import { defaultRegistries } from '@vltpkg/spec';
4
4
  import { createInterface } from 'node:readline/promises';
5
+ import { configWriteTarget } from "../config/index.js";
6
+ import { registrySelectionFields } from "../config/merge-layers.js";
5
7
  import { commandUsage } from "../config/usage.js";
6
8
  import { stdout } from "../output.js";
7
9
  /** Where users sign up for / log into a vlt.io account. */
@@ -13,17 +15,6 @@ export const VLT_REGISTRY_BASE = 'https://registry.vlt.io';
13
15
  * `npm` is the {@link https://docs.vlt.sh | default bare-spec alias}.
14
16
  */
15
17
  export const accountRegistries = ['npm', 'main'];
16
- /**
17
- * What each account registry is, in the order {@link accountRegistries}
18
- * is authenticated. Every registry has its own credentials, so the
19
- * web-login flow opens the browser once per registry -- naming what is
20
- * being authenticated keeps the second prompt from reading like a repeat
21
- * of the first.
22
- */
23
- const accountRegistryAuthPrompts = {
24
- npm: `First, let's authenticate your public npm registry mirror`,
25
- main: `Now let's authenticate your private registry`,
26
- };
27
18
  /** Build the per-account registry URL for a given registry name. */
28
19
  export const accountRegistryURL = (account, name) => `${VLT_REGISTRY_BASE}/${encodeURIComponent(account)}/${name}/`;
29
20
  /** Ensure a registry URL ends with a single trailing slash. */
@@ -38,6 +29,9 @@ export const views = {
38
29
  for (const [name, url] of Object.entries(result.registries)) {
39
30
  lines.push(` ${name} -> ${url}`);
40
31
  }
32
+ if (result.shadowedByProject) {
33
+ lines.push('', `Note: this project's vlt.json configures its own registries, and`, 'project config takes precedence. Re-run with `--config=project`', 'to configure this project instead.');
34
+ }
41
35
  lines.push('', 'You can now install packages, e.g.:', ' vlt install <pkg>');
42
36
  return lines.join('\n');
43
37
  },
@@ -71,7 +65,7 @@ export const usage = () => commandUsage({
71
65
  });
72
66
  export const command = async (conf) => {
73
67
  const yes = !!conf.get('yes');
74
- const which = conf.get('config') === 'project' ? 'project' : 'user';
68
+ const which = configWriteTarget(conf, 'user');
75
69
  let rl;
76
70
  const ask = async (question) => {
77
71
  rl ??= createInterface(process.stdin, process.stdout);
@@ -96,11 +90,19 @@ export const command = async (conf) => {
96
90
  for (const name of accountRegistries) {
97
91
  registries[name] = accountRegistryURL(account, name);
98
92
  }
99
- // 3. merge any user-supplied registry aliases (skip built-in defaults)
93
+ // 3. merge in registry aliases supplied for this invocation. aliases
94
+ // that came from a config file are left where they are: copying them
95
+ // would turn a project-local alias into a global one (or vice versa).
100
96
  const builtinRegistries = defaultRegistries;
97
+ const fileRegistries = {
98
+ ...conf.layers.user?.registries,
99
+ ...conf.layers.project?.registries,
100
+ };
101
101
  for (const [name, url] of Object.entries(conf.options.registries)) {
102
102
  if (builtinRegistries[name] === url)
103
103
  continue;
104
+ if (fileRegistries[name] === url)
105
+ continue;
104
106
  registries[name] = normalizeRegistryURL(url);
105
107
  }
106
108
  const rc = new RegistryClient(conf.options);
@@ -108,12 +110,10 @@ export const command = async (conf) => {
108
110
  if (!yes) {
109
111
  const doAuth = await ask('Authenticate with your vlt.io account now? (Y/n) ');
110
112
  if (!/^n/i.test(doAuth)) {
111
- stdout('Each registry is authenticated separately, so the browser ' +
112
- 'opens once per registry.');
113
- for (const name of accountRegistries) {
114
- stdout(`${accountRegistryAuthPrompts[name]} ("${name}")...`);
115
- await rc.login(accountRegistryURL(account, name));
116
- }
113
+ // one token covers every registry on the account, so the browser
114
+ // opens once and the token is stored for all of them.
115
+ stdout(`Authenticating your account registries (${accountRegistries.join(', ')})...`);
116
+ await rc.login(accountRegistries.map(name => accountRegistryURL(account, name)));
117
117
  }
118
118
  // 5. offer to add further custom aliases
119
119
  for (;;) {
@@ -144,7 +144,17 @@ export const command = async (conf) => {
144
144
  }
145
145
  // 6. persist the staged registries (merged, not clobbered)
146
146
  await conf.addConfigToFile(which, { registries });
147
- return { account, which, registries };
147
+ // writing the user config from inside a project that configures its own
148
+ // registries has no effect here, so say so rather than looking like it
149
+ // worked.
150
+ const shadowedByProject = which === 'user' &&
151
+ registrySelectionFields.some(f => f in (conf.layers.project ?? {}));
152
+ return {
153
+ account,
154
+ which,
155
+ registries,
156
+ ...(shadowedByProject ? { shadowedByProject } : undefined),
157
+ };
148
158
  }
149
159
  finally {
150
160
  if (rl) {
@@ -141,7 +141,7 @@ export const command = async (conf) => {
141
141
  cause: asError(err),
142
142
  });
143
143
  }
144
- if (response.statusCode !== 200 && response.statusCode !== 201) {
144
+ if (response.statusCode < 200 || response.statusCode >= 300) {
145
145
  throw error('Failed to unpublish package version', {
146
146
  url: putUrl,
147
147
  response,
@@ -188,7 +188,7 @@ export const command = async (conf) => {
188
188
  cause: asError(err),
189
189
  });
190
190
  }
191
- if (response.statusCode !== 200 && response.statusCode !== 201) {
191
+ if (response.statusCode < 200 || response.statusCode >= 300) {
192
192
  throw error('Failed to unpublish package', {
193
193
  url: deleteUrl,
194
194
  response,
@@ -454,5 +454,16 @@ export declare const definition: import("jackspeak").Jack<{
454
454
  help: import("jackspeak").ConfigOption<"boolean", false, undefined>;
455
455
  all: import("jackspeak").ConfigOption<"boolean", false, undefined>;
456
456
  }>;
457
+ /**
458
+ * The pristine `default` of every known config field, captured here at
459
+ * module load, before anything can call `jack.setConfigValues()`.
460
+ *
461
+ * Config layers are applied by overwriting jackspeak's defaults, and
462
+ * `setConfigValues()` can only set them. So when a layer stops setting a
463
+ * field (or removes it with `null`), the definitional default has to be
464
+ * put back explicitly, otherwise a value left behind by a previous apply
465
+ * would leak through.
466
+ */
467
+ export declare const defaultValues: Record<string, unknown>;
457
468
  export declare const getSortedCliOptions: () => string[];
458
469
  export declare const getSortedKeys: () => string[];
@@ -132,14 +132,22 @@ export const definition = j
132
132
  * Definition of all configuration values used by vlt.
133
133
  */
134
134
  .heading('Configuration')
135
- .description(`If a \`vlt.json\` file is present in the root of the current project,
136
- then that will be used as a source of configuration information.
137
-
138
- Next, the \`vlt.json\` file in the XDG specified config directory
139
- will be checked, and loaded for any fields not set in the local project.
140
-
141
- Object type values will be merged together. Set a field to \`null\` in
142
- the JSON configuration to explicitly remove it.
135
+ .description(`The \`vlt.json\` file in the XDG specified config directory is loaded
136
+ first, and then the \`vlt.json\` file in the root of the current
137
+ project, if present, is layered on top of it.
138
+
139
+ Scalar values are overridden by the innermost layer that sets them.
140
+ Object type values are merged together key by key. Set a field, or a
141
+ key within an object field, to \`null\` in the JSON configuration to
142
+ explicitly remove it.
143
+
144
+ Registry *selection* is an exception, since \`--registry\` outranks any
145
+ \`--registries\` alias. A layer that sets any of \`registry\`,
146
+ \`registries\`, or \`default-registry-alias\` owns selection: the
147
+ \`registry\` and \`default-registry-alias\` it does *not* set are
148
+ dropped, rather than inherited from an outer layer. So a project that
149
+ configures its own registry is never sent to the user-level one, while
150
+ the aliases in the user config stay addressable by name.
143
151
 
144
152
  Command-specific fields may be set in a nested \`command\` object that
145
153
  overrides any options defined at the top level.
@@ -750,6 +758,20 @@ export const definition = j
750
758
  description: 'Show all commands, bins, and flags',
751
759
  },
752
760
  });
761
+ /**
762
+ * The pristine `default` of every known config field, captured here at
763
+ * module load, before anything can call `jack.setConfigValues()`.
764
+ *
765
+ * Config layers are applied by overwriting jackspeak's defaults, and
766
+ * `setConfigValues()` can only set them. So when a layer stops setting a
767
+ * field (or removes it with `null`), the definitional default has to be
768
+ * put back explicitly, otherwise a value left behind by a previous apply
769
+ * would leak through.
770
+ */
771
+ export const defaultValues = Object.fromEntries(Object.entries(definition.toJSON()).map(([field, def]) => [
772
+ field,
773
+ def.default,
774
+ ]));
753
775
  export const getSortedCliOptions = () => {
754
776
  const defs = definition.toJSON();
755
777
  return getSortedKeys().map((k) => {
@@ -2,8 +2,8 @@
2
2
  * Module that handles all vlt configuration needs
3
3
  *
4
4
  * Project-level configs are set in a `vlt.json` file in the local project
5
- * if present. This will override the user-level configs in the appropriate
6
- * XDG config path.
5
+ * if present, and are layered over the user-level configs in the appropriate
6
+ * XDG config path. See `./merge-layers.ts` for the layering rules.
7
7
  *
8
8
  * Command-specific configuration can be specified by putting options in a
9
9
  * field in the `command` object. For example:
@@ -38,6 +38,11 @@ export type PairsAsRecords = ConfigOptionsNoExtras & {
38
38
  [k in keyof Commands]?: ConfigOptionsNoExtras;
39
39
  };
40
40
  };
41
+ /**
42
+ * One `vlt.json` file's `config` object, in record form. Every field is
43
+ * optional, since a config file only sets what it sets.
44
+ */
45
+ export type ConfigFileLayer = Partial<PairsAsRecords>;
41
46
  export declare const pairsToRecords: (obj: NonNullable<ConfigFileData> | OptionsResults<ConfigDefinitions>) => PairsAsRecords;
42
47
  export declare const recordsToPairs: (obj: RecordPairs) => RecordPairs;
43
48
  export type ConfigDataNoCommand = {
@@ -169,6 +174,14 @@ export declare class Config {
169
174
  * in the config file.
170
175
  */
171
176
  addConfigToFile(this: LoadedConfig, which: WhichConfig, values: NonNullable<ConfigFileData>): Promise<void>;
177
+ /**
178
+ * The raw config data from each `vlt.json` file, normalized to record
179
+ * form, before the layers are merged together. `undefined` for a layer
180
+ * with no file, or a file with no `config` field.
181
+ */
182
+ get layers(): Readonly<{
183
+ [k in WhichConfig]?: ConfigFileLayer;
184
+ }>;
172
185
  /**
173
186
  * Deletes the specified config fields from the named file
174
187
  * Returns `true` if anything was changed.
@@ -216,3 +229,11 @@ export type ParsedConfig = Config & {
216
229
  * A fully loaded {@link Config} object
217
230
  */
218
231
  export type LoadedConfig = ParsedConfig;
232
+ /**
233
+ * Which `vlt.json` a command that persists settings should write to.
234
+ *
235
+ * `--config=user` and `--config=project` are explicit. Anything else means
236
+ * the caller's own default, since `--config` also takes `all` (its default,
237
+ * meaningful only for `vlt config` reads).
238
+ */
239
+ export declare const configWriteTarget: (conf: Pick<LoadedConfig, "get">, fallback: WhichConfig) => WhichConfig;
@@ -2,8 +2,8 @@
2
2
  * Module that handles all vlt configuration needs
3
3
  *
4
4
  * Project-level configs are set in a `vlt.json` file in the local project
5
- * if present. This will override the user-level configs in the appropriate
6
- * XDG config path.
5
+ * if present, and are layered over the user-level configs in the appropriate
6
+ * XDG config path. See `./merge-layers.ts` for the layering rules.
7
7
  *
8
8
  * Command-specific configuration can be specified by putting options in a
9
9
  * field in the `command` object. For example:
@@ -31,8 +31,9 @@ import { Monorepo } from '@vltpkg/workspaces';
31
31
  import { readFile, rm, writeFile } from 'node:fs/promises';
32
32
  import { dirname } from 'node:path';
33
33
  import { PathScurry } from 'path-scurry';
34
- import { commands, definition, getCommand, isRecordField, recordFields, } from "./definition.js";
34
+ import { commands, definition, defaultValues, getCommand, isRecordField, recordFields, } from "./definition.js";
35
35
  import { merge } from "./merge.js";
36
+ import { cloneLayer, mergeLayers } from "./merge-layers.js";
36
37
  export { commands, definition, isRecordField, recordFields, };
37
38
  export const kCustomInspect = Symbol.for('nodejs.util.inspect.custom');
38
39
  // turn a set of pairs into a Record object.
@@ -53,6 +54,25 @@ const reducePairs = (pairs) => {
53
54
  };
54
55
  const isRecordFieldValue = (k, v) => Array.isArray(v) &&
55
56
  recordFields.includes(k);
57
+ /**
58
+ * `registries` alias names are used as spec prefixes, where `~` is
59
+ * reserved, and an empty name has nothing to prefix with.
60
+ */
61
+ const assertRegistryKeys = (registries, file) => {
62
+ if (!registries || typeof registries !== 'object')
63
+ return;
64
+ const obj = Array.isArray(registries) ?
65
+ reducePairs(registries)
66
+ : registries;
67
+ for (const key of Object.keys(obj)) {
68
+ if (key === '' || key.includes('~')) {
69
+ throw error('Reserved character found in registries name', {
70
+ path: file,
71
+ found: key,
72
+ });
73
+ }
74
+ }
75
+ };
56
76
  export const pairsToRecords = (obj) => {
57
77
  return Object.fromEntries(Object.entries(obj).map(([k, v]) => [
58
78
  k,
@@ -75,7 +95,12 @@ export const recordsToPairs = (obj) => {
75
95
  .map(([k, v]) => [
76
96
  k,
77
97
  k === 'command' && v && typeof v === 'object' ?
78
- recordsToPairs(v)
98
+ // each command's own block gets converted too, mirroring
99
+ // pairsToRecords
100
+ Object.fromEntries(Object.entries(v).map(([k, v]) => [
101
+ k,
102
+ recordsToPairs(v),
103
+ ]))
79
104
  : (!v ||
80
105
  typeof v !== 'object' ||
81
106
  Array.isArray(v) ||
@@ -277,7 +302,10 @@ export class Config {
277
302
  * in the config file.
278
303
  */
279
304
  async addConfigToFile(which, values) {
280
- return this.writeConfigFile(which, merge((await this.#maybeLoadConfigFile(which)) ?? {}, values));
305
+ // normalize both sides to record form first, so that a record field
306
+ // written as a `key=value` list in an existing file is still merged key
307
+ // by key, rather than replaced wholesale.
308
+ return this.writeConfigFile(which, merge(pairsToRecords((await this.#maybeLoadConfigFile(which)) ?? {}), pairsToRecords(values)));
281
309
  }
282
310
  // called in this weird bound way so that it can be used by the
283
311
  // vlt-json config loading module.
@@ -292,53 +320,92 @@ export class Config {
292
320
  wanted: 'ConfigFileData',
293
321
  });
294
322
  }
295
- const { command, ...values } = recordsToPairs(c);
296
- // Validate registries keys don't contain reserved ~ character
297
- const registries = c.registries;
298
- if (registries && typeof registries === 'object') {
299
- const registriesObj = Array.isArray(registries) ?
300
- reducePairs(registries)
301
- : registries;
302
- for (const key of Object.keys(registriesObj)) {
303
- if (key === '' || key.includes('~')) {
304
- throw error('Reserved character found in registries name', {
305
- path: file,
306
- found: key,
307
- });
323
+ const { command, ...values } = c;
324
+ assertRegistryKeys(values.registries, file);
325
+ if (command && typeof command === 'object') {
326
+ for (const opts of Object.values(command)) {
327
+ if (opts && typeof opts === 'object') {
328
+ assertRegistryKeys(opts.registries, file);
308
329
  }
309
330
  }
310
331
  }
311
- if (command) {
312
- for (const [c, opts] of Object.entries(command)) {
313
- const cmd = getCommand(c);
314
- if (cmd && opts && typeof opts === 'object') {
315
- // Validate registries in command-specific config
316
- const cmdRegistries = opts.registries;
317
- if (cmdRegistries && typeof cmdRegistries === 'object') {
318
- const cmdRegistriesObj = Array.isArray(cmdRegistries) ?
319
- reducePairs(cmdRegistries)
320
- : cmdRegistries;
321
- for (const key of Object.keys(cmdRegistriesObj)) {
322
- if (key === '' || key.includes('~')) {
323
- throw error('Reserved character found in registries name', {
324
- path: file,
325
- found: key,
326
- });
327
- }
328
- }
329
- }
330
- this.commandValues[cmd] = merge(this.commandValues[cmd] ?? {}, opts);
331
- }
332
+ // validate this layer on its own, so that an invalid value is blamed on
333
+ // the file it actually came from. `mergeLayers` on a single layer just
334
+ // strips the `null`s, which mean "remove this field" rather than being
335
+ // a value jack should ever see.
336
+ const layer = cloneLayer(pairsToRecords(c));
337
+ const { command: _cmd, ...single } = mergeLayers([layer]);
338
+ try {
339
+ this.jack.validate(recordsToPairs(single));
340
+ }
341
+ catch (er) {
342
+ /* c8 ignore next - for TS */
343
+ if (er instanceof Error) {
344
+ // jack only attaches the source path when going through
345
+ // setConfigValues(), and the CLI's "Problem in Config File" banner
346
+ // keys off it.
347
+ /* c8 ignore next - jack always throws with a cause object */
348
+ const cause = typeof er.cause === 'object' ? er.cause : {};
349
+ er.cause = { ...cause, path: file };
350
+ }
351
+ throw er;
352
+ }
353
+ this.#layers[file === find('user') ? 'user' : 'project'] = layer;
354
+ this.#applyLayers();
355
+ }
356
+ /**
357
+ * The raw config data from each `vlt.json` file, normalized to record
358
+ * form, before the layers are merged together. `undefined` for a layer
359
+ * with no file, or a file with no `config` field.
360
+ */
361
+ get layers() {
362
+ return this.#layers;
363
+ }
364
+ #layers = {};
365
+ /**
366
+ * Recompute the jack defaults and {@link Config#commandValues} from
367
+ * {@link Config#layers}.
368
+ *
369
+ * Everything is derived from `#layers`, and every known field is applied,
370
+ * so this is idempotent: re-reading a config file (which
371
+ * `addConfigToFile` and `deleteConfigKeys` both do) can't stack the user
372
+ * layer back on top of the project layer, and a field a layer stopped
373
+ * setting goes back to its definitional default.
374
+ */
375
+ #applyLayers() {
376
+ const { command, ...values } = mergeLayers([
377
+ this.#layers.user,
378
+ this.#layers.project,
379
+ ]);
380
+ this.commandValues = {};
381
+ for (const [c, opts] of Object.entries(command ?? {})) {
382
+ const cmd = getCommand(c);
383
+ if (cmd && opts && typeof opts === 'object') {
384
+ this.commandValues[cmd] = opts;
332
385
  }
333
386
  }
334
- this.jack.setConfigValues(values, file);
387
+ const pairs = recordsToPairs(values);
388
+ const applied = {};
389
+ for (const [field, dflt] of Object.entries(defaultValues)) {
390
+ applied[field] = field in pairs ? pairs[field] : dflt;
391
+ }
392
+ this.jack.setConfigValues(applied);
393
+ this.#options = undefined;
335
394
  }
336
395
  /**
337
396
  * if the file exists, parse and load it. returns object if data was
338
397
  * loaded, or undefined if not.
339
398
  */
340
399
  async #maybeLoadConfigFile(whichConfig) {
341
- return load('config', this.#validator, whichConfig);
400
+ const data = load('config', this.#validator, whichConfig);
401
+ if (data === undefined &&
402
+ this.#layers[whichConfig] !== undefined) {
403
+ // the file (or its `config` field) went away, so the validator never
404
+ // ran. drop the layer it left behind.
405
+ delete this.#layers[whichConfig];
406
+ this.#applyLayers();
407
+ }
408
+ return data;
342
409
  }
343
410
  /**
344
411
  * Deletes the specified config fields from the named file
@@ -493,3 +560,14 @@ export class Config {
493
560
  }
494
561
  }
495
562
  const isParsed = (c) => !!(c.values && c.positionals && c.command);
563
+ /**
564
+ * Which `vlt.json` a command that persists settings should write to.
565
+ *
566
+ * `--config=user` and `--config=project` are explicit. Anything else means
567
+ * the caller's own default, since `--config` also takes `all` (its default,
568
+ * meaningful only for `vlt config` reads).
569
+ */
570
+ export const configWriteTarget = (conf, fallback) => {
571
+ const which = conf.get('config');
572
+ return which === 'user' || which === 'project' ? which : fallback;
573
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Merge the `config` objects found in the user and project `vlt.json`
3
+ * files into the single set of values that gets applied to the
4
+ * {@link https://npmjs.com/jackspeak | jackspeak} definition.
5
+ *
6
+ * Layers are supplied outermost first (user, then project), and follow the
7
+ * rules documented in `vlt help` and at
8
+ * <https://docs.vlt.sh/cli/configuring>:
9
+ *
10
+ * - Scalar fields come from the innermost layer that sets them.
11
+ * - Object (`key=value` record) fields are merged key by key.
12
+ * - A `null` value removes a field, or a single key within a record field.
13
+ * - Registry *selection* belongs to a single layer, see
14
+ * {@link registrySelectionFields}.
15
+ *
16
+ * Note that this operates on the record form of a config object (the shape
17
+ * it has in `vlt.json`), not on jackspeak's `key=value` string lists, so
18
+ * record fields can be merged per key. Run values through
19
+ * `pairsToRecords` first.
20
+ * @module
21
+ */
22
+ /** One config layer, in record (not `key=value` pair) form. */
23
+ export type ConfigLayer = Record<string, unknown>;
24
+ /**
25
+ * The fields that *select* which registry is used, as opposed to the
26
+ * catalogs of aliases that can be selected from.
27
+ */
28
+ export declare const registrySelectors: readonly ["registry", "default-registry-alias"];
29
+ /**
30
+ * When a layer sets any of these, that layer owns registry selection: the
31
+ * {@link registrySelectors} it does *not* set are removed from the merged
32
+ * result, rather than inherited from an outer layer.
33
+ *
34
+ * Without this, a `registry` in the user `vlt.json` would outrank a
35
+ * project's `registries.npm`, since the `registry` scalar beats any alias
36
+ * in `requireRegistry()`. The alias catalogs are still merged, so aliases
37
+ * configured at the user level stay addressable by name.
38
+ *
39
+ * Deliberately narrow: `scoped-registries` and `jsr-registries` are purely
40
+ * additive, so adding one of those in a project must not drop the user's
41
+ * default registry.
42
+ */
43
+ export declare const registrySelectionFields: readonly ["registry", "registries", "default-registry-alias"];
44
+ export declare const cloneLayer: <T>(data: T) => T;
45
+ /**
46
+ * Merge config layers, outermost first. Layers are never mutated.
47
+ */
48
+ export declare const mergeLayers: (layers: (ConfigLayer | undefined)[]) => ConfigLayer;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Merge the `config` objects found in the user and project `vlt.json`
3
+ * files into the single set of values that gets applied to the
4
+ * {@link https://npmjs.com/jackspeak | jackspeak} definition.
5
+ *
6
+ * Layers are supplied outermost first (user, then project), and follow the
7
+ * rules documented in `vlt help` and at
8
+ * <https://docs.vlt.sh/cli/configuring>:
9
+ *
10
+ * - Scalar fields come from the innermost layer that sets them.
11
+ * - Object (`key=value` record) fields are merged key by key.
12
+ * - A `null` value removes a field, or a single key within a record field.
13
+ * - Registry *selection* belongs to a single layer, see
14
+ * {@link registrySelectionFields}.
15
+ *
16
+ * Note that this operates on the record form of a config object (the shape
17
+ * it has in `vlt.json`), not on jackspeak's `key=value` string lists, so
18
+ * record fields can be merged per key. Run values through
19
+ * `pairsToRecords` first.
20
+ * @module
21
+ */
22
+ import { isRecordField } from "./definition.js";
23
+ /**
24
+ * The fields that *select* which registry is used, as opposed to the
25
+ * catalogs of aliases that can be selected from.
26
+ */
27
+ export const registrySelectors = [
28
+ 'registry',
29
+ 'default-registry-alias',
30
+ ];
31
+ /**
32
+ * When a layer sets any of these, that layer owns registry selection: the
33
+ * {@link registrySelectors} it does *not* set are removed from the merged
34
+ * result, rather than inherited from an outer layer.
35
+ *
36
+ * Without this, a `registry` in the user `vlt.json` would outrank a
37
+ * project's `registries.npm`, since the `registry` scalar beats any alias
38
+ * in `requireRegistry()`. The alias catalogs are still merged, so aliases
39
+ * configured at the user level stay addressable by name.
40
+ *
41
+ * Deliberately narrow: `scoped-registries` and `jsr-registries` are purely
42
+ * additive, so adding one of those in a project must not drop the user's
43
+ * default registry.
44
+ */
45
+ export const registrySelectionFields = [
46
+ 'registry',
47
+ 'registries',
48
+ 'default-registry-alias',
49
+ ];
50
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
51
+ /**
52
+ * Deep copy of a config layer, preserving `null` values.
53
+ *
54
+ * Layers are held onto for the life of the `Config` object, but the objects
55
+ * nested in them are shared with the `@vltpkg/vlt-json` cache, which
56
+ * `deleteConfigKeys` mutates in place. Copying keeps a stored layer an
57
+ * accurate snapshot of what was read.
58
+ */
59
+ const cloneValue = (v) => Array.isArray(v) ? v.map(x => cloneValue(x))
60
+ : isObj(v) ?
61
+ Object.fromEntries(Object.entries(v).map(([k, x]) => [k, cloneValue(x)]))
62
+ : v;
63
+ export const cloneLayer = (data) => cloneValue(data);
64
+ /**
65
+ * Fold one record field's keys into the value from the outer layers.
66
+ * `null` removes a key. Returns `undefined` when nothing is left, so that
67
+ * the field falls back to its definitional default instead of applying an
68
+ * empty record.
69
+ */
70
+ const mergeRecord = (base, add) => {
71
+ const result = isObj(base) ? { ...base } : {};
72
+ for (const [k, v] of Object.entries(add)) {
73
+ if (v === null)
74
+ delete result[k];
75
+ else
76
+ result[k] = v;
77
+ }
78
+ return Object.keys(result).length ? result : undefined;
79
+ };
80
+ /**
81
+ * Fold the `command` object in, merging each command's block with the same
82
+ * rules as the top level.
83
+ */
84
+ const mergeCommands = (base, add) => {
85
+ const result = isObj(base) ? { ...base } : {};
86
+ for (const [cmd, opts] of Object.entries(add)) {
87
+ if (opts === null)
88
+ delete result[cmd];
89
+ else if (isObj(opts)) {
90
+ result[cmd] = mergeLayers([
91
+ isObj(result[cmd]) ? result[cmd] : undefined,
92
+ opts,
93
+ ]);
94
+ }
95
+ }
96
+ return Object.keys(result).length ? result : undefined;
97
+ };
98
+ /**
99
+ * Merge config layers, outermost first. Layers are never mutated.
100
+ */
101
+ export const mergeLayers = (layers) => {
102
+ const result = {};
103
+ for (const layer of layers) {
104
+ if (!isObj(layer))
105
+ continue;
106
+ for (const [k, v] of Object.entries(layer)) {
107
+ if (k === 'command') {
108
+ const merged = isObj(v) ? mergeCommands(result.command, v) : undefined;
109
+ if (merged)
110
+ result.command = merged;
111
+ else
112
+ delete result.command;
113
+ }
114
+ else if (v === null) {
115
+ delete result[k];
116
+ }
117
+ else if (isRecordField(k) && isObj(v)) {
118
+ const merged = mergeRecord(result[k], v);
119
+ if (merged)
120
+ result[k] = merged;
121
+ else
122
+ delete result[k];
123
+ }
124
+ else {
125
+ result[k] = v;
126
+ }
127
+ }
128
+ // the innermost layer that configures a registry at all owns which
129
+ // registry is selected.
130
+ if (registrySelectionFields.some(f => f in layer)) {
131
+ for (const f of registrySelectors) {
132
+ if (!(f in layer))
133
+ delete result[f];
134
+ }
135
+ }
136
+ }
137
+ return result;
138
+ };
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.0.5",
4
+ "version": "1.0.7",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/vltpkg/vltpkg.git",
@@ -14,30 +14,30 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@resvg/resvg-wasm": "^2.6.2",
17
- "@vltpkg/config": "1.0.5",
18
- "@vltpkg/dep-id": "1.0.5",
19
- "@vltpkg/dot-prop": "1.0.5",
20
- "@vltpkg/error-cause": "1.0.5",
21
- "@vltpkg/git": "1.0.5",
22
- "@vltpkg/graph": "1.0.5",
23
- "@vltpkg/graph-run": "1.0.5",
24
- "@vltpkg/init": "1.0.5",
25
- "@vltpkg/output": "1.0.5",
26
- "@vltpkg/package-info": "1.0.5",
27
- "@vltpkg/package-json": "1.0.5",
28
- "@vltpkg/promise-spawn": "1.0.5",
29
- "@vltpkg/query": "1.0.5",
30
- "@vltpkg/registry-client": "1.0.5",
31
- "@vltpkg/rollback-remove": "1.0.5",
32
- "@vltpkg/run": "1.0.5",
33
- "@vltpkg/security-archive": "1.0.5",
34
- "@vltpkg/spec": "1.0.5",
35
- "@vltpkg/types": "1.0.5",
36
- "@vltpkg/url-open": "1.0.5",
37
- "@vltpkg/vlt-json": "1.0.5",
38
- "@vltpkg/vlx": "1.0.5",
39
- "@vltpkg/workspaces": "1.0.5",
40
- "@vltpkg/xdg": "1.0.5",
17
+ "@vltpkg/config": "1.0.7",
18
+ "@vltpkg/dep-id": "1.0.7",
19
+ "@vltpkg/dot-prop": "1.0.7",
20
+ "@vltpkg/error-cause": "1.0.7",
21
+ "@vltpkg/git": "1.0.7",
22
+ "@vltpkg/graph": "1.0.7",
23
+ "@vltpkg/graph-run": "1.0.7",
24
+ "@vltpkg/init": "1.0.7",
25
+ "@vltpkg/output": "1.0.7",
26
+ "@vltpkg/package-info": "1.0.7",
27
+ "@vltpkg/package-json": "1.0.7",
28
+ "@vltpkg/promise-spawn": "1.0.7",
29
+ "@vltpkg/query": "1.0.7",
30
+ "@vltpkg/registry-client": "1.0.7",
31
+ "@vltpkg/rollback-remove": "1.0.7",
32
+ "@vltpkg/run": "1.0.7",
33
+ "@vltpkg/security-archive": "1.0.7",
34
+ "@vltpkg/spec": "1.0.7",
35
+ "@vltpkg/types": "1.0.7",
36
+ "@vltpkg/url-open": "1.0.7",
37
+ "@vltpkg/vlt-json": "1.0.7",
38
+ "@vltpkg/vlx": "1.0.7",
39
+ "@vltpkg/workspaces": "1.0.7",
40
+ "@vltpkg/xdg": "1.0.7",
41
41
  "ansi-to-pre": "^1.0.6",
42
42
  "beautiful-mermaid": "^1.1.3",
43
43
  "chalk": "^5.6.2",