@codyswann/lisa 2.327.0 → 2.328.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +30 -6
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-detect-tooling/SKILL.md +21 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +25 -13
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/validate-config.mjs +72 -10
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-local-env/SKILL.md +95 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-local-env/agents/openai.yaml +4 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-local-env/scripts/local-env.mjs +174 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/SKILL.md +27 -11
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +70 -30
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/toolchain.mjs +132 -6
- package/plugins/lisa/commands/setup/local-env.md +7 -0
- package/plugins/lisa/skills/lisa-detect-tooling/SKILL.md +21 -1
- package/plugins/lisa/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +25 -13
- package/plugins/lisa/skills/lisa-secrets-access/scripts/validate-config.mjs +72 -10
- package/plugins/lisa/skills/lisa-setup-local-env/SKILL.md +95 -0
- package/plugins/lisa/skills/lisa-setup-local-env/agents/openai.yaml +4 -0
- package/plugins/lisa/skills/lisa-setup-local-env/scripts/local-env.mjs +174 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/SKILL.md +27 -11
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +70 -30
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/toolchain.mjs +132 -6
- package/plugins/lisa-agy/commands/lisa/setup/local-env.md +7 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-detect-tooling/SKILL.md +21 -1
- package/plugins/lisa-agy/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +25 -13
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/validate-config.mjs +72 -10
- package/plugins/lisa-agy/skills/lisa-setup-local-env/SKILL.md +95 -0
- package/plugins/lisa-agy/skills/lisa-setup-local-env/scripts/local-env.mjs +174 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/SKILL.md +27 -11
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +70 -30
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/toolchain.mjs +132 -6
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/commands/lisa/setup/local-env.md +7 -0
- package/plugins/lisa-copilot/skills/lisa-detect-tooling/SKILL.md +21 -1
- package/plugins/lisa-copilot/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +25 -13
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/validate-config.mjs +72 -10
- package/plugins/lisa-copilot/skills/lisa-setup-local-env/SKILL.md +95 -0
- package/plugins/lisa-copilot/skills/lisa-setup-local-env/scripts/local-env.mjs +174 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/SKILL.md +27 -11
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +70 -30
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/toolchain.mjs +132 -6
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/commands/lisa/setup/local-env.md +7 -0
- package/plugins/lisa-cursor/skills/lisa-detect-tooling/SKILL.md +21 -1
- package/plugins/lisa-cursor/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +25 -13
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/validate-config.mjs +72 -10
- package/plugins/lisa-cursor/skills/lisa-setup-local-env/SKILL.md +95 -0
- package/plugins/lisa-cursor/skills/lisa-setup-local-env/scripts/local-env.mjs +174 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/SKILL.md +27 -11
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +70 -30
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/toolchain.mjs +132 -6
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/commands/setup/local-env.md +7 -0
- package/plugins/src/base/skills/lisa-detect-tooling/SKILL.md +21 -1
- package/plugins/src/base/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +25 -13
- package/plugins/src/base/skills/lisa-secrets-access/scripts/validate-config.mjs +72 -10
- package/plugins/src/base/skills/lisa-setup-local-env/SKILL.md +95 -0
- package/plugins/src/base/skills/lisa-setup-local-env/scripts/local-env.mjs +174 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/SKILL.md +27 -11
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +70 -30
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/toolchain.mjs +132 -6
|
@@ -361,9 +361,11 @@ export function detectTooling(cwd = process.cwd()) {
|
|
|
361
361
|
/**
|
|
362
362
|
* The manifest entries an operator can paste, with the parts only they supply.
|
|
363
363
|
*
|
|
364
|
-
*
|
|
365
|
-
* `
|
|
366
|
-
*
|
|
364
|
+
* A release archive is proposed as a `platforms` map rather than a single URL
|
|
365
|
+
* plus `surfaces: ["remote"]`. The old shape could express only one artifact, so
|
|
366
|
+
* keeping a Linux binary off a laptop meant excluding the tool from laptops
|
|
367
|
+
* altogether — which is how `bws` and `gh` came to be required on a developer
|
|
368
|
+
* machine and installable only in a container.
|
|
367
369
|
* @param {{name: string}} proposal One detected tool.
|
|
368
370
|
* @returns {{install: object[], require: object[]}} Manifest skeletons.
|
|
369
371
|
*/
|
|
@@ -394,23 +396,33 @@ export function proposedEntries(proposal) {
|
|
|
394
396
|
require: [],
|
|
395
397
|
};
|
|
396
398
|
}
|
|
397
|
-
// A release archive is built for one platform, so
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
399
|
+
// A release archive is built for one platform, so every platform the project
|
|
400
|
+
// is developed or built on needs its own block — with its own install method,
|
|
401
|
+
// because a vendor may ship a tarball for one and a zip for another, as gh
|
|
402
|
+
// does. Both containers and laptops are proposed: a tool the manifest can
|
|
403
|
+
// only install remotely is one every developer installs by hand or does
|
|
404
|
+
// without.
|
|
405
|
+
//
|
|
406
|
+
// The placeholders stay unresolved on purpose. A guessed URL is an artifact
|
|
407
|
+
// the checksum cannot vouch for, which is the one property that makes a
|
|
408
|
+
// pinned entry worth reviewing.
|
|
409
|
+
const archive = platform => ({
|
|
410
|
+
install: "<release-tar or release-zip, whichever this platform ships>",
|
|
411
|
+
url: `<release url for this exact version, for ${platform}>`,
|
|
412
|
+
sha256: `<sha256 published with the ${platform} release>`,
|
|
413
|
+
});
|
|
402
414
|
return {
|
|
403
415
|
install: [
|
|
404
416
|
{
|
|
405
417
|
name: proposal.name,
|
|
406
418
|
version: "<pin>",
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
419
|
+
platforms: {
|
|
420
|
+
"linux-x64": archive("linux-x64"),
|
|
421
|
+
"darwin-arm64": archive("darwin-arm64"),
|
|
422
|
+
},
|
|
411
423
|
},
|
|
412
424
|
],
|
|
413
|
-
require: [
|
|
425
|
+
require: [],
|
|
414
426
|
};
|
|
415
427
|
}
|
|
416
428
|
|
|
@@ -73,8 +73,16 @@ function bindingsFor(surface) {
|
|
|
73
73
|
return SURFACE_BINDINGS[surface] ?? ["repository"];
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
/**
|
|
77
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Install methods the toolchain runner supports.
|
|
78
|
+
*
|
|
79
|
+
* `release-tar` was missing here while the runner had supported it for as long
|
|
80
|
+
* as gh has been pinned — so this validator would have rejected the project's
|
|
81
|
+
* own manifest, and the only reason nobody hit it is that nothing ran the two
|
|
82
|
+
* against each other. Kept in step with `assertPinned` in `toolchain.mjs`,
|
|
83
|
+
* which is the list that actually decides.
|
|
84
|
+
*/
|
|
85
|
+
const INSTALL_METHODS = new Set(["release-zip", "release-tar", "npm-global"]);
|
|
78
86
|
|
|
79
87
|
/**
|
|
80
88
|
* Validate the `secrets` block.
|
|
@@ -143,6 +151,44 @@ export function validateSecrets(secrets) {
|
|
|
143
151
|
return problems;
|
|
144
152
|
}
|
|
145
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Validate the artifact half of one install entry.
|
|
156
|
+
*
|
|
157
|
+
* Split out because the same obligations apply whether the fields sit directly
|
|
158
|
+
* on the entry or inside a per-platform block: a method must be one Lisa can
|
|
159
|
+
* execute, an archive must carry a checksum, and an npm install must name a
|
|
160
|
+
* package. Duplicating those three rules per shape is how one shape ends up
|
|
161
|
+
* quietly weaker than the other.
|
|
162
|
+
* @param {string} label How to name this entry in a message.
|
|
163
|
+
* @param {object} entry The entry or platform block.
|
|
164
|
+
* @param {string[]} problems Accumulator.
|
|
165
|
+
*/
|
|
166
|
+
function validateInstallArtifact(label, entry, problems) {
|
|
167
|
+
if (!INSTALL_METHODS.has(entry.install)) {
|
|
168
|
+
problems.push(
|
|
169
|
+
`remoteEnv install ${label} has method ${JSON.stringify(entry.install)}. ` +
|
|
170
|
+
`Supported: ${[...INSTALL_METHODS].join(", ")}.`
|
|
171
|
+
);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// Both archive kinds, not just zip. A tarball pinned without a checksum
|
|
175
|
+
// trusts whatever the URL serves today exactly as much as a zip does, and
|
|
176
|
+
// checking only one of them meant the tool Lisa's guardrails shell out to —
|
|
177
|
+
// gh, which ships a tarball on Linux — was the one going unverified.
|
|
178
|
+
if (
|
|
179
|
+
(entry.install === "release-zip" || entry.install === "release-tar") &&
|
|
180
|
+
!(entry.url && entry.sha256)
|
|
181
|
+
) {
|
|
182
|
+
problems.push(
|
|
183
|
+
`remoteEnv install ${label} needs both url and sha256. A pinned ` +
|
|
184
|
+
`version with no checksum still trusts whatever the URL serves today.`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
if (entry.install === "npm-global" && !entry.package) {
|
|
188
|
+
problems.push(`remoteEnv install ${label} needs a package`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
146
192
|
/**
|
|
147
193
|
* Validate the `remoteEnv` block.
|
|
148
194
|
* @param {object|undefined} remoteEnv The block, if present.
|
|
@@ -163,21 +209,37 @@ export function validateRemoteEnv(remoteEnv) {
|
|
|
163
209
|
if (!tool.version) {
|
|
164
210
|
problems.push(`remoteEnv install "${tool.name}" has no pinned version`);
|
|
165
211
|
}
|
|
166
|
-
|
|
212
|
+
// A flat entry means one artifact serves every platform — true of npm-global
|
|
213
|
+
// and of nothing else. Anything downloaded is platform-specific, so those
|
|
214
|
+
// declare a block per platform and each block is validated in full: a
|
|
215
|
+
// half-filled map is the failure this shape exists to prevent, and it is
|
|
216
|
+
// invisible until someone runs setup on the platform that was left out.
|
|
217
|
+
if (tool.platforms === undefined) {
|
|
218
|
+
validateInstallArtifact(`"${tool.name}"`, tool, problems);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (
|
|
222
|
+
typeof tool.platforms !== "object" ||
|
|
223
|
+
tool.platforms === null ||
|
|
224
|
+
Array.isArray(tool.platforms)
|
|
225
|
+
) {
|
|
167
226
|
problems.push(
|
|
168
|
-
`remoteEnv install "${tool.name}" has
|
|
169
|
-
`
|
|
227
|
+
`remoteEnv install "${tool.name}" has a platforms field that is not an ` +
|
|
228
|
+
`object keyed by <platform>-<arch>. Omit it when one artifact serves ` +
|
|
229
|
+
`every platform.`
|
|
170
230
|
);
|
|
171
231
|
continue;
|
|
172
232
|
}
|
|
173
|
-
|
|
233
|
+
const platforms = Object.entries(tool.platforms);
|
|
234
|
+
if (platforms.length === 0) {
|
|
174
235
|
problems.push(
|
|
175
|
-
`remoteEnv install "${tool.name}"
|
|
176
|
-
`
|
|
236
|
+
`remoteEnv install "${tool.name}" declares platforms but lists none, so ` +
|
|
237
|
+
`it can never be installed anywhere.`
|
|
177
238
|
);
|
|
239
|
+
continue;
|
|
178
240
|
}
|
|
179
|
-
|
|
180
|
-
|
|
241
|
+
for (const [platform, block] of platforms) {
|
|
242
|
+
validateInstallArtifact(`"${tool.name}" (${platform})`, block, problems);
|
|
181
243
|
}
|
|
182
244
|
}
|
|
183
245
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lisa-setup-local-env
|
|
3
|
+
description: "Bring a developer's machine in line with the toolchain the project declares. Reports every tool in remoteEnv.tools that is missing, outdated, or unpinned for this platform, and installs the missing ones into ~/.local/bin from the same pinned, checksummed entries the remote surfaces use — but only when asked. Same manifest, same pins, same installers as lisa-setup-remote-env; what differs is consent and that the pin is a floor rather than an equality. Run it on a fresh checkout, after a manifest change, or when a tool fails at the moment of use."
|
|
4
|
+
allowed-tools: ["Bash", "Read", "Skill"]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Setup Local Environment: $ARGUMENTS
|
|
8
|
+
|
|
9
|
+
Make this machine match what the project says it needs.
|
|
10
|
+
|
|
11
|
+
## Why this exists separately
|
|
12
|
+
|
|
13
|
+
`remoteEnv.tools` was always the manifest for **every** surface, and the executor always
|
|
14
|
+
understood `local`. What did not exist was a way to ask for it. The only path ran a file named
|
|
15
|
+
`setup-remote-env.mjs`, out of a directory named `lisa-remote-env`, via a skill named
|
|
16
|
+
`lisa-setup-remote-env` — and it only existed at all once someone had provisioned a *remote*
|
|
17
|
+
environment, because that is what writes it into the repository.
|
|
18
|
+
|
|
19
|
+
So the machine most likely to be missing a tool was the one with no way to ask about it.
|
|
20
|
+
|
|
21
|
+
This skill is that surface. It adds no manifest, no pins, and no installer: it imports the
|
|
22
|
+
ones the remote flow owns. A second installer would be a second thing to keep honest, and the
|
|
23
|
+
one people run least is the one that rots.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
node scripts/local-env.mjs # report what diverges
|
|
29
|
+
node scripts/local-env.mjs --install-tools # install what is missing
|
|
30
|
+
node scripts/local-env.mjs --json # machine-readable plan
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Exit status is non-zero when a tool cannot be resolved, so this is usable as a gate.
|
|
34
|
+
|
|
35
|
+
## What differs from a container
|
|
36
|
+
|
|
37
|
+
Two things, and both are because a person is here.
|
|
38
|
+
|
|
39
|
+
**Consent.** A container is disposable and provisions itself silently — that is the whole
|
|
40
|
+
point of it. A laptop belongs to someone, and putting pinned binaries into their
|
|
41
|
+
`~/.local/bin` uninvited is not ours to decide. Nothing installs until `--install-tools`.
|
|
42
|
+
Reporting happens either way, because knowing the machine diverges from what the project
|
|
43
|
+
declares is most of the value.
|
|
44
|
+
|
|
45
|
+
**The pin is a floor, not an equality.** A container should hold exactly the pinned version;
|
|
46
|
+
it exists to be reproducible. A developer's machine is shared with every other project they
|
|
47
|
+
work on, so installing a pinned binary ahead of a **newer** one already on `PATH` is a
|
|
48
|
+
downgrade this project imposed on all of them. A newer tool is reported as `newer` and left
|
|
49
|
+
alone. Older, or absent, is an install.
|
|
50
|
+
|
|
51
|
+
## Platform pins
|
|
52
|
+
|
|
53
|
+
An install entry either serves every platform — true of `npm-global` and nothing else — or
|
|
54
|
+
declares a `platforms` map keyed `<platform>-<arch>`, each block carrying its own `install`
|
|
55
|
+
method, `url`, and `sha256`:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"name": "gh",
|
|
60
|
+
"version": "2.83.0",
|
|
61
|
+
"platforms": {
|
|
62
|
+
"linux-x64": { "install": "release-tar", "url": "...linux_amd64.tar.gz", "sha256": "...", "binary": "gh_2.83.0_linux_amd64/bin/gh" },
|
|
63
|
+
"darwin-arm64": { "install": "release-zip", "url": "...macOS_arm64.zip", "sha256": "...", "binary": "gh_2.83.0_macOS_arm64/bin/gh" }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The method lives **inside** each block because it varies: gh publishes a `.tar.gz` for Linux
|
|
69
|
+
and a `.zip` for macOS, so a single method would have forced one platform onto an archive kind
|
|
70
|
+
its vendor does not ship.
|
|
71
|
+
|
|
72
|
+
When a tool has no block for the running platform, this skill says so and stops there. It will
|
|
73
|
+
not guess a download URL — a guessed artifact is one the checksum cannot vouch for, and the
|
|
74
|
+
checksum is the only thing standing between a pinned entry and whatever a URL serves today.
|
|
75
|
+
Add the block, or install that one tool however the platform normally would.
|
|
76
|
+
|
|
77
|
+
## Detect before you install
|
|
78
|
+
|
|
79
|
+
Run `/lisa:detect-tooling` when the report looks thin. The manifest is the only thing that puts
|
|
80
|
+
a binary on `PATH`, and nothing populates it automatically — so a project ships npm scripts
|
|
81
|
+
invoking `maestro`, wires an MCP server whose CLI it also shells out to, and configures
|
|
82
|
+
Playwright thresholds, while `remoteEnv.tools` stays empty and each of those fails at the
|
|
83
|
+
moment of use instead of at setup.
|
|
84
|
+
|
|
85
|
+
The detector proposes and a human decides. It writes nothing, so a tool still only ever reaches
|
|
86
|
+
a machine from a reviewed, pinned, checksummed entry.
|
|
87
|
+
|
|
88
|
+
## What it will not do
|
|
89
|
+
|
|
90
|
+
- **No unpinned installs.** Every install goes through the same `assertPinned` gate the remote
|
|
91
|
+
flow uses, and the same in-process checksum verification, which refuses before unpacking.
|
|
92
|
+
- **No package-manager fallback.** Shelling out to Homebrew would give no version pin and no
|
|
93
|
+
checksum, which is the property this design exists to enforce.
|
|
94
|
+
- **No writes to the manifest.** Proposing entries belongs to `/lisa:detect-tooling`; deciding
|
|
95
|
+
belongs to a human reviewing a diff.
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Bring a developer's machine in line with the toolchain the project declares.
|
|
4
|
+
*
|
|
5
|
+
* The manifest, the planner, the pins and the installers are the remote flow's,
|
|
6
|
+
* unchanged and imported — this is the surface that was missing, not a second
|
|
7
|
+
* implementation. `remoteEnv.tools` was always surface-aware and the executor
|
|
8
|
+
* always understood `local`; the only way to reach either was to run a file
|
|
9
|
+
* called `setup-remote-env.mjs` out of a directory called `lisa-remote-env`,
|
|
10
|
+
* which nobody looking for local setup ever found.
|
|
11
|
+
*
|
|
12
|
+
* Two things differ from the remote flow, and both are because a person is here:
|
|
13
|
+
*
|
|
14
|
+
* consent — a container provisions itself silently because it is disposable
|
|
15
|
+
* and nobody is watching. A laptop belongs to someone, so nothing
|
|
16
|
+
* is installed until they ask with --install-tools.
|
|
17
|
+
*
|
|
18
|
+
* tolerance — a container that cannot be fully provisioned should fail before
|
|
19
|
+
* it half-runs, because there is no one to read the report. A
|
|
20
|
+
* developer can act on a list, so an unprovisionable tool is
|
|
21
|
+
* reported, everything else is still installed, and the exit code
|
|
22
|
+
* carries the failure.
|
|
23
|
+
* @module local-env
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync } from "node:fs";
|
|
27
|
+
import { dirname, resolve } from "node:path";
|
|
28
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
29
|
+
|
|
30
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a script belonging to a sibling skill.
|
|
34
|
+
*
|
|
35
|
+
* Skills are always siblings under one `skills/` directory, in every install
|
|
36
|
+
* layout, so this holds for a plugin install, a checkout, and node_modules
|
|
37
|
+
* alike. Failing loudly here beats reimplementing what the sibling owns.
|
|
38
|
+
* @param {string} skill Sibling skill slug.
|
|
39
|
+
* @param {string} script Script filename.
|
|
40
|
+
* @returns {string} Absolute path.
|
|
41
|
+
*/
|
|
42
|
+
function siblingScript(skill, script) {
|
|
43
|
+
const path = resolve(HERE, "..", "..", skill, "scripts", script);
|
|
44
|
+
if (!existsSync(path)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`cannot find ${skill}/scripts/${script} beside this skill.\n` +
|
|
47
|
+
`Local setup composes with that skill rather than reimplementing it: ` +
|
|
48
|
+
`the pins, checksums and installers must be the same ones the remote ` +
|
|
49
|
+
`surface uses, or the two drift and only one gets fixed.`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return path;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Load the pieces the remote flow owns.
|
|
57
|
+
* @returns {Promise<object>} The imported helpers.
|
|
58
|
+
*/
|
|
59
|
+
async function loadRemoteEnv() {
|
|
60
|
+
const setup = await import(
|
|
61
|
+
pathToFileURL(
|
|
62
|
+
siblingScript("lisa-setup-remote-env", "setup-remote-env.mjs")
|
|
63
|
+
).href
|
|
64
|
+
);
|
|
65
|
+
const toolchain = await import(
|
|
66
|
+
pathToFileURL(siblingScript("lisa-setup-remote-env", "toolchain.mjs")).href
|
|
67
|
+
);
|
|
68
|
+
return { ...toolchain, ...setup };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Describe one blocked decision in terms a person can act on.
|
|
73
|
+
*
|
|
74
|
+
* A blocked step on a laptop is usually one of two things, and they are fixed
|
|
75
|
+
* differently: a tool the project requires and the machine lacks (install it),
|
|
76
|
+
* or a tool with no artifact pinned for this platform (add the pin, or install
|
|
77
|
+
* it however this platform normally would). Printing the planner's reason alone
|
|
78
|
+
* left the reader to work out which.
|
|
79
|
+
* @param {object} step A plan step.
|
|
80
|
+
* @param {string} platform The platform key that was resolved against.
|
|
81
|
+
* @returns {string} A rendered block.
|
|
82
|
+
*/
|
|
83
|
+
function explainBlocked(step, platform) {
|
|
84
|
+
if (step.action === "invalid" && step.reason.includes("no pin for")) {
|
|
85
|
+
return (
|
|
86
|
+
` ${step.name} — declared, but no artifact is pinned for ${platform}.\n` +
|
|
87
|
+
` Lisa will not guess a download URL, because a guessed artifact is ` +
|
|
88
|
+
`one the checksum cannot vouch for.\n` +
|
|
89
|
+
` Add a "${platform}" block under this tool's "platforms" map, or ` +
|
|
90
|
+
`install it yourself.`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return ` ${step.name} — ${step.reason.split("\n")[0]}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Report and optionally apply the local toolchain.
|
|
98
|
+
* @param {object} argv Parsed arguments.
|
|
99
|
+
* @returns {Promise<number>} Process exit code.
|
|
100
|
+
*/
|
|
101
|
+
export async function run(argv) {
|
|
102
|
+
const env = await loadRemoteEnv();
|
|
103
|
+
// Both seams exist for tests, and both default to this machine. A platform
|
|
104
|
+
// that can only be exercised by running on it is one CI will never cover,
|
|
105
|
+
// which is how a resolution bug ships to the platform nobody tested.
|
|
106
|
+
const platform = argv.platform ?? env.currentPlatform();
|
|
107
|
+
const { tools } = env.readRemoteEnvConfig(argv.cwd);
|
|
108
|
+
|
|
109
|
+
const declared = (tools.require ?? []).length + (tools.install ?? []).length;
|
|
110
|
+
if (declared === 0) {
|
|
111
|
+
console.log(
|
|
112
|
+
"No tools are declared in remoteEnv.tools, so there is nothing to check.\n" +
|
|
113
|
+
"Run /lisa:detect-tooling — it reads npm scripts, MCP servers, " +
|
|
114
|
+
"credential\nnotes and quality config, and proposes pinned entries for " +
|
|
115
|
+
"what it finds."
|
|
116
|
+
);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const plan = env.planToolchain(tools, env.probe, "local", platform);
|
|
121
|
+
const blocked = plan.filter(
|
|
122
|
+
step => step.action === "missing" || step.action === "invalid"
|
|
123
|
+
);
|
|
124
|
+
const installable = plan.filter(step => step.action === "install");
|
|
125
|
+
|
|
126
|
+
if (argv.json) {
|
|
127
|
+
console.log(JSON.stringify({ platform, plan }, null, 2));
|
|
128
|
+
return blocked.length ? 1 : 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
console.log(`Local toolchain for ${platform}\n`);
|
|
132
|
+
for (const step of plan) {
|
|
133
|
+
console.log(` ${step.action.padEnd(8)} ${step.reason.split("\n")[0]}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (installable.length && !argv.installTools) {
|
|
137
|
+
console.log(
|
|
138
|
+
`\n${installable.length} declared tool(s) can be installed here.\n` +
|
|
139
|
+
`Nothing has been installed: this machine is yours, not a container.\n` +
|
|
140
|
+
`Re-run with --install-tools to provision them into ~/.local/bin.`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (installable.length && argv.installTools) {
|
|
145
|
+
const binDir = env.ensureBinDir();
|
|
146
|
+
console.log(`\nInstalling into ${binDir}`);
|
|
147
|
+
for (const step of installable) {
|
|
148
|
+
console.log(` ${step.name}`);
|
|
149
|
+
if (!argv.dryRun) env.installTool(step.tool, binDir);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (blocked.length) {
|
|
154
|
+
console.log(`\n${blocked.length} tool(s) this command cannot resolve:\n`);
|
|
155
|
+
for (const step of blocked) console.log(explainBlocked(step, platform));
|
|
156
|
+
return 1;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
163
|
+
const argv = {
|
|
164
|
+
installTools: process.argv.includes("--install-tools"),
|
|
165
|
+
dryRun: process.argv.includes("--dry-run"),
|
|
166
|
+
json: process.argv.includes("--json"),
|
|
167
|
+
};
|
|
168
|
+
run(argv)
|
|
169
|
+
.then(code => process.exit(code))
|
|
170
|
+
.catch(err => {
|
|
171
|
+
console.error(err.message);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -95,6 +95,19 @@ This repository has paid for that twice: `gh` was declared nowhere and a cloud s
|
|
|
95
95
|
|
|
96
96
|
The detector proposes and a human decides. It writes nothing, so provisioning still only ever happens from a reviewed, pinned, checksummed entry.
|
|
97
97
|
|
|
98
|
+
## The same manifest provisions a laptop
|
|
99
|
+
|
|
100
|
+
`remoteEnv.tools` is not a remote-only manifest, and `/lisa:setup:local-env` is how a developer
|
|
101
|
+
applies it to their own machine — same pins, same checksums, same installers, differing only in
|
|
102
|
+
that nothing installs without `--install-tools` and that a newer tool already on `PATH` is left
|
|
103
|
+
alone rather than downgraded.
|
|
104
|
+
|
|
105
|
+
A downloaded tool declares a `platforms` map keyed `<platform>-<arch>`, each block carrying its
|
|
106
|
+
own `install` method, `url`, and `sha256`, because the artifact and the archive kind both vary
|
|
107
|
+
by platform. Before that existed, the only way to keep a Linux binary off a laptop was
|
|
108
|
+
`surfaces: ["remote"]`, which achieved it by making the tool uninstallable there — so `bws` and
|
|
109
|
+
`gh` were required on developer machines and provisionable only in containers.
|
|
110
|
+
|
|
98
111
|
## Toolchain manifest — two entry kinds
|
|
99
112
|
|
|
100
113
|
```json
|
|
@@ -108,20 +121,23 @@ The detector proposes and a human decides. It writes nothing, so provisioning st
|
|
|
108
121
|
{ "name": "unzip" }
|
|
109
122
|
],
|
|
110
123
|
"install": [
|
|
111
|
-
{
|
|
112
|
-
"name": "bws",
|
|
113
|
-
"version": "2.1.0",
|
|
114
|
-
"install": "release-zip",
|
|
115
|
-
"url": "https://<vendor>/releases/download/bws-v2.1.0/bws-<platform>-2.1.0.zip",
|
|
116
|
-
"sha256": "<sha256 published with that exact release>"
|
|
117
|
-
},
|
|
118
124
|
{
|
|
119
125
|
"name": "gh",
|
|
120
126
|
"version": "2.83.0",
|
|
121
|
-
"
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
127
|
+
"platforms": {
|
|
128
|
+
"linux-x64": {
|
|
129
|
+
"install": "release-tar",
|
|
130
|
+
"url": "https://<vendor>/releases/download/v2.83.0/gh_2.83.0_linux_amd64.tar.gz",
|
|
131
|
+
"sha256": "<sha256 published with that exact release>",
|
|
132
|
+
"binary": "gh_2.83.0_linux_amd64/bin/gh"
|
|
133
|
+
},
|
|
134
|
+
"darwin-arm64": {
|
|
135
|
+
"install": "release-zip",
|
|
136
|
+
"url": "https://<vendor>/releases/download/v2.83.0/gh_2.83.0_macOS_arm64.zip",
|
|
137
|
+
"sha256": "<sha256 published with that exact release>",
|
|
138
|
+
"binary": "gh_2.83.0_macOS_arm64/bin/gh"
|
|
139
|
+
}
|
|
140
|
+
}
|
|
125
141
|
},
|
|
126
142
|
{
|
|
127
143
|
"name": "codex",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { execFileSync } from "node:child_process";
|
|
32
|
+
import { createHash } from "node:crypto";
|
|
32
33
|
import {
|
|
33
34
|
chmodSync,
|
|
34
35
|
existsSync,
|
|
@@ -37,7 +38,7 @@ import {
|
|
|
37
38
|
rmSync,
|
|
38
39
|
writeFileSync,
|
|
39
40
|
} from "node:fs";
|
|
40
|
-
import {
|
|
41
|
+
import { delimiter, dirname, join, resolve } from "node:path";
|
|
41
42
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
42
43
|
|
|
43
44
|
import { assertPinned, extractVersion, planToolchain } from "./toolchain.mjs";
|
|
@@ -122,6 +123,34 @@ export function probe(name, exec = execFileSync) {
|
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Refuse an archive whose contents are not exactly what was pinned.
|
|
128
|
+
*
|
|
129
|
+
* Hashed in-process rather than by shelling out. `sha256sum` is a GNU coreutils
|
|
130
|
+
* program: it is not present on a stock macOS, where the equivalent is
|
|
131
|
+
* `shasum -a 256`, and it was never in the manifest's `require` list either — so
|
|
132
|
+
* the verification step depended on a tool nothing asserted, on every surface.
|
|
133
|
+
* Doing it here removes the dependency instead of adding a second name to probe
|
|
134
|
+
* for, and the check can then be tested without a real archive or a real binary.
|
|
135
|
+
* @param {string} archive Path to the downloaded file.
|
|
136
|
+
* @param {string} expected Pinned lowercase hex digest.
|
|
137
|
+
* @param {string} name Tool name, for the message.
|
|
138
|
+
*/
|
|
139
|
+
export function verifyChecksum(archive, expected, name) {
|
|
140
|
+
const actual = createHash("sha256")
|
|
141
|
+
.update(readFileSync(archive))
|
|
142
|
+
.digest("hex");
|
|
143
|
+
if (actual !== String(expected).trim().toLowerCase()) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`${name}: checksum mismatch — refusing to install.\n` +
|
|
146
|
+
` expected ${expected}\n` +
|
|
147
|
+
` actual ${actual}\n` +
|
|
148
|
+
`The URL served something other than the reviewed artifact. Do not ` +
|
|
149
|
+
`update the pin to match without establishing why it changed.`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
125
154
|
/**
|
|
126
155
|
* Install a pinned archive, refusing anything whose checksum does not match.
|
|
127
156
|
* @param {object} tool Manifest entry.
|
|
@@ -137,10 +166,7 @@ function installReleaseZip(tool, binDir) {
|
|
|
137
166
|
});
|
|
138
167
|
// Verify before unpacking, not after. An unexpected archive must fail
|
|
139
168
|
// before any of its contents reach a directory that is on PATH.
|
|
140
|
-
|
|
141
|
-
input: `${tool.sha256} ${archive}\n`,
|
|
142
|
-
stdio: ["pipe", "ignore", "inherit"],
|
|
143
|
-
});
|
|
169
|
+
verifyChecksum(archive, tool.sha256, tool.name);
|
|
144
170
|
execFileSync("unzip", ["-q", "-o", archive, "-d", temporary], {
|
|
145
171
|
stdio: "inherit",
|
|
146
172
|
});
|
|
@@ -171,10 +197,7 @@ function installReleaseTar(tool, binDir) {
|
|
|
171
197
|
execFileSync("curl", ["-fsSL", tool.url, "-o", archive], {
|
|
172
198
|
stdio: "inherit",
|
|
173
199
|
});
|
|
174
|
-
|
|
175
|
-
input: `${tool.sha256} ${archive}\n`,
|
|
176
|
-
stdio: ["pipe", "ignore", "inherit"],
|
|
177
|
-
});
|
|
200
|
+
verifyChecksum(archive, tool.sha256, tool.name);
|
|
178
201
|
execFileSync("tar", ["-xzf", archive, "-C", temporary], {
|
|
179
202
|
stdio: "inherit",
|
|
180
203
|
});
|
|
@@ -205,16 +228,46 @@ function installNpmGlobal(tool) {
|
|
|
205
228
|
|
|
206
229
|
/**
|
|
207
230
|
* Execute one install decision.
|
|
208
|
-
*
|
|
231
|
+
*
|
|
232
|
+
* Exported because the local-environment flow installs from the same manifest
|
|
233
|
+
* with the same pins and the same checksum refusal. A second installer would be
|
|
234
|
+
* a second thing to keep honest, and the one people run least would rot.
|
|
235
|
+
* @param {object} tool Manifest entry, already resolved to this platform.
|
|
209
236
|
* @param {string} binDir Directory for downloaded binaries.
|
|
210
237
|
*/
|
|
211
|
-
function installTool(tool, binDir) {
|
|
238
|
+
export function installTool(tool, binDir) {
|
|
212
239
|
assertPinned(tool);
|
|
213
240
|
if (tool.install === "release-zip") installReleaseZip(tool, binDir);
|
|
214
241
|
else if (tool.install === "release-tar") installReleaseTar(tool, binDir);
|
|
215
242
|
else installNpmGlobal(tool);
|
|
216
243
|
}
|
|
217
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Create the directory installs land in, and make sure PATH will find it.
|
|
247
|
+
*
|
|
248
|
+
* Installing a binary somewhere nothing looks is the same as not installing it.
|
|
249
|
+
* `~/.local/bin` is on PATH by default on a developer workstation and is NOT on
|
|
250
|
+
* a minimal container, so the toolchain step reported `install bws`, the file
|
|
251
|
+
* landed at ~/.local/bin/bws mode 755, and the very next step died with
|
|
252
|
+
* `spawnSync bws ENOENT`.
|
|
253
|
+
*
|
|
254
|
+
* This was invisible in every local test because a workstation shell already
|
|
255
|
+
* exports the directory — the environment doing the hiding was the one used to
|
|
256
|
+
* verify the fix.
|
|
257
|
+
*
|
|
258
|
+
* Prepended, not appended: a pinned-and-checksummed binary must win over
|
|
259
|
+
* whatever an image happens to ship under the same name.
|
|
260
|
+
* @returns {string} The directory installs are written to.
|
|
261
|
+
*/
|
|
262
|
+
export function ensureBinDir() {
|
|
263
|
+
const binDir = join(process.env.HOME ?? "", ".local", "bin");
|
|
264
|
+
mkdirSync(binDir, { recursive: true, mode: 0o755 });
|
|
265
|
+
if (!pathContains(binDir)) {
|
|
266
|
+
process.env.PATH = `${binDir}${delimiter}${process.env.PATH ?? ""}`;
|
|
267
|
+
}
|
|
268
|
+
return binDir;
|
|
269
|
+
}
|
|
270
|
+
|
|
218
271
|
/**
|
|
219
272
|
* Apply the toolchain plan, reporting every decision.
|
|
220
273
|
* @param {object} tools Manifest.
|
|
@@ -231,8 +284,7 @@ function applyToolchain(tools, dryRun, options = {}) {
|
|
|
231
284
|
throw new Error(blocked.map(p => p.reason).join("\n\n"));
|
|
232
285
|
}
|
|
233
286
|
|
|
234
|
-
const binDir =
|
|
235
|
-
mkdirSync(binDir, { recursive: true, mode: 0o755 });
|
|
287
|
+
const binDir = ensureBinDir();
|
|
236
288
|
const byName = new Map((tools.install ?? []).map(t => [t.name, t]));
|
|
237
289
|
|
|
238
290
|
// Installing is a different act on a laptop than in a container. A container
|
|
@@ -263,26 +315,14 @@ function applyToolchain(tools, dryRun, options = {}) {
|
|
|
263
315
|
return plan;
|
|
264
316
|
}
|
|
265
317
|
|
|
266
|
-
// Installing a binary somewhere nothing looks is the same as not installing
|
|
267
|
-
// it. `~/.local/bin` is on PATH by default on a developer workstation and is
|
|
268
|
-
// NOT on a minimal container, so the toolchain step reported `install bws`,
|
|
269
|
-
// the file landed at ~/.local/bin/bws mode 755, and the very next step died
|
|
270
|
-
// with `spawnSync bws ENOENT`.
|
|
271
|
-
//
|
|
272
|
-
// This was invisible in every local test because a workstation shell already
|
|
273
|
-
// exports the directory — the environment doing the hiding was the one used
|
|
274
|
-
// to verify the fix.
|
|
275
|
-
//
|
|
276
|
-
// Prepended, not appended: a pinned-and-checksummed binary must win over
|
|
277
|
-
// whatever an image happens to ship under the same name.
|
|
278
|
-
if (!pathContains(binDir)) {
|
|
279
|
-
process.env.PATH = `${binDir}${delimiter}${process.env.PATH ?? ""}`;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
318
|
for (const step of plan) {
|
|
283
319
|
console.log(` ${step.action.padEnd(8)} ${step.reason}`);
|
|
320
|
+
// The planner already resolved this entry to the running platform, so use
|
|
321
|
+
// what it decided rather than looking the raw entry up again. `byName` is
|
|
322
|
+
// the pre-platform shape and would hand the installer a url that belongs to
|
|
323
|
+
// whichever platform happened to be written first.
|
|
284
324
|
if (step.action === "install" && !dryRun)
|
|
285
|
-
installTool(byName.get(step.name), binDir);
|
|
325
|
+
installTool(step.tool ?? byName.get(step.name), binDir);
|
|
286
326
|
}
|
|
287
327
|
return plan;
|
|
288
328
|
}
|