Skip to content

Commit 04e1893

Browse files
authored
new: Support env mode scoped lockfiles. (#1082)
1 parent c8e3a56 commit 04e1893

26 files changed

Lines changed: 2175 additions & 265 deletions

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,31 @@
1717

1818
## Unreleased
1919

20+
#### 💥 Breaking
21+
22+
- **Lockfiles**
23+
- Added support for environment scoped lockfiles. When `PROTO_ENV` is set, a `.prototools.<env>` config is now locked to a sibling `.protolock.<env>` lockfile, instead of sharing the directory's `.protolock` with the base `.prototools` config.
24+
- Each lockfile only tracks tools with versions defined in its own config. A version overridden by an environment config is tracked in `.protolock.<env>`, while versions inherited from `.prototools` remain in `.protolock`. Ad-hoc installs are tracked by the base `.prototools` config.
25+
- Environment configs inherit the `settings.lockfile` setting from the `.prototools` config in the same directory, but can override it. Lockfiles for inactive environments are never loaded or modified.
26+
- Records for environment specific versions that were previously written to `.protolock` are no longer used, and will be re-created in `.protolock.<env>` on the next install.
27+
2028
#### 🚀 Updates
2129

30+
- **Lockfiles**
31+
- Updated `proto pin` and `proto unpin` to always keep the lockfile of the modified config in sync, even when another config (like an environment config) takes precedence for the tool.
32+
- Updated `proto install --pin` to track the record of the installed version in the lockfile of the pinned config, even when another config (like an environment config) takes precedence for the tool.
33+
- Updated `proto uninstall` to remove records for the uninstalled version from all applicable lockfiles, as the version may be pinned in multiple configs (each of which is unpinned). When uninstalling all versions, the tool is removed from all applicable lockfiles.
34+
- Updated `proto outdated --update` to also update versions defined in environment scoped configs (`.prototools.<env>`), including the records in their lockfiles. Previously these versions were skipped with a warning.
2235
- **WASM API**
2336
- Added a `download_file` host function, which downloads a file from a URL directly to a file on the host machine, without loading the contents into WASM memory. Requests are made with proto's HTTP client, respecting `[settings.http]` and `.netrc` configuration.
2437
- Added `download` and `download_from_url` functions, and a `download_file!` macro, to the PDK.
2538
- Added a `method` field to the `send_request` host function input (`SendRequestInput`), which supports `GET` (default) and `POST`.
2639
- Added a `SendRequestInput::post()` constructor for creating `POST` requests.
2740

41+
#### 🐞 Fixes
42+
43+
- Fixed an issue where `proto uninstall` would fail when `PROTO_ENV` is set and an environment scoped `.prototools.<env>` config exists, as it would attempt to unpin the version from an invalid path.
44+
2845
## 0.60.2
2946

3047
#### 🚀 Updates

crates/cli/src/commands/debug/config.rs

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ pub async fn config(session: ProtoSession, args: DebugConfigArgs) -> SessionResu
3939
if session.is_json_format() {
4040
let mut locks = BTreeMap::default();
4141

42-
for entry in &manager.entries {
43-
if entry.locked
44-
&& let Some(lock) = manager.get_lock(&entry.path)?
42+
for file in manager.get_config_files() {
43+
if file.locked
44+
&& let Some(lock) = manager.get_lock(&file.path)?
4545
{
4646
locks.insert(lock.path.clone(), (*lock).clone());
4747
}
@@ -61,25 +61,25 @@ pub async fn config(session: ProtoSession, args: DebugConfigArgs) -> SessionResu
6161
return Ok(None);
6262
}
6363

64-
for entry in manager.entries.iter().rev() {
65-
for file in &entry.configs {
66-
if file.exists {
67-
let code = toml::format(&file.config, true)?;
68-
69-
session.console.render(element! {
70-
Container {
71-
Section(
72-
title: file.path.to_string_lossy(),
73-
title_color: style_to_color(Style::Path)
74-
)
75-
CodeBlock(code, format: "toml")
76-
}
77-
})?;
78-
}
64+
// Render from lowest to highest precedence, with each
65+
// config followed by the lockfile it enabled
66+
for file in manager.get_config_files().into_iter().rev() {
67+
if file.exists {
68+
let code = toml::format(&file.config, true)?;
69+
70+
session.console.render(element! {
71+
Container {
72+
Section(
73+
title: file.path.to_string_lossy(),
74+
title_color: style_to_color(Style::Path)
75+
)
76+
CodeBlock(code, format: "toml")
77+
}
78+
})?;
7979
}
8080

81-
if entry.locked
82-
&& let Some(lock) = manager.get_lock(&entry.path)?
81+
if file.locked
82+
&& let Some(lock) = manager.get_lock(&file.path)?
8383
{
8484
let code = toml::format(&*lock, true)?;
8585

crates/cli/src/commands/diagnose.rs

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -298,25 +298,23 @@ fn gather_lockfile_warnings(session: &ProtoSession) -> Result<Vec<Issue>, ProtoC
298298
let mut warnings = vec![];
299299
let manager = session.env.load_file_manager()?;
300300

301-
for entry in &manager.entries {
302-
if !entry.locked {
301+
for file in manager.get_config_files() {
302+
if !file.locked {
303303
continue;
304304
}
305305

306-
let Some(lock) = manager.get_lock(&entry.path)? else {
306+
let Some(lock) = manager.get_lock(&file.path)? else {
307307
continue;
308308
};
309309

310-
// Gather specs defined in sibling configs, so that we can
311-
// detect lockfile records that no longer match a config
310+
// Gather specs defined in the config that owns the lockfile, so
311+
// that we can detect lockfile records that no longer match it
312312
let mut config_specs: BTreeMap<&ToolContext, BTreeSet<&UnresolvedVersionSpec>> =
313313
BTreeMap::default();
314314

315-
for file in &entry.configs {
316-
if let Some(versions) = &file.config.versions {
317-
for (context, spec) in versions {
318-
config_specs.entry(context).or_default().insert(&spec.req);
319-
}
315+
if let Some(versions) = &file.config.versions {
316+
for (context, spec) in versions {
317+
config_specs.entry(context).or_default().insert(&spec.req);
320318
}
321319
}
322320

crates/cli/src/commands/outdated.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,9 @@ pub async fn outdated(session: ProtoSession, args: OutdatedArgs) -> SessionResul
250250
continue;
251251
};
252252

253-
if !src.ends_with(PROTO_CONFIG_NAME) {
253+
// Only proto configs can be updated, including environment scoped
254+
// configs, as versions may also be detected from ecosystem files
255+
if !ProtoConfig::is_config_file(src) {
254256
warn!(
255257
config = ?src,
256258
"Unable to update the version for {}, as its config source is not a {} file",
@@ -297,23 +299,25 @@ pub async fn outdated(session: ProtoSession, args: OutdatedArgs) -> SessionResul
297299
})?;
298300
}
299301

300-
// Update lockfile records to match the newly updated configs,
302+
// Update records in the lockfiles owned by the updated configs,
301303
// otherwise the stale records will be used indefinitely
302304
for tool in &tools {
303305
let Some(item) = items.get(&tool.context) else {
304306
continue;
305307
};
306308

307-
let Some(new_spec) = item
308-
.config_source
309-
.as_ref()
310-
.and_then(|src| updates.get(src))
309+
let Some(src) = &item.config_source else {
310+
continue;
311+
};
312+
313+
let Some(new_spec) = updates
314+
.get(src)
311315
.and_then(|versions| versions.get(&tool.context))
312316
else {
313317
continue;
314318
};
315319

316-
Locker::new(tool).update_spec_in_lockfile(
320+
Locker::for_config(tool, src).update_spec_in_lockfile(
317321
&item.config_version.req,
318322
new_spec,
319323
if args.latest {

crates/cli/src/commands/pin.rs

Lines changed: 39 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use clap::Args;
33
use proto_core::flow::lock::{Locker, ProtoLockError};
44
use proto_core::flow::resolve::Resolver;
55
use proto_core::{
6-
PinLocation, ProtoConfig, Tool, ToolContext, ToolSpec, cfg, reporter::NoticeOutput,
6+
LockRecord, PinLocation, ProtoConfig, Tool, ToolContext, ToolSpec, cfg, reporter::NoticeOutput,
77
};
88
use proto_pdk_api::{PinVersionInput, PinVersionOutput, PluginFunction};
99
use starbase_console::ui::*;
@@ -29,10 +29,15 @@ pub struct PinArgs {
2929
pub tool_native: bool,
3030
}
3131

32+
/// Pin the version to the config at the provided location, and keep the
33+
/// lockfile owned by that config in sync. When pinning as part of an install,
34+
/// the record of the fresh install should be provided, so that it's tracked
35+
/// by the pinned config, which now defines a version for the tool.
3236
pub async fn internal_pin(
3337
tool: &Tool,
3438
spec: &ToolSpec,
3539
pin_to: PinLocation,
40+
install_record: Option<&LockRecord>,
3641
) -> Result<PathBuf, ProtoLockError> {
3742
let version = match &spec.version {
3843
Some(version) => version.to_string(),
@@ -58,42 +63,40 @@ pub async fn internal_pin(
5863
"Pinned the version",
5964
);
6065

61-
// Keep lockfile records in sync with the config change, but only when
62-
// the config being modified owns the lock records for the tool
63-
let owns_lock = tool
64-
.proto
65-
.load_file_manager()?
66-
.get_locked_dir(&tool.context)
67-
.is_some_and(|dir| dir == config_dir);
68-
69-
if owns_lock {
70-
let locker = Locker::new(tool);
71-
72-
match &spec.version {
73-
// We know what the pinned version resolves to, so migrate
74-
// records from the requested and previous specs to it
75-
Some(new_version) => {
76-
let new_spec = new_version.to_unresolved_spec();
77-
78-
locker.update_spec_in_lockfile(&spec.req, &new_spec, new_version)?;
79-
80-
if let Some(previous) = previous_spec
81-
&& previous.req != spec.req
82-
{
83-
locker.update_spec_in_lockfile(&previous.req, &new_spec, new_version)?;
84-
}
66+
// Keep records in the lockfile owned by the modified config in sync
67+
// with the change (no-op if the config has not enabled a lockfile)
68+
let locker = Locker::for_config(tool, &config_path);
69+
70+
// The install record was tracked by the config that previously defined
71+
// the tool (if any), which may not be the config we just pinned to
72+
if let Some(record) = install_record {
73+
locker.insert_record_into_lockfile(record)?;
74+
}
75+
76+
match &spec.version {
77+
// We know what the pinned version resolves to, so migrate
78+
// records from the requested and previous specs to it
79+
Some(new_version) => {
80+
let new_spec = new_version.to_unresolved_spec();
81+
82+
locker.update_spec_in_lockfile(&spec.req, &new_spec, new_version)?;
83+
84+
if let Some(previous) = previous_spec
85+
&& previous.req != spec.req
86+
{
87+
locker.update_spec_in_lockfile(&previous.req, &new_spec, new_version)?;
8588
}
86-
// We don't know what the pinned version resolves to, so
87-
// remove records for the previous spec
88-
None => {
89-
if let Some(previous) = previous_spec
90-
&& previous.req != spec.req
91-
{
92-
locker.remove_spec_from_lockfile(&previous.req)?;
93-
}
89+
}
90+
// We don't know what the pinned version resolves to, so
91+
// remove records for the previous spec
92+
None => {
93+
if let Some(previous) = previous_spec
94+
&& previous.req != spec.req
95+
{
96+
locker.remove_spec_from_lockfile(&previous.req)?;
9497
}
95-
};
96-
}
98+
}
99+
};
97100

98101
Ok(config_path)
99102
}
@@ -158,7 +161,7 @@ pub async fn pin(session: ProtoSession, args: PinArgs) -> SessionResult {
158161
return Ok(Some(1));
159162
}
160163
} else {
161-
config_path = internal_pin(&tool, &spec, args.to).await?;
164+
config_path = internal_pin(&tool, &spec, args.to, None).await?;
162165
}
163166

164167
session.console.notice(

crates/cli/src/commands/uninstall.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,12 @@ async fn try_uninstall_all(tool: &mut ToolRecord) -> miette::Result<()> {
8787
fs::remove_dir_all(tool.get_inventory_dir())?;
8888
fs::remove_dir_all(tool.get_temp_dir())?;
8989

90-
// Remove from lockfile
91-
Locker::new(tool).remove_from_lockfile()?;
90+
// Remove from all lockfiles, as the tool is unpinned from all configs
91+
for file in tool.proto.load_file_manager()?.get_config_files() {
92+
if file.locked {
93+
Locker::for_config(tool, &file.path).remove_from_lockfile()?;
94+
}
95+
}
9296

9397
Ok(())
9498
}

crates/cli/src/commands/unpin.rs

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -90,16 +90,10 @@ pub async fn unpin(session: ProtoSession, args: UnpinArgs) -> SessionResult {
9090
});
9191
})?;
9292

93-
// Remove lockfile records for the unpinned spec, but only when
94-
// the config being modified owns the lock records for the tool
95-
if let Some(removed) = removed_spec
96-
&& tool
97-
.proto
98-
.load_file_manager()?
99-
.get_locked_dir(&tool.context)
100-
.is_some_and(|dir| dir == config_dir)
101-
{
102-
Locker::new(&tool).remove_spec_from_lockfile(&removed.req)?;
93+
// Remove records for the unpinned spec from the lockfile owned by
94+
// the modified config (no-op if the config has not enabled a lockfile)
95+
if let Some(removed) = removed_spec {
96+
Locker::for_config(&tool, &config_path).remove_spec_from_lockfile(&removed.req)?;
10397
}
10498
}
10599

crates/cli/src/workflows/install_workflow.rs

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ impl InstallWorkflow {
112112
Resolver::resolve(&self.tool, spec, false).await?;
113113

114114
if !params.force && self.tool.is_installed(spec) {
115-
self.pin_version(spec, &params.pin_to).await?;
115+
self.pin_version(spec, None, params.pin_to.as_ref()).await?;
116116
self.finish_progress(spec, started);
117117

118118
// Ensure bins/shims exist
@@ -125,13 +125,21 @@ impl InstallWorkflow {
125125
self.pre_install(spec, &params).await?;
126126

127127
// Run install
128-
let record = self.do_install(spec, &params).await?;
129-
130-
if record.is_none() {
128+
let Some(record) = self.do_install(spec, &params).await? else {
131129
return Ok(InstallOutcome::FailedToInstall(self.tool.get_id().clone()));
132-
}
130+
};
133131

134-
let pinned = self.pin_version(spec, &params.pin_to).await?;
132+
let pinned = self
133+
.pin_version(
134+
spec,
135+
if spec.update_lockfile {
136+
Some(&record)
137+
} else {
138+
None
139+
},
140+
params.pin_to.as_ref(),
141+
)
142+
.await?;
135143
self.finish_progress(spec, started);
136144

137145
// Run post-install hooks
@@ -396,7 +404,8 @@ impl InstallWorkflow {
396404
async fn pin_version(
397405
&mut self,
398406
spec: &ToolSpec,
399-
arg_pin_to: &Option<PinLocation>,
407+
install_record: Option<&LockRecord>,
408+
arg_pin_to: Option<&PinLocation>,
400409
) -> Result<bool, ProtoCliError> {
401410
let config = self.tool.proto.load_config()?;
402411
let mut pin_to = PinLocation::Local;
@@ -422,7 +431,7 @@ impl InstallWorkflow {
422431
}
423432

424433
if pin {
425-
internal_pin(&self.tool.tool, spec, pin_to).await?;
434+
internal_pin(&self.tool.tool, spec, pin_to, install_record).await?;
426435
}
427436

428437
Ok(pin)

0 commit comments

Comments
 (0)