Skip to content

Commit f58770a

Browse files
authored
feat: bootstrap plan support (#270)
1 parent beb9a0f commit f58770a

18 files changed

Lines changed: 838 additions & 40 deletions

File tree

docs/src/content/docs/reference/Bootstrap.md

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,47 @@ warning icon, along with the number of pending entries.
2525
From the panel title bar, you can:
2626

2727
- open the bootstrap status webview
28+
- show the bootstrap plan
2829
- run `mise bootstrap` (with a confirmation prompt, or as a dry run)
2930
- refresh the status
3031

32+
## Declarative resources
33+
34+
mise `2026.8.2` added declarative host provisioning, and those resources show up
35+
as their own sections:
36+
37+
| Section | Configured in |
38+
| ---------- | --------------------------------------------------- |
39+
| `Secrets` | `[bootstrap.secrets]` |
40+
| `Accounts` | `[bootstrap.users]`, `[bootstrap.groups]` (Linux) |
41+
| `Files` | `[bootstrap.files]`, `[bootstrap.directories]` |
42+
| `Services` | `[bootstrap.services]` (Linux) |
43+
| `Firewall` | `[bootstrap.linux.firewall]` (Linux) |
44+
| `Compose` | `[bootstrap.compose]` |
45+
46+
Instead of a state, these entries show the action mise would take to converge
47+
them: `create`, `update`, `remove`, or `unchanged` when they are already in
48+
their desired state. `unknown` means mise could not inspect the resource (a
49+
Linux-only section on macOS, a missing secret, Docker not running) and is shown
50+
as neutral rather than pending.
51+
52+
Selecting an entry jumps to its declaration.
53+
54+
Secrets are listed by name and by the environment variable they are read from.
55+
Their values are never read by the extension.
56+
57+
## Bootstrap plan
58+
59+
`Mise: Show mise bootstrap plan` (or the diff icon in the `Bootstrap` panel)
60+
runs [`mise bootstrap plan`](https://mise.jdx.dev/bootstrap.html), which prints
61+
what the declarative resources would change without applying anything, followed
62+
by a summary of the create/update/remove/unchanged counts.
63+
64+
This requires mise `2026.8.2` or later.
65+
3166
## Bootstrap status view
3267

3368
Use the `Mise: Show bootstrap status` command (or the list icon in the
3469
`Bootstrap` panel) to open a table with all bootstrap entries and their state.
35-
From there, you can filter for pending entries and run `mise bootstrap` or a
36-
dry run.
70+
From there, you can filter for pending entries, show the plan, and run
71+
`mise bootstrap` or a dry run.

docs/src/content/docs/reference/Tasks.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,21 @@ as tasks (e.g. `node:frontend#test`, also addressable as
222222
`package.json` file and can be run like any other task. Go-to-definition on a
223223
reference to such a task jumps to the script in the `package.json` file.
224224

225+
Since mise `2026.8.2`, this inference is opt-in per workspace provider: list
226+
`node` in
227+
[`task.auto_infer`](https://mise.jdx.dev/configuration/settings.html#taskauto_infer)
228+
in the monorepo root `mise.toml`, otherwise no script task shows up.
229+
230+
```toml
231+
[settings]
232+
experimental = true
233+
task.auto_infer = ["node"]
234+
```
235+
236+
`node` is currently the only provider that infers tasks. The other workspace
237+
providers (`cargo`, `go`, `uv`) contribute projects and dependency edges to the
238+
[workspace projects graph](#workspace-projects-graph), which needs no opt-in.
239+
225240
If a mise task in the project's `mise.toml` has the same name as a script, the
226241
toml task takes precedence and keeps the script name as an alias.
227242

package.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,12 @@
972972
"title": "Mise: Run mise bootstrap (dry run)",
973973
"enablement": "!isWeb"
974974
},
975+
{
976+
"command": "mise.runBootstrapPlan",
977+
"title": "Mise: Show mise bootstrap plan",
978+
"icon": "$(diff)",
979+
"enablement": "!isWeb"
980+
},
975981
{
976982
"command": "mise.showBootstrap",
977983
"title": "Mise: Show bootstrap status",
@@ -1088,10 +1094,15 @@
10881094
"group": "navigation@1"
10891095
},
10901096
{
1091-
"command": "mise.runBootstrap",
1097+
"command": "mise.runBootstrapPlan",
10921098
"when": "view == miseBootstrapView",
10931099
"group": "navigation@2"
10941100
},
1101+
{
1102+
"command": "mise.runBootstrap",
1103+
"when": "view == miseBootstrapView",
1104+
"group": "navigation@3"
1105+
},
10951106
{
10961107
"command": "mise.refreshEntry",
10971108
"when": "view == miseTasksView || view == miseToolsView || view == miseEnvsView || view == miseBootstrapView",

src/commands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const MISE_OPEN_BOOTSTRAP_ENTRY_DEFINITION =
3535
"mise.openBootstrapEntryDefinition";
3636
export const MISE_RUN_BOOTSTRAP = "mise.runBootstrap";
3737
export const MISE_RUN_BOOTSTRAP_DRY_RUN = "mise.runBootstrapDryRun";
38+
export const MISE_RUN_BOOTSTRAP_PLAN = "mise.runBootstrapPlan";
3839
export const MISE_SHOW_BOOTSTRAP = "mise.showBootstrap";
3940
export const MISE_RUN_TASK = "mise.runTask";
4041
export const MISE_SEARCH_TASKS = "mise.searchTasks";

src/e2e-tests/bootstrap/bootstrap.e2e.ts

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import * as assert from "node:assert";
2+
import * as os from "node:os";
23
import * as path from "node:path";
34
import * as vscode from "vscode";
5+
import { MiseService } from "../../miseService";
6+
import { getBootstrapSections } from "../../utils/bootstrapUtils";
47

58
/**
69
* Bootstrap e2e tests. Everything here is read-only and machine-agnostic:
7-
* `mise bootstrap` is never executed (only definition navigation and command
10+
* `mise bootstrap` is never executed (only status, `bootstrap plan` — which is
11+
* a preview and never applies anything — definition navigation and command
812
* registration are exercised), the fixture entries point at resources that do
913
* not exist anywhere, and the suite isolates the machine's global mise config
1014
* via MISE_GLOBAL_CONFIG_FILE (see .vscode-test.js), so results are identical
@@ -16,6 +20,21 @@ suite("Bootstrap Test Suite", function () {
1620
let workspaceRoot: string;
1721
let miseTomlDocument: vscode.TextDocument;
1822

23+
// the service only reads workspaceState from the context
24+
const fakeContext = {
25+
workspaceState: { get: () => undefined },
26+
} as unknown as vscode.ExtensionContext;
27+
28+
const createMiseService = async () => {
29+
const miseService = new MiseService(fakeContext);
30+
await miseService.initializeMisePath();
31+
assert.ok(miseService.getMiseBinaryPath(), "mise binary should resolve");
32+
return miseService;
33+
};
34+
35+
// mise reports resource paths expanded, the fixture declares them with `~`
36+
const fixturePath = (name: string) => path.join(os.homedir(), name);
37+
1938
const lineOf = (text: string): number => {
2039
for (let i = 0; i < miseTomlDocument.lineCount; i++) {
2140
if (miseTomlDocument.lineAt(i).text.includes(text)) {
@@ -55,6 +74,7 @@ suite("Bootstrap Test Suite", function () {
5574
for (const command of [
5675
"mise.runBootstrap",
5776
"mise.runBootstrapDryRun",
77+
"mise.runBootstrapPlan",
5878
"mise.showBootstrap",
5979
"mise.openBootstrapEntryDefinition",
6080
]) {
@@ -157,4 +177,126 @@ suite("Bootstrap Test Suite", function () {
157177
test("show bootstrap status webview opens without error", async () => {
158178
await vscode.commands.executeCommand("mise.showBootstrap");
159179
});
180+
181+
test("status reports the declarative resource sections", async () => {
182+
const miseService = await createMiseService();
183+
184+
const status = await miseService.getBootstrapStatus();
185+
assert.ok(status, "bootstrap status should be returned");
186+
187+
const sections = getBootstrapSections(status);
188+
const byLabel = (label: string) =>
189+
sections.find((section) => section.label === label);
190+
191+
const files = byLabel("Files");
192+
assert.ok(files, "Files section should be present");
193+
assert.deepEqual(
194+
files.entries.map((entry) => entry.label),
195+
[
196+
fixturePath(".mise-vscode-e2e-dir"),
197+
fixturePath(".mise-vscode-e2e-dir-two"),
198+
fixturePath(".mise-vscode-e2e-file.conf"),
199+
fixturePath(".mise-vscode-e2e-secret.conf"),
200+
],
201+
"directories and files share one section, in mise's order",
202+
);
203+
assert.equal(files.entries[2]?.state, "create");
204+
// the secret this file templates is never set, so mise cannot inspect it
205+
assert.equal(files.entries[3]?.state, "unknown");
206+
207+
const secrets = byLabel("Secrets");
208+
assert.ok(secrets, "Secrets section should be present");
209+
assert.equal(secrets.entries[0]?.label, "e2e_token");
210+
assert.equal(
211+
secrets.entries[0]?.description,
212+
"MISE_VSCODE_E2E_SECRET_THAT_IS_NEVER_SET",
213+
);
214+
assert.equal(secrets.entries[0]?.state, "missing");
215+
216+
// the fixture declares no services, firewall, compose or accounts, so
217+
// those sections stay out of the view on every platform
218+
for (const label of ["Services", "Firewall", "Compose", "Accounts"]) {
219+
assert.equal(byLabel(label), undefined, `${label} should be absent`);
220+
}
221+
});
222+
223+
test("plan previews the changes without applying them", async () => {
224+
const miseService = await createMiseService();
225+
assert.ok(
226+
await miseService.isBootstrapPlanAvailable(),
227+
"`mise bootstrap plan` requires mise 2026.8.2 or later",
228+
);
229+
230+
const plan = await miseService.getBootstrapPlan();
231+
assert.ok(plan, "bootstrap plan should be returned");
232+
233+
assert.deepEqual(
234+
plan.resources.map((resource) => resource.id),
235+
[
236+
{ kind: "directory", name: fixturePath(".mise-vscode-e2e-dir") },
237+
{ kind: "directory", name: fixturePath(".mise-vscode-e2e-dir-two") },
238+
{ kind: "file", name: fixturePath(".mise-vscode-e2e-file.conf") },
239+
{ kind: "file", name: fixturePath(".mise-vscode-e2e-secret.conf") },
240+
],
241+
);
242+
assert.deepEqual(plan.summary, {
243+
create: 3,
244+
update: 0,
245+
remove: 0,
246+
unchanged: 0,
247+
unknown: 1,
248+
});
249+
250+
// a plan is a preview: nothing it lists may exist afterwards
251+
for (const resource of plan.resources) {
252+
assert.equal(
253+
await vscode.workspace.fs.stat(vscode.Uri.file(resource.id.name)).then(
254+
() => true,
255+
() => false,
256+
),
257+
false,
258+
`${resource.id.name} should not have been created`,
259+
);
260+
}
261+
});
262+
263+
// status reports resource paths expanded, so navigation has to find the `~`
264+
// form they are declared with. Both tests target the second declaration of
265+
// their kind: the enclosing-table fallback always resolves to the first, so
266+
// they fail unless the expanded path is really matched back to its `~` form.
267+
test("navigates to a [bootstrap.files] declaration from the expanded path", async () => {
268+
const editor = await openDefinition(
269+
["bootstrap", "files"],
270+
fixturePath(".mise-vscode-e2e-secret.conf"),
271+
);
272+
273+
assert.equal(
274+
editor.selection.start.line,
275+
lineOf('"~/.mise-vscode-e2e-secret.conf"'),
276+
"Selection should be on the file declaration line",
277+
);
278+
});
279+
280+
test("navigates to a [bootstrap.directories] declaration from the expanded path", async () => {
281+
const editor = await openDefinition(
282+
["bootstrap", "directories"],
283+
fixturePath(".mise-vscode-e2e-dir-two"),
284+
);
285+
286+
assert.equal(
287+
editor.selection.start.line,
288+
lineOf('"~/.mise-vscode-e2e-dir-two"'),
289+
"Selection should be on the directory declaration line",
290+
);
291+
});
292+
293+
test("navigates to a [bootstrap.secrets] declaration", async () => {
294+
const editor = await openDefinition(["bootstrap", "secrets"], "e2e_token");
295+
296+
assert.equal(
297+
editor.selection.start.line,
298+
lineOf("e2e_token ="),
299+
"Selection should be on the secret declaration line",
300+
);
301+
});
160302
});

src/e2e-tests/fixtures/bootstrap-workspace/mise.toml

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,39 @@
55
[bootstrap.repos]
66
"vendor/mise-e2e-repo" = { url = "https://example.invalid/mise-e2e-repo.git" }
77

8-
[dotfiles]
9-
"~/.mise-vscode-e2e-dotfile" = { mode = "symlink" }
10-
118
[bootstrap.macos.defaults]
129
"com.example.mise-vscode-e2e" = { ExampleKey = true }
1310

1411
# friendly shorthand: resolves to `com.apple.finder ShowPathbar` in status
1512
[bootstrap.macos.finder]
1613
show_pathbar = true
14+
15+
# Declarative resources (mise 2026.8.2+). Only the sections that report
16+
# identically on macOS and Linux live here — services, firewall, compose and
17+
# users/groups are Linux/docker only, so those are covered by unit tests and by
18+
# navigation tests that pass synthetic entries.
19+
[bootstrap.secrets]
20+
e2e_token = "MISE_VSCODE_E2E_SECRET_THAT_IS_NEVER_SET"
21+
22+
# two declarations per kind: the navigation tests target the second one, which
23+
# the enclosing-table fallback cannot reach — it always lands on the first
24+
[bootstrap.directories."~/.mise-vscode-e2e-dir"]
25+
mode = "0755"
26+
27+
[bootstrap.directories."~/.mise-vscode-e2e-dir-two"]
28+
mode = "0700"
29+
30+
[bootstrap.files."~/.mise-vscode-e2e-file.conf"]
31+
content = "e2e = true\n"
32+
mode = "0644"
33+
34+
# the secret is never available, so mise reports this file as `unknown` rather
35+
# than inspecting it — which is exactly the neutral state the views must render
36+
[bootstrap.files."~/.mise-vscode-e2e-secret.conf"]
37+
content = "token = {{ secret(name='e2e_token') }}\n"
38+
template = true
39+
mode = "0600"
40+
41+
# kept last so every [bootstrap.*] table stays contiguous
42+
[dotfiles]
43+
"~/.mise-vscode-e2e-dotfile" = { mode = "symlink" }

src/e2e-tests/fixtures/monorepo-workspace/mise.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ run = "echo 'Building crate'"
3030
[settings]
3131
# package.json script tasks and `mise tasks graph` are experimental-gated
3232
experimental = true
33+
# Since mise 2026.8.2 workspace task inference is opt-in per provider, so the
34+
# `node:<package>#<script>` tasks below need `node` listed explicitly. Only the
35+
# node provider infers tasks; cargo/go/uv discover projects and dependency
36+
# edges for `mise tasks graph`, which needs no opt-in.
37+
task.auto_infer = ["node"]
3338
# the declared tools are not installed, don't install them during test runs
3439
task.run_auto_install = false
3540
# pick up the node version declared in package.json devEngines

src/e2e-tests/monorepo/monorepo-tasks.e2e.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@ suite("Monorepo Tasks Test Suite", function () {
2929
);
3030
}
3131
const tasks = JSON.parse(stdout) as Array<{ name: string }>;
32-
// the fixture enables the experimental setting that gates script tasks,
33-
// so a missing node: task means the mise version is too old
32+
// the fixture opts into script tasks with `experimental` and
33+
// `task.auto_infer = ["node"]` (opt-in since mise 2026.8.2), so a missing
34+
// node: task means the mise version is too old, or that mise changed how
35+
// workspace task inference is enabled again
3436
assert.ok(
3537
tasks.some((t) => t.name.startsWith("node:")),
36-
"This mise version does not discover package.json script tasks, update mise to the latest version to run this suite",
38+
"This mise version does not discover package.json script tasks. Update mise to the latest version, and check that `task.auto_infer` in the fixture still matches how mise opts into workspace task inference",
3739
);
3840
});
3941

0 commit comments

Comments
 (0)