Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions crates/formats/apt/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,10 @@ use std::cmp::Ordering;

use forge_plugin_api::{
Action, Answer, Assets, ChangeRequest, Descriptor, FetchResult, FormatError, FormatOption,
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};
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 20 additions & 4 deletions crates/formats/npm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
};
Expand Down
35 changes: 33 additions & 2 deletions crates/plugin-api/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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
Expand Down
15 changes: 13 additions & 2 deletions crates/plugin/src/descriptor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -83,6 +84,11 @@ 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(())
}
Expand Down Expand Up @@ -206,6 +212,7 @@ mod tests {
kinds: &[RepositoryKind::Hosted],
required: true,
example: "bookworm",
input: OptionInput::Text,
};
let good = Descriptor {
options: &[OPTION],
Expand All @@ -232,6 +239,10 @@ mod tests {
label: "",
..OPTION
}][..],
&[FormatOption {
input: OptionInput::Switch,
..OPTION
}][..],
] {
let bad = Descriptor { options, ..GOOD };
assert!(check(&[&bad]).is_err(), "{options:?} accepted");
Expand Down
4 changes: 4 additions & 0 deletions crates/server/src/admin/formats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -81,6 +84,7 @@ pub async fn list(State(state): State<AppState>) -> 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,
Expand Down
29 changes: 25 additions & 4 deletions crates/server/src/admin/repositories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>,
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
78 changes: 78 additions & 0 deletions crates/server/tests/npm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2189,3 +2189,81 @@ 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"]));
}
12 changes: 8 additions & 4 deletions docs/changes/2.2.0/npm-replace-registry-host.feature.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
An npm repository's **Connect** panel in the web UI also shows
`npm config set replace-registry-host <package-lock.json-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 <package-lock.json-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`).
18 changes: 12 additions & 6 deletions docs/format-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions docs/npm.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,19 @@ npmAlwaysAuth: true
npmAuthToken: "<token>"
```

A proxy or a group can also offer
`npm config set replace-registry-host <package-lock.json-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
Expand Down
5 changes: 5 additions & 0 deletions web/src/api/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading
Loading