diff --git a/.yarn/versions/0a4c10c5.yml b/.yarn/versions/0a4c10c5.yml new file mode 100644 index 00000000000..79e285e03d5 --- /dev/null +++ b/.yarn/versions/0a4c10c5.yml @@ -0,0 +1,36 @@ +releases: + "@yarnpkg/cli": minor + "@yarnpkg/core": minor + "@yarnpkg/plugin-npm": minor + +declined: + - "@yarnpkg/plugin-catalog" + - "@yarnpkg/plugin-compat" + - "@yarnpkg/plugin-constraints" + - "@yarnpkg/plugin-dlx" + - "@yarnpkg/plugin-essentials" + - "@yarnpkg/plugin-exec" + - "@yarnpkg/plugin-file" + - "@yarnpkg/plugin-git" + - "@yarnpkg/plugin-github" + - "@yarnpkg/plugin-http" + - "@yarnpkg/plugin-init" + - "@yarnpkg/plugin-interactive-tools" + - "@yarnpkg/plugin-jsr" + - "@yarnpkg/plugin-link" + - "@yarnpkg/plugin-nm" + - "@yarnpkg/plugin-npm-cli" + - "@yarnpkg/plugin-pack" + - "@yarnpkg/plugin-patch" + - "@yarnpkg/plugin-pnp" + - "@yarnpkg/plugin-pnpm" + - "@yarnpkg/plugin-stage" + - "@yarnpkg/plugin-typescript" + - "@yarnpkg/plugin-version" + - "@yarnpkg/plugin-workspace-tools" + - "@yarnpkg/builder" + - "@yarnpkg/doctor" + - "@yarnpkg/extensions" + - "@yarnpkg/nm" + - "@yarnpkg/pnpify" + - "@yarnpkg/sdks" diff --git a/packages/acceptance-tests/pkg-tests-specs/sources/features/npmMinimalAgeGate.test.ts b/packages/acceptance-tests/pkg-tests-specs/sources/features/npmMinimalAgeGate.test.ts index 728183476cf..34d1c080248 100644 --- a/packages/acceptance-tests/pkg-tests-specs/sources/features/npmMinimalAgeGate.test.ts +++ b/packages/acceptance-tests/pkg-tests-specs/sources/features/npmMinimalAgeGate.test.ts @@ -1,12 +1,10 @@ -const ONE_DAY_IN_MINUTES = 24 * 60; - describe(`Features`, () => { describe(`npmMinimalAgeGate and npmPreapprovedPackages`, () => { describe(`add`, () => { test( `add should install the latest version allowed by the minimum release age`, makeTemporaryEnv({}, { - npmMinimalAgeGate: ONE_DAY_IN_MINUTES, + npmMinimalAgeGate: `1d`, }, async ({run, source}) => { await run(`add`, `release-date`); await expect(source(`require('release-date/package.json')`)).resolves.toMatchObject({ @@ -19,7 +17,7 @@ describe(`Features`, () => { test( `it should fail when trying to install exact version that is newer than the minimum release age`, makeTemporaryEnv({}, { - npmMinimalAgeGate: ONE_DAY_IN_MINUTES, + npmMinimalAgeGate: `1d`, }, async ({run}) => { await expect(run(`add`, `release-date@1.1.1`)).rejects.toThrowError(`No candidates found`); }), @@ -28,7 +26,7 @@ describe(`Features`, () => { test( `it should install older package versions when the minimum release age disallows the newest suitable version`, makeTemporaryEnv({}, { - npmMinimalAgeGate: ONE_DAY_IN_MINUTES, + npmMinimalAgeGate: `1d`, }, async ({run, source}) => { await run(`add`, `release-date@^1.0.0`); @@ -42,7 +40,7 @@ describe(`Features`, () => { test( `it should install new version when excluded by a descriptor; while transitive dependencies are not excluded`, makeTemporaryEnv({}, { - npmMinimalAgeGate: ONE_DAY_IN_MINUTES, + npmMinimalAgeGate: `1d`, npmPreapprovedPackages: [`release-date@^1.0.0`], }, async ({run, source}) => { await run(`add`, `release-date@^1.0.0`); @@ -63,7 +61,7 @@ describe(`Features`, () => { test( `it should install new version when excluded by package ident`, makeTemporaryEnv({}, { - npmMinimalAgeGate: ONE_DAY_IN_MINUTES, + npmMinimalAgeGate: `1d`, npmPreapprovedPackages: [`release-date`], }, async ({run, source}) => { await run(`add`, `release-date@^1.0.0`); @@ -92,7 +90,7 @@ describe(`Features`, () => { test( `it should work with scoped packages`, makeTemporaryEnv({}, { - npmMinimalAgeGate: ONE_DAY_IN_MINUTES, + npmMinimalAgeGate: `1d`, }, async ({run}) => { await expect(run(`add`, `@scoped/release-date@1.1.1`)).rejects.toThrowError(`No candidates found`); }), @@ -101,7 +99,7 @@ describe(`Features`, () => { test( `it should install scoped package when excluded`, makeTemporaryEnv({}, { - npmMinimalAgeGate: ONE_DAY_IN_MINUTES, + npmMinimalAgeGate: `1d`, npmPreapprovedPackages: [`@scoped/release-date`], }, async ({run, source}) => { await run(`add`, `@scoped/release-date@^1.0.0`); @@ -116,7 +114,7 @@ describe(`Features`, () => { test( `it should install scoped package when excluded by scoped glob pattern`, makeTemporaryEnv({}, { - npmMinimalAgeGate: ONE_DAY_IN_MINUTES, + npmMinimalAgeGate: `1d`, npmPreapprovedPackages: [`@scoped/*`], }, async ({run, source}) => { await run(`add`, `@scoped/release-date@^1.0.0`); @@ -132,7 +130,7 @@ describe(`Features`, () => { `it should not install a version via add that is higher than the latest tag`, makeTemporaryEnv({ }, { - npmMinimalAgeGate: ONE_DAY_IN_MINUTES, + npmMinimalAgeGate: `1d`, }, async ({run, source}) => { await run(`add`, `@scoped/release-date`); @@ -149,7 +147,7 @@ describe(`Features`, () => { makeTemporaryEnv({ dependencies: {[`release-date`]: `1.1.1`}, }, { - npmMinimalAgeGate: ONE_DAY_IN_MINUTES, + npmMinimalAgeGate: `1d`, }, async ({run}) => { await expect(run(`install`)).rejects.toThrowError(`No candidates found`); }), @@ -160,7 +158,7 @@ describe(`Features`, () => { makeTemporaryEnv({ dependencies: {[`release-date`]: `^1.0.0`}, }, { - npmMinimalAgeGate: ONE_DAY_IN_MINUTES, + npmMinimalAgeGate: `1d`, }, async ({run, source}) => { await run(`install`); @@ -176,7 +174,7 @@ describe(`Features`, () => { makeTemporaryEnv({ dependencies: {[`release-date`]: `^1.0.0`}, }, { - npmMinimalAgeGate: ONE_DAY_IN_MINUTES, + npmMinimalAgeGate: `1d`, npmPreapprovedPackages: [`release-date@^1.0.0`], }, async ({run, source}) => { await run(`install`); @@ -193,7 +191,7 @@ describe(`Features`, () => { makeTemporaryEnv({ dependencies: {[`release-date`]: `^1.0.0`}, }, { - npmMinimalAgeGate: ONE_DAY_IN_MINUTES, + npmMinimalAgeGate: `1d`, npmPreapprovedPackages: [`release-*`], }, async ({run, source}) => { await run(`install`); @@ -210,7 +208,7 @@ describe(`Features`, () => { makeTemporaryEnv({ dependencies: {[`release-date`]: `^1.0.0`}, }, { - npmMinimalAgeGate: ONE_DAY_IN_MINUTES, + npmMinimalAgeGate: `1d`, npmPreapprovedPackages: [`release-date`], }, async ({run, source}) => { await run(`install`); @@ -243,7 +241,7 @@ describe(`Features`, () => { makeTemporaryEnv({ dependencies: {[`@scoped/release-date`]: `1.1.1`}, }, { - npmMinimalAgeGate: ONE_DAY_IN_MINUTES, + npmMinimalAgeGate: `1d`, }, async ({run}) => { await expect(run(`install`)).rejects.toThrowError(`No candidates found`); }), @@ -254,7 +252,7 @@ describe(`Features`, () => { makeTemporaryEnv({ dependencies: {[`@scoped/release-date`]: `^1.0.0`}, }, { - npmMinimalAgeGate: ONE_DAY_IN_MINUTES, + npmMinimalAgeGate: `1d`, npmPreapprovedPackages: [`@scoped/release-date`], }, async ({run, source}) => { await run(`install`); @@ -271,7 +269,7 @@ describe(`Features`, () => { makeTemporaryEnv({ dependencies: {[`@scoped/release-date`]: `^1.0.0`}, }, { - npmMinimalAgeGate: ONE_DAY_IN_MINUTES, + npmMinimalAgeGate: `1d`, npmPreapprovedPackages: [`@scoped/*`], }, async ({run, source}) => { await run(`install`); @@ -288,7 +286,7 @@ describe(`Features`, () => { makeTemporaryEnv({ dependencies: {[`@scoped/release-date`]: `latest`}, }, { - npmMinimalAgeGate: ONE_DAY_IN_MINUTES, + npmMinimalAgeGate: `1d`, }, async ({run, source}) => { await run(`install`); @@ -305,7 +303,7 @@ describe(`Features`, () => { makeTemporaryEnv({ dependencies: {[`release-date`]: `^1.0.0`}, }, { - npmMinimalAgeGate: ONE_DAY_IN_MINUTES, + npmMinimalAgeGate: `1d`, }, async ({run, source}) => { await run(`install`); await run(`set`, `resolution`, `release-date@npm:^1.0.0`, `npm:1.0.0`); @@ -330,7 +328,7 @@ describe(`Features`, () => { // disabling these checks for the purpose of this test pnpFallbackMode: `all`, pnpMode: `loose`, - npmMinimalAgeGate: ONE_DAY_IN_MINUTES, + npmMinimalAgeGate: `1d`, }, async ({run, source}) => { await run(`install`); await run(`set`, `resolution`, `release-date@npm:^1.0.0`, `npm:1.0.0`); @@ -358,7 +356,7 @@ describe(`Features`, () => { makeTemporaryEnv({ dependencies: {[`@scoped/release-date`]: `^1.0.0`}, }, { - npmMinimalAgeGate: ONE_DAY_IN_MINUTES, + npmMinimalAgeGate: `1d`, }, async ({run, source}) => { await run(`set`, `resolution`, `@scoped/release-date@npm:^1.0.0`, `npm:1.0.0`); diff --git a/packages/docusaurus/static/configuration/yarnrc.json b/packages/docusaurus/static/configuration/yarnrc.json index d875cf4cc3e..954213b7f55 100644 --- a/packages/docusaurus/static/configuration/yarnrc.json +++ b/packages/docusaurus/static/configuration/yarnrc.json @@ -272,9 +272,13 @@ }, "httpTimeout": { "_package": "@yarnpkg/core", - "title": "Amount of time to wait in milliseconds before cancelling pending HTTP requests.", - "type": "number", - "default": 60000 + "title": "Amount of time to wait before cancelling pending HTTP requests.", + "type": "mixed", + "oneOf": [ + { "type": "number" }, + { "type": "string", "pattern": "^(\\d*\\.?\\d+)(ms|s|m|h|d|w)?$" } + ], + "default": "1m" }, "httpsCaFilePath": { "_package": "@yarnpkg/core", @@ -481,10 +485,14 @@ }, "npmMinimalAgeGate": { "_package": "@yarnpkg/core", - "title": "Minimum age of a package version according to the publish date on the npm registry in minutes to be considered for installation.", + "title": "Minimum age of a package version according to the publish date on the npm registry to be considered for installation.", "description": "If a package version is newer than the minimal age gate, it will not be considered for installation. This can be used to reduce the likelihood of installing compromised packages.", - "type": "number", - "default": 0 + "type": "mixed", + "oneOf": [ + { "type": "number" }, + { "type": "string", "pattern": "^(\\d*\\.?\\d+)(ms|s|m|h|d|w)?$" } + ], + "default": "0m" }, "npmPreapprovedPackages": { "_package": "@yarnpkg/core", @@ -851,10 +859,14 @@ }, "telemetryInterval": { "_package": "@yarnpkg/core", - "title": "Define the minimal amount of time between two telemetry events, in days.", + "title": "Define the minimal amount of time between two telemetry events.", "description": "By default we only send one request per week, making it impossible for us to track your usage with a lower granularity.", - "type": "number", - "default": 7 + "type": "mixed", + "oneOf": [ + { "type": "number" }, + { "type": "string", "pattern": "^(\\d*\\.?\\d+)(ms|s|m|h|d|w)?$" } + ], + "default": "7d" }, "telemetryUserId": { "_package": "@yarnpkg/core", diff --git a/packages/plugin-npm/sources/index.ts b/packages/plugin-npm/sources/index.ts index 3c1cf5b1582..a783cdd74fe 100644 --- a/packages/plugin-npm/sources/index.ts +++ b/packages/plugin-npm/sources/index.ts @@ -1,13 +1,14 @@ -import {Plugin, SettingsType, miscUtils, Configuration, Ident} from '@yarnpkg/core'; - -import {NpmHttpFetcher} from './NpmHttpFetcher'; -import {NpmRemapResolver} from './NpmRemapResolver'; -import {NpmSemverFetcher} from './NpmSemverFetcher'; -import {NpmSemverResolver} from './NpmSemverResolver'; -import {NpmTagResolver} from './NpmTagResolver'; -import * as npmConfigUtils from './npmConfigUtils'; -import * as npmHttpUtils from './npmHttpUtils'; -import * as npmPublishUtils from './npmPublishUtils'; +import {Plugin, SettingsType, DurationUnit, miscUtils, Configuration, Ident} from '@yarnpkg/core'; +import type {SettingsDefinition} from '@yarnpkg/core'; + +import {NpmHttpFetcher} from './NpmHttpFetcher'; +import {NpmRemapResolver} from './NpmRemapResolver'; +import {NpmSemverFetcher} from './NpmSemverFetcher'; +import {NpmSemverResolver} from './NpmSemverResolver'; +import {NpmTagResolver} from './NpmTagResolver'; +import * as npmConfigUtils from './npmConfigUtils'; +import * as npmHttpUtils from './npmHttpUtils'; +import * as npmPublishUtils from './npmPublishUtils'; export {npmConfigUtils}; export {npmHttpUtils}; @@ -34,52 +35,53 @@ export interface Hooks { const authSettings = { npmAlwaysAuth: { description: `URL of the selected npm registry (note: npm enterprise isn't supported)`, - type: SettingsType.BOOLEAN as const, + type: SettingsType.BOOLEAN, default: false, }, npmAuthIdent: { description: `Authentication identity for the npm registry (_auth in npm and yarn v1)`, - type: SettingsType.SECRET as const, + type: SettingsType.SECRET, default: null, }, npmAuthToken: { description: `Authentication token for the npm registry (_authToken in npm and yarn v1)`, - type: SettingsType.SECRET as const, + type: SettingsType.SECRET, default: null, }, -}; +} satisfies Record; const registrySettings = { npmAuditRegistry: { description: `Registry to query for audit reports`, - type: SettingsType.STRING as const, + type: SettingsType.STRING, default: null, }, npmPublishRegistry: { description: `Registry to push packages to`, - type: SettingsType.STRING as const, + type: SettingsType.STRING, default: null, }, npmRegistryServer: { description: `URL of the selected npm registry (note: npm enterprise isn't supported)`, - type: SettingsType.STRING as const, + type: SettingsType.STRING, default: `https://registry.yarnpkg.com`, }, -}; +} satisfies Record; const packageGateSettings = { npmMinimalAgeGate: { - description: `Minimum age of a package version according to the publish date on the npm registry in minutes to be considered for installation`, - type: SettingsType.NUMBER as const, - default: 0, + description: `Minimum age of a package version according to the publish date on the npm registry to be considered for installation`, + type: SettingsType.DURATION, + unit: DurationUnit.MINUTES, + default: `0m`, }, npmPreapprovedPackages: { description: `Array of package descriptors or package name glob patterns to exclude from the minimum release age check`, - type: SettingsType.STRING as const, - isArray: true as const, + type: SettingsType.STRING, + isArray: true, default: [], }, -}; +} satisfies Record; declare module '@yarnpkg/core' { interface ConfigurationValueMap { diff --git a/packages/yarnpkg-core/sources/Configuration.ts b/packages/yarnpkg-core/sources/Configuration.ts index f7e379adef3..ed63eb4cd20 100644 --- a/packages/yarnpkg-core/sources/Configuration.ts +++ b/packages/yarnpkg-core/sources/Configuration.ts @@ -129,6 +129,7 @@ export enum SettingsType { LOCATOR_LOOSE = `LOCATOR_LOOSE`, NUMBER = `NUMBER`, STRING = `STRING`, + DURATION = `DURATION`, SECRET = `SECRET`, SHAPE = `SHAPE`, MAP = `MAP`, @@ -154,6 +155,20 @@ export type BaseSettingsDefinition = { type: T; } & ({isArray?: false} | {isArray: true, concatenateValues?: boolean}); +export enum DurationUnit { + MILLISECONDS = `ms`, + SECONDS = `s`, + MINUTES = `m`, + HOURS = `h`, + DAYS = `d`, + WEEKS = `w`, +} +export type DurationSettingsDefinition = BaseSettingsDefinition & { + default: string; + unit: DurationUnit; + isNullable?: boolean; +}; + export type ShapeSettingsDefinition = BaseSettingsDefinition & { properties: {[propertyName: string]: SettingsDefinition}; }; @@ -163,7 +178,7 @@ export type MapSettingsDefinition = BaseSettingsDefinition & { normalizeKeys?: (key: string) => string; }; -export type SimpleSettingsDefinition = BaseSettingsDefinition> & { +export type SimpleSettingsDefinition = BaseSettingsDefinition> & { default: any; defaultText?: any; isNullable?: boolean; @@ -173,11 +188,12 @@ export type SimpleSettingsDefinition = BaseSettingsDefinition; + | Omit; export type SettingsDefinition = | MapSettingsDefinition | ShapeSettingsDefinition + | DurationSettingsDefinition | SimpleSettingsDefinition; export type PluginConfiguration = { @@ -414,9 +430,10 @@ export const coreDefinitions: {[coreSettingName: string]: SettingsDefinition} = isArray: true, }, httpTimeout: { - description: `Timeout of each http request in milliseconds`, - type: SettingsType.NUMBER, - default: 60000, + description: `Timeout of each http request`, + type: SettingsType.DURATION, + unit: DurationUnit.MILLISECONDS, + default: `1m`, }, httpRetry: { description: `Retry times on http failure`, @@ -538,9 +555,10 @@ export const coreDefinitions: {[coreSettingName: string]: SettingsDefinition} = default: true, }, telemetryInterval: { - description: `Minimal amount of time between two telemetry uploads, in days`, - type: SettingsType.NUMBER, - default: 7, + description: `Minimal amount of time between two telemetry uploads`, + type: SettingsType.DURATION, + unit: DurationUnit.DAYS, + default: `7d`, }, telemetryUserId: { description: `If you desire to tell us which project you are, you can set this field. Completely optional and opt-in.`, @@ -732,7 +750,9 @@ type DefinitionForTypeHelper = T extends Map ? (MapSettingsDefinition & {valueDefinition: Omit, `default`>}) : T extends miscUtils.ToMapValue ? (ShapeSettingsDefinition & {properties: ConfigurationDefinitionMap}) - : SimpleDefinitionForType; + : T extends number + ? SimpleDefinitionForType | DurationSettingsDefinition + : SimpleDefinitionForType; type DefinitionForType = T extends Array ? (DefinitionForTypeHelper & {isArray: true}) @@ -786,7 +806,7 @@ function parseSingleValue(configuration: Configuration, path: string, valueBase: if (value === null && !definition.isNullable && definition.default !== null) throw new Error(`Non-nullable configuration settings "${path}" cannot be set to null`); - if (definition.values?.includes(value)) + if (`values` in definition && definition.values?.includes(value)) return value; const interpretValue = () => { @@ -819,6 +839,8 @@ function parseSingleValue(configuration: Configuration, path: string, valueBase: return structUtils.parseLocator(valueWithReplacedVariables); case SettingsType.BOOLEAN: return miscUtils.parseBoolean(valueWithReplacedVariables); + case SettingsType.DURATION: + return miscUtils.parseDuration(valueWithReplacedVariables, definition.unit); default: return valueWithReplacedVariables; } @@ -826,7 +848,7 @@ function parseSingleValue(configuration: Configuration, path: string, valueBase: const interpreted = interpretValue(); - if (definition.values && !definition.values.includes(interpreted)) + if (`values` in definition && definition.values && !definition.values.includes(interpreted)) throw new Error(`Invalid value, expected one of ${definition.values.join(`, `)}`); return interpreted; @@ -926,6 +948,9 @@ function getDefaultValue(configuration: Configuration, definition: SettingsDefin } } } + case SettingsType.DURATION: { + return miscUtils.parseDuration(definition.default, definition.unit); + } default: { return definition.default; } diff --git a/packages/yarnpkg-core/sources/index.ts b/packages/yarnpkg-core/sources/index.ts index 9d6c9e9617e..50eab00a093 100644 --- a/packages/yarnpkg-core/sources/index.ts +++ b/packages/yarnpkg-core/sources/index.ts @@ -13,7 +13,7 @@ import * as treeUtils from './treeUtils'; export {CACHE_VERSION, CACHE_CHECKPOINT, Cache} from './Cache'; export {DEFAULT_RC_FILENAME, LEGACY_PLUGINS, TAG_REGEXP} from './Configuration'; -export {Configuration, FormatType, SettingsType, WindowsLinkType} from './Configuration'; +export {Configuration, FormatType, SettingsType, DurationUnit, WindowsLinkType} from './Configuration'; export type {PluginConfiguration, SettingsDefinition, PackageExtensionData, PackageExtensions} from './Configuration'; export type {ConfigurationValueMap, ConfigurationDefinitionMap} from './Configuration'; export type {Fetcher, FetchOptions, FetchResult, MinimalFetchOptions} from './Fetcher'; diff --git a/packages/yarnpkg-core/sources/miscUtils.ts b/packages/yarnpkg-core/sources/miscUtils.ts index e9735a24d3a..ceba3237b8c 100644 --- a/packages/yarnpkg-core/sources/miscUtils.ts +++ b/packages/yarnpkg-core/sources/miscUtils.ts @@ -6,6 +6,8 @@ import pLimit, {Limit} from 'p-limit'; import semver from 'semver'; import {Readable, Transform} from 'stream'; +import type {DurationUnit} from './Configuration'; + /** * @internal */ @@ -599,3 +601,26 @@ export function groupBy, K extends keyof T>(items: export function parseInt(val: string | number) { return typeof val === `string` ? Number.parseInt(val, 10) : val; } + +const DURATION_UNITS: Record = { + ms: 1, + s: 1000, + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + w: 7 * 24 * 60 * 60 * 1000, +}; +const DURATION_REGEXP = new RegExp(`^(?\\d*\\.?\\d+)(?${Object.keys(DURATION_UNITS).join(`|`)})?$`); +export function parseDuration(value: string, unit: DurationUnit) { + const match = DURATION_REGEXP.exec(value)?.groups; + if (!match) throw new Error(`Couldn't parse "${value}" as a duration`); + + if (match.unit === undefined) + return parseFloat(match.num); + + const multiplier = DURATION_UNITS[match.unit as DurationUnit]; + if (!multiplier) + throw new Error(`Invalid duration unit "${match.unit}"`); + + return parseFloat(match.num) * multiplier / DURATION_UNITS[unit]; +}