From 752ee8a35d37bb8d6638bdff444a7ce4fec03a1d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 13:21:13 +0000 Subject: [PATCH 1/2] npm proxies and groups: switch the replace-registry-host command on The Connect sidebar of every npm repository offered `npm config set replace-registry-host `. It is now optional: a proxy or a group shows it only once an administrator switches on "Show npm's replace-registry-host setting" in its settings (the npm option `show_replace_registry_host`); hosted repositories never take it. A format option can now be a switch (`OptionInput::Switch`): the admin form shows it as a switch, `GET /admin/formats` names its `input`, and the host keeps `true` when it is on and nothing when it is off, without asking the plugin's `check_option`. A switch is never required. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012e6DJXfcjjRxskuvtSWDNo --- crates/formats/apt/src/lib.rs | 2 + crates/formats/npm/src/lib.rs | 24 +++++- crates/plugin-api/src/types.rs | 35 ++++++++- crates/plugin/src/descriptor.rs | 13 +++- crates/server/src/admin/formats.rs | 4 + crates/server/src/admin/repositories.rs | 29 +++++++- crates/server/tests/npm.rs | 74 +++++++++++++++++++ .../npm-replace-registry-host.feature.md | 12 ++- docs/format-plugins.md | 18 +++-- docs/npm.md | 13 ++++ web/src/api/repositories.ts | 5 ++ web/src/components/PackageConnect.test.ts | 27 ++++++- web/src/components/PackageConnect.tsx | 32 +++++--- web/src/components/RepositoryForm.test.tsx | 68 +++++++++++++++++ web/src/components/RepositoryForm.tsx | 53 +++++++++---- 15 files changed, 361 insertions(+), 48 deletions(-) diff --git a/crates/formats/apt/src/lib.rs b/crates/formats/apt/src/lib.rs index 8d0ae3fe..eaa863af 100644 --- a/crates/formats/apt/src/lib.rs +++ b/crates/formats/apt/src/lib.rs @@ -32,6 +32,7 @@ use std::cmp::Ordering; use forge_plugin_api::{ Action, Answer, Assets, ChangeRequest, Descriptor, FetchResult, FormatError, FormatOption, + OptionInput, FormatPlugin, IndexFile, IndexInput, Loaded, PackageRef, PackageSource, ParsedPackage, Prereleases, PublishRequest, Query, Remote, Repository, RepositoryKind, Request, Response, Storage, Upload, UpstreamAnswer, UpstreamOp, UpstreamStep, UpstreamToken, VersionClass, @@ -68,6 +69,7 @@ pub const DESCRIPTOR: Descriptor = Descriptor { kinds: &[RepositoryKind::Hosted, RepositoryKind::Proxy], required: true, example: "bookworm", + input: OptionInput::Text, }], // A `-` starts the Debian revision (`1.0-1`), it marks no prerelease. prereleases: Prereleases::None, diff --git a/crates/formats/npm/src/lib.rs b/crates/formats/npm/src/lib.rs index 3f798bb8..eda890b1 100644 --- a/crates/formats/npm/src/lib.rs +++ b/crates/formats/npm/src/lib.rs @@ -34,14 +34,20 @@ use std::io::{Read, SeekFrom, Write}; use forge_plugin_api::{ Action, Assets, AuthOutcome, Change, Dependency, DependencyGroup, Descriptor, FetchResult, - FormatError, FormatPlugin, Forward, Loaded, PackageRef, PackageSource, ParsedPackage, Remote, - Repository, RepositoryKind, Request, Response, Storage, Unpacked, Uploaded, UpstreamOp, - UpstreamStep, UpstreamToken, + FormatError, FormatOption, FormatPlugin, Forward, Loaded, OptionInput, PackageRef, + PackageSource, ParsedPackage, Remote, Repository, RepositoryKind, Request, Response, Storage, + Unpacked, Uploaded, UpstreamOp, UpstreamStep, UpstreamToken, }; use serde_json::Value; pub use name::PackageName; +/// The switch that has the web UI's Connect panel of a proxy or a group +/// show `npm config set replace-registry-host`, for clients whose +/// `package-lock.json` was written against another registry. Nothing else +/// reads it. +pub const SHOW_REPLACE_REGISTRY_HOST: &str = "show_replace_registry_host"; + /// What npm says about itself: its format id, name and version. pub const DESCRIPTOR: Descriptor = Descriptor { id: "npm", @@ -66,7 +72,17 @@ pub const DESCRIPTOR: Descriptor = Descriptor { // A proxy given only a password (a registry token) sends it as a Bearer // token, as npm clients do with `_authToken`. upstream_token: UpstreamToken::Bearer, - options: &[], + options: &[FormatOption { + name: SHOW_REPLACE_REGISTRY_HOST, + label: "Show npm's replace-registry-host setting", + description: "The Connect panel also offers npm config set replace-registry-host, for \ + clients whose package-lock.json was written against another registry \ + (npm rewrites registry.npmjs.org by itself).", + kinds: &[RepositoryKind::Proxy, RepositoryKind::Group], + required: false, + example: "", + input: OptionInput::Switch, + }], prereleases: forge_plugin_api::Prereleases::Dash, indexes: false, }; diff --git a/crates/plugin-api/src/types.rs b/crates/plugin-api/src/types.rs index 19115425..0f58c0fb 100644 --- a/crates/plugin-api/src/types.rs +++ b/crates/plugin-api/src/types.rs @@ -187,8 +187,8 @@ pub enum Prereleases { /// A setting of the format's own that a repository carries /// ([`Descriptor::options`]): a line of text an administrator sets when /// creating or editing the repository, checked by -/// [`FormatPlugin::check_option`] and handed to the plugin with every -/// request ([`Repository::options`]). +/// [`FormatPlugin::check_option`], or a switch ([`OptionInput::Switch`]); +/// handed to the plugin with every request ([`Repository::options`]). /// /// [`FormatPlugin::check_option`]: crate::FormatPlugin::check_option #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -205,6 +205,37 @@ pub struct FormatOption { pub required: bool, /// An example value, for forms. pub example: &'static str, + /// How a form takes it: a line of text, or a switch. + pub input: OptionInput, +} + +/// How an administrator sets a format option ([`FormatOption::input`]). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum OptionInput { + /// A line of text, checked by [`FormatPlugin::check_option`]. + /// + /// [`FormatPlugin::check_option`]: crate::FormatPlugin::check_option + #[default] + Text, + /// On or off: forms show a switch. The host keeps [`SWITCH_ON`] when it + /// is on and nothing when it is off, and asks no + /// [`FormatPlugin::check_option`]. A switch is never required. + /// + /// [`FormatPlugin::check_option`]: crate::FormatPlugin::check_option + Switch, +} + +/// The value of a switch option that is on ([`OptionInput::Switch`]). +pub const SWITCH_ON: &str = "true"; + +impl OptionInput { + /// Its name in the admin API (`GET /admin/formats`). + pub fn as_str(self) -> &'static str { + match self { + OptionInput::Text => "text", + OptionInput::Switch => "switch", + } + } } /// What a proxy given a password and no username sends its upstream diff --git a/crates/plugin/src/descriptor.rs b/crates/plugin/src/descriptor.rs index be3769f1..e8c67119 100644 --- a/crates/plugin/src/descriptor.rs +++ b/crates/plugin/src/descriptor.rs @@ -3,7 +3,7 @@ //! each, plain asset names and content types. A test over the server's built-in formats //! (`plugins::builtin`) checks them, so a bad descriptor fails `cargo test`. -use forge_plugin_api::{Assets, Descriptor, Storage}; +use forge_plugin_api::{Assets, Descriptor, OptionInput, Storage}; /// Format ids a plugin can never take: names the server routes or /// reserves. @@ -58,7 +58,8 @@ pub fn check_one(descriptor: &Descriptor) -> Result<(), String> { } /// Options have names of 1–32 lowercase letters, digits and `_` (starting -/// with a letter), each once, and kinds the format serves. +/// with a letter), each once, and kinds the format serves; a switch is not +/// required. fn validate_options(descriptor: &Descriptor) -> Result<(), String> { for (i, option) in descriptor.options.iter().enumerate() { let name = option.name; @@ -83,6 +84,9 @@ fn validate_options(descriptor: &Descriptor) -> Result<(), String> { if option.label.trim().is_empty() { return Err(format!("option {name:?} needs a label")); } + if option.required && option.input == OptionInput::Switch { + return Err(format!("option {name:?} is a switch, which is never required")); + } } Ok(()) } @@ -206,6 +210,7 @@ mod tests { kinds: &[RepositoryKind::Hosted], required: true, example: "bookworm", + input: OptionInput::Text, }; let good = Descriptor { options: &[OPTION], @@ -232,6 +237,10 @@ mod tests { label: "", ..OPTION }][..], + &[FormatOption { + input: OptionInput::Switch, + ..OPTION + }][..], ] { let bad = Descriptor { options, ..GOOD }; assert!(check(&[&bad]).is_err(), "{options:?} accepted"); diff --git a/crates/server/src/admin/formats.rs b/crates/server/src/admin/formats.rs index 092f7f72..743b6976 100644 --- a/crates/server/src/admin/formats.rs +++ b/crates/server/src/admin/formats.rs @@ -53,6 +53,9 @@ pub struct OptionView { /// Whether every repository of those kinds sets it. pub required: bool, pub example: &'static str, + /// How a form takes it: `text` (a line), or `switch` (on is `"true"`, + /// off is no value). + pub input: &'static str, } /// `GET /admin/formats`: NuGet first, then the images formats (Docker), @@ -81,6 +84,7 @@ pub async fn list(State(state): State) -> Response { kinds: option.kinds.iter().map(|k| k.as_str()).collect(), required: option.required, example: option.example, + input: option.input.as_str(), }) .collect(), signed_indexes: descriptor.indexes, diff --git a/crates/server/src/admin/repositories.rs b/crates/server/src/admin/repositories.rs index 4e112258..a3ccc85e 100644 --- a/crates/server/src/admin/repositories.rs +++ b/crates/server/src/admin/repositories.rs @@ -89,7 +89,7 @@ pub struct RepoView { /// or `images` (Docker). pub storage: &'static str, /// The settings of its format's own (`GET /admin/formats` lists them): - /// apt's `distribution`. + /// apt's `distribution`, npm's `show_replace_registry_host`. pub options: BTreeMap, } @@ -1028,9 +1028,10 @@ async fn delay_overrides_for( } /// The settings of a format's own of a repository of `format` and `kind` -/// (`config.options`): `given` checked by the format's plugin and merged into -/// `existing` (a `None` value clears one), every option the kind requires -/// set. Returns the options to store. +/// (`config.options`): `given` checked by the format's plugin (a switch: +/// `true` or `false`, which clears it) and merged into `existing` (a `None` +/// value clears one), every option the kind requires set. Returns the +/// options to store. #[allow(clippy::result_large_err)] fn format_options( state: &AppState, @@ -1066,6 +1067,26 @@ fn format_options( None => { options.remove(&name); } + // A switch is on (`true`) or off (not kept); its plugin is not asked. + Some(value) if option.input == forge_plugin::api::OptionInput::Switch => { + match value.trim() { + forge_plugin::api::SWITCH_ON => { + options.insert( + name, + serde_json::Value::String(forge_plugin::api::SWITCH_ON.into()), + ); + } + "false" => { + options.remove(&name); + } + _ => { + return Err(bad_request(format!( + "{}: a switch is \"true\" or \"false\"", + option.label + ))) + } + } + } Some(value) => { let implementation = plugin.as_ref().map(|p| p.implementation().clone()); let checked = match implementation { diff --git a/crates/server/tests/npm.rs b/crates/server/tests/npm.rs index 4795a130..2c6ecf2f 100644 --- a/crates/server/tests/npm.rs +++ b/crates/server/tests/npm.rs @@ -2189,3 +2189,77 @@ async fn routes_only_npm_repositories() { assert!(npm_format.get("mount").is_none()); assert_eq!(npm_format["kinds"], json!(["hosted", "proxy", "group"])); } + +#[tokio::test] +async fn proxies_and_groups_switch_the_replace_registry_host_command_on() { + let srv = common::start().await; + const SWITCH: &str = "show_replace_registry_host"; + // Off unless switched on: a group without it has no options. + let group = format!("npmall{}", unique()); + let repo = create(&srv, json!({"name": group, "kind": "group", "format": "npm"})).await; + assert_eq!(repo["options"], json!({})); + + // Switched on: kept as "true", whatever the spacing. + let on = format!("npmon{}", unique()); + let repo = create( + &srv, + json!({"name": on, "kind": "group", "format": "npm", "options": {SWITCH: " true "}}), + ) + .await; + assert_eq!(repo["options"], json!({SWITCH: "true"})); + + // Hosted repositories do not take it, and a switch is true or false. + let hosted = format!("npmhosted{}", unique()); + let resp = admin( + &srv, + reqwest::Method::POST, + "/admin/repositories", + json!({"name": hosted, "kind": "hosted", "format": "npm", "options": {SWITCH: "true"}}), + ) + .await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + assert!(resp.text().await.unwrap().contains("not valid for hosted")); + let resp = admin( + &srv, + reqwest::Method::PUT, + &format!("/admin/repositories/{group}"), + json!({"options": {SWITCH: "yes"}}), + ) + .await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + // An update switches it on, "false" or null off again. + for (value, options) in [ + (json!("true"), json!({SWITCH: "true"})), + (json!("false"), json!({})), + (json!("true"), json!({SWITCH: "true"})), + (Value::Null, json!({})), + ] { + let resp = admin( + &srv, + reqwest::Method::PUT, + &format!("/admin/repositories/{group}"), + json!({"options": {SWITCH: value}}), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let repo: Value = resp.json().await.unwrap(); + assert_eq!(repo["options"], options, "{value}"); + } + + // The formats listing names it a switch of proxies and groups. + let formats: Value = admin(&srv, reqwest::Method::GET, "/admin/formats", json!({})) + .await + .json() + .await + .unwrap(); + let npm_format = formats + .as_array() + .unwrap() + .iter() + .find(|f| f["id"] == "npm") + .expect("npm listed"); + assert_eq!(npm_format["options"][0]["name"], SWITCH); + assert_eq!(npm_format["options"][0]["input"], "switch"); + assert_eq!(npm_format["options"][0]["kinds"], json!(["proxy", "group"])); +} diff --git a/docs/changes/2.2.0/npm-replace-registry-host.feature.md b/docs/changes/2.2.0/npm-replace-registry-host.feature.md index 68e6d074..d4154f31 100644 --- a/docs/changes/2.2.0/npm-replace-registry-host.feature.md +++ b/docs/changes/2.2.0/npm-replace-registry-host.feature.md @@ -1,4 +1,8 @@ -An npm repository's **Connect** panel in the web UI also shows -`npm config set replace-registry-host `, which has npm -download the tarballs of a `package-lock.json` written against another -registry through the repository instead. +An npm proxy or group can offer +`npm config set replace-registry-host ` in its +**Connect** panel in the web UI, which has npm download the tarballs of a +`package-lock.json` written against another registry through the repository +instead. It is off by default: an administrator switches on **Show npm's +replace-registry-host setting** in the repository's settings (admin API: +the option `show_replace_registry_host`). A format option can now be such a +switch (`OptionInput::Switch`). diff --git a/docs/format-plugins.md b/docs/format-plugins.md index 97ddb72c..93f4fae7 100644 --- a/docs/format-plugins.md +++ b/docs/format-plugins.md @@ -412,12 +412,18 @@ descriptor. The host then keeps the indexes of its hosted repositories: A format's repositories may carry settings of its own (apt's `distribution`): the descriptor lists them (`options`: a name, a label, a -description, the kinds that take one, whether it is required, an example), -`GET /admin/formats` names them, and the web UI's repository form offers -them. An administrator sets them in the admin API's `options` (`null` on an -update clears one that is not required); the plugin checks and normalizes -each value (`check_option`), and gets the repository's in -`Repository::options` and a proxy's in `Remote::options`. +description, the kinds that take one, whether it is required, an example, +and the `input`), `GET /admin/formats` names them, and the web UI's +repository form offers them. An administrator sets them in the admin API's +`options` (`null` on an update clears one that is not required); the plugin +checks and normalizes each value (`check_option`), and gets the +repository's in `Repository::options` and a proxy's in `Remote::options`. + +An option's `input` is `OptionInput::Text`, a line of text, or +`OptionInput::Switch`, which the form shows as a switch (npm's +`show_replace_registry_host`). A switch is never required; the host takes +`"true"` (kept as `SWITCH_ON`) or `"false"` (which clears it) without asking +`check_option`. Which versions the host's search filter takes for prereleases is the descriptor's too (`prereleases`): those with a `-` in the normalized version diff --git a/docs/npm.md b/docs/npm.md index 587b4e80..68d48cc2 100644 --- a/docs/npm.md +++ b/docs/npm.md @@ -81,6 +81,19 @@ npmAlwaysAuth: true npmAuthToken: "" ``` +A proxy or a group can also offer +`npm config set replace-registry-host ` there, for +clients whose `package-lock.json` was written against another registry (npm +rewrites `registry.npmjs.org` by itself). It is off by default: switch on +**Show npm's replace-registry-host setting** in the repository's settings +(**Admin → Repositories**), or set it through the admin API: + +```bash +curl … -X PUT /admin/repositories/npm-all -d '{"options":{"show_replace_registry_host":"true"}}' +``` + +`"false"` switches it off again. + The token is an API token: one `npm login` mints (below), or one you create under **Your tokens** in the web UI, which then shows these lines with the token filled in. An API token of yours works for every diff --git a/web/src/api/repositories.ts b/web/src/api/repositories.ts index 3cb19580..dc701827 100644 --- a/web/src/api/repositories.ts +++ b/web/src/api/repositories.ts @@ -271,6 +271,11 @@ export interface FormatOption { required: boolean /** An example value, for the form's placeholder. */ example: string + /** + * How the form takes it: a line of `text` (the default), or a `switch`, + * whose value is `"true"` when it is on and absent when it is off. + */ + input?: 'text' | 'switch' } /** The options of `format` a repository of `kind` takes. */ diff --git a/web/src/components/PackageConnect.test.ts b/web/src/components/PackageConnect.test.ts index 3b39e37e..41f9f03a 100644 --- a/web/src/components/PackageConnect.test.ts +++ b/web/src/components/PackageConnect.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest' -import { aptAuth, aptSource, connectSnippets, npmrc, yarnrc } from './PackageConnect' +import { + SHOW_REPLACE_REGISTRY_HOST, + aptAuth, + aptSource, + connectSnippets, + npmrc, + yarnrc, +} from './PackageConnect' describe('connectSnippets', () => { it('gives a format without commands of its own its URL', () => { @@ -27,7 +34,6 @@ describe('connectSnippets', () => { expect(snippets.map((s) => s.command)).toEqual([ 'npm config set registry https://f.example/repository/npm-all/', 'npm config set @:registry https://f.example/repository/npm-all/', - 'npm config set replace-registry-host ', 'npm login --registry https://f.example/repository/npm-all/', ]) expect( @@ -35,6 +41,23 @@ describe('connectSnippets', () => { ?.command, ).toBe('npm publish --registry https://f.example/repository/npm-hosted/') }) + + it("offers replace-registry-host only where the repository's switch is on", () => { + const commands = (options: Record) => + connectSnippets('npm', 'npm-all', 'https://f.example/repository/npm-all', false, { + kind: 'group', + options, + }).map((s) => s.command) + expect(commands({})).not.toContain( + 'npm config set replace-registry-host ', + ) + expect(commands({ [SHOW_REPLACE_REGISTRY_HOST]: 'true' })).toEqual([ + 'npm config set registry https://f.example/repository/npm-all/', + 'npm config set @:registry https://f.example/repository/npm-all/', + 'npm config set replace-registry-host ', + 'npm login --registry https://f.example/repository/npm-all/', + ]) + }) }) describe('npmrc', () => { diff --git a/web/src/components/PackageConnect.tsx b/web/src/components/PackageConnect.tsx index df307ff2..c7452904 100644 --- a/web/src/components/PackageConnect.tsx +++ b/web/src/components/PackageConnect.tsx @@ -9,7 +9,16 @@ interface Snippet { copyLabel: string } -/** What else the snippets of some formats need: apt's kind and distribution. */ +/** + * The npm option (a switch of proxies and groups) that has the panel offer + * `npm config set replace-registry-host`. + */ +export const SHOW_REPLACE_REGISTRY_HOST = 'show_replace_registry_host' + +/** + * What else the snippets of some formats need: apt's kind and distribution, + * npm's replace-registry-host switch. + */ export interface ConnectContext { kind?: string options?: Record @@ -91,13 +100,18 @@ export function connectSnippets( command: `npm config set @:registry ${url}/`, copyLabel: 'Copy scope-registry command', }, - { - // npm rewrites a lockfile's registry.npmjs.org URLs onto `registry` - // by itself; another registry's host it has to be told. - label: 'Install a package-lock.json written against another registry', - command: 'npm config set replace-registry-host ', - copyLabel: 'Copy replace-registry-host command', - }, + // npm rewrites a lockfile's registry.npmjs.org URLs onto `registry` + // by itself; another registry's host it has to be told. Offered where + // an administrator switched it on (a proxy's or a group's option). + ...(context.options?.[SHOW_REPLACE_REGISTRY_HOST] === 'true' + ? [ + { + label: 'Install a package-lock.json written against another registry', + command: 'npm config set replace-registry-host ', + copyLabel: 'Copy replace-registry-host command', + }, + ] + : []), { label: 'Sign in', command: `npm login --registry ${url}/`, @@ -183,7 +197,7 @@ export function PackageConnect({ url: string /** The repository's kind (apt's snippets differ for a proxy). */ kind?: string - /** The settings of its format's own (apt's distribution). */ + /** The settings of its format's own (apt's distribution, npm's switches). */ options?: Record canPush?: boolean }) { diff --git a/web/src/components/RepositoryForm.test.tsx b/web/src/components/RepositoryForm.test.tsx index 445afee8..84d9ad3a 100644 --- a/web/src/components/RepositoryForm.test.tsx +++ b/web/src/components/RepositoryForm.test.tsx @@ -1305,4 +1305,72 @@ describe('npm repositories', () => { writable_member: null, }) }) + + // The server's npm format declares its replace-registry-host switch. + const npmWithSwitch: FormatView = { + ...NPM_FORMAT, + options: [ + { + name: 'show_replace_registry_host', + label: "Show npm's replace-registry-host setting", + description: 'The Connect panel also offers npm config set replace-registry-host.', + kinds: ['proxy', 'group'], + required: false, + example: '', + input: 'switch', + }, + ], + } + + it('offers the replace-registry-host switch to proxies and groups, off by default', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn() + render( + {}} + />, + ) + const label = /replace-registry-host setting/ + await user.selectOptions(screen.getByLabelText('Format'), 'npm') + // Hosted repositories do not take it. + expect(screen.queryByLabelText(label)).toBeNull() + await user.selectOptions(screen.getByLabelText('Kind'), 'group') + expect(screen.getByLabelText(label)).not.toBeChecked() + await user.click(screen.getByLabelText(label)) + await user.type(screen.getByLabelText('Name'), 'npm-all') + await create(user) + expect(toCreateBody(lastValues(onSubmit))).toMatchObject({ + kind: 'group', + options: { show_replace_registry_host: 'true' }, + }) + + // Back to hosted, the switch is not sent: the server refuses it there. + await user.selectOptions(screen.getByLabelText('Kind'), 'hosted') + await create(user) + expect(toCreateBody(lastValues(onSubmit)).options).toBeUndefined() + }) + + it('on edit, sends the switch turned on as true and turned off as null', () => { + const group: RepoView = { + ...storedProxy, + name: 'npm-all', + format: 'npm', + kind: 'group', + remote_url: null, + } + const off = lastValuesOf(group) + expect(toUpdateBody(off, group)).toEqual({}) + expect( + toUpdateBody({ ...off, options: { show_replace_registry_host: 'true' } }, group), + ).toEqual({ options: { show_replace_registry_host: 'true' } }) + + const on: RepoView = { ...group, options: { show_replace_registry_host: 'true' } } + expect( + toUpdateBody({ ...lastValuesOf(on), options: { show_replace_registry_host: '' } }, on), + ).toEqual({ options: { show_replace_registry_host: null } }) + }) }) diff --git a/web/src/components/RepositoryForm.tsx b/web/src/components/RepositoryForm.tsx index 04ff78e7..fdd5e12a 100644 --- a/web/src/components/RepositoryForm.tsx +++ b/web/src/components/RepositoryForm.tsx @@ -509,6 +509,19 @@ export function RepositoryForm({ setValues((v) => ({ ...v, options: { ...v.options, [name]: value } })) } + function setKind(kind: RepoKind) { + // An option the new kind does not take (npm's switch of proxies and + // groups) is not sent: the server refuses it. + const takes = new Set(optionsFor(format, kind).map((o) => o.name)) + setValues((v) => ({ + ...v, + kind, + options: format.options + ? Object.fromEntries(Object.entries(v.options).filter(([name]) => takes.has(name))) + : v.options, + })) + } + function setOverride(index: number, change: Partial) { setValues((v) => ({ ...v, @@ -631,7 +644,7 @@ export function RepositoryForm({ set('kind', e.target.value as RepoKind)} + onChange={(e) => setKind(e.target.value as RepoKind)} description={kindHelp(values.kind, things)} > {(['hosted', 'proxy', 'group'] as const) @@ -654,20 +667,30 @@ export function RepositoryForm({ )} - {formatOptions.map((option) => ( - setOption(option.name, e.target.value)} - error={announced(errors[`option:${option.name}`])} - description={option.description} - /> - ))} + {formatOptions.map((option) => + option.input === 'switch' ? ( + setOption(option.name, on ? 'true' : '')} + description={option.description} + /> + ) : ( + setOption(option.name, e.target.value)} + error={announced(errors[`option:${option.name}`])} + description={option.description} + /> + ), + )} Date: Tue, 3 Nov 2026 13:25:21 +0000 Subject: [PATCH 2/2] Format the switch's code with rustfmt Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012e6DJXfcjjRxskuvtSWDNo --- crates/formats/apt/src/lib.rs | 8 ++++---- crates/plugin/src/descriptor.rs | 4 +++- crates/server/tests/npm.rs | 6 +++++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/formats/apt/src/lib.rs b/crates/formats/apt/src/lib.rs index eaa863af..5ca82aad 100644 --- a/crates/formats/apt/src/lib.rs +++ b/crates/formats/apt/src/lib.rs @@ -32,10 +32,10 @@ use std::cmp::Ordering; use forge_plugin_api::{ Action, Answer, Assets, ChangeRequest, Descriptor, FetchResult, FormatError, FormatOption, - OptionInput, - FormatPlugin, IndexFile, IndexInput, Loaded, PackageRef, PackageSource, ParsedPackage, - Prereleases, PublishRequest, Query, Remote, Repository, RepositoryKind, Request, Response, - Storage, Upload, UpstreamAnswer, UpstreamOp, UpstreamStep, UpstreamToken, VersionClass, + FormatPlugin, IndexFile, IndexInput, Loaded, OptionInput, PackageRef, PackageSource, + ParsedPackage, Prereleases, PublishRequest, Query, Remote, Repository, RepositoryKind, Request, + Response, Storage, Upload, UpstreamAnswer, UpstreamOp, UpstreamStep, UpstreamToken, + VersionClass, }; use crate::layout::{PoolPath, Target, KEY_ASC, KEY_GPG}; diff --git a/crates/plugin/src/descriptor.rs b/crates/plugin/src/descriptor.rs index e8c67119..e0cc1492 100644 --- a/crates/plugin/src/descriptor.rs +++ b/crates/plugin/src/descriptor.rs @@ -85,7 +85,9 @@ fn validate_options(descriptor: &Descriptor) -> Result<(), String> { return Err(format!("option {name:?} needs a label")); } if option.required && option.input == OptionInput::Switch { - return Err(format!("option {name:?} is a switch, which is never required")); + return Err(format!( + "option {name:?} is a switch, which is never required" + )); } } Ok(()) diff --git a/crates/server/tests/npm.rs b/crates/server/tests/npm.rs index 2c6ecf2f..ba1d7001 100644 --- a/crates/server/tests/npm.rs +++ b/crates/server/tests/npm.rs @@ -2196,7 +2196,11 @@ async fn proxies_and_groups_switch_the_replace_registry_host_command_on() { const SWITCH: &str = "show_replace_registry_host"; // Off unless switched on: a group without it has no options. let group = format!("npmall{}", unique()); - let repo = create(&srv, json!({"name": group, "kind": "group", "format": "npm"})).await; + let repo = create( + &srv, + json!({"name": group, "kind": "group", "format": "npm"}), + ) + .await; assert_eq!(repo["options"], json!({})); // Switched on: kept as "true", whatever the spacing.