@codyswann/lisa 2.328.5 → 2.330.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/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh +4 -2
- package/all/copy-overwrite/scripts/lisa-hooks/block-instruction-file-edits.sh +142 -0
- package/all/merge/.claude/settings.json +9 -0
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +60 -6
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/hooks-installer.d.ts.map +1 -1
- package/dist/opencode/hooks-installer.js +1 -41
- package/dist/opencode/hooks-installer.js.map +1 -1
- package/dist/opencode/plugin-catalog.d.ts +25 -0
- package/dist/opencode/plugin-catalog.d.ts.map +1 -0
- package/dist/opencode/plugin-catalog.js +47 -0
- package/dist/opencode/plugin-catalog.js.map +1 -0
- package/dist/opencode/plugin-templates/lisa-block-instruction-file-edits.ts +72 -0
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +14 -1
- package/plugins/lisa/.codex-plugin/hooks.json +13 -0
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-workstation/SKILL.md +121 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-workstation/agents/openai.yaml +4 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-workstation/scripts/catalogue.mjs +262 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-workstation/scripts/cli.mjs +315 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-workstation/scripts/workstation.mjs +403 -0
- package/plugins/lisa/commands/setup/workstation.md +7 -0
- package/plugins/lisa/hooks/block-instruction-file-edits.agy.sh +58 -0
- package/plugins/lisa/hooks/block-instruction-file-edits.sh +142 -0
- package/plugins/lisa/skills/lisa-setup-workstation/SKILL.md +121 -0
- package/plugins/lisa/skills/lisa-setup-workstation/agents/openai.yaml +4 -0
- package/plugins/lisa/skills/lisa-setup-workstation/scripts/catalogue.mjs +262 -0
- package/plugins/lisa/skills/lisa-setup-workstation/scripts/cli.mjs +315 -0
- package/plugins/lisa/skills/lisa-setup-workstation/scripts/workstation.mjs +403 -0
- package/plugins/lisa-agy/commands/lisa/setup/workstation.md +7 -0
- package/plugins/lisa-agy/hooks/block-instruction-file-edits.agy.sh +58 -0
- package/plugins/lisa-agy/hooks/block-instruction-file-edits.sh +142 -0
- package/plugins/lisa-agy/hooks.json +13 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-setup-workstation/SKILL.md +121 -0
- package/plugins/lisa-agy/skills/lisa-setup-workstation/scripts/catalogue.mjs +262 -0
- package/plugins/lisa-agy/skills/lisa-setup-workstation/scripts/cli.mjs +315 -0
- package/plugins/lisa-agy/skills/lisa-setup-workstation/scripts/workstation.mjs +403 -0
- 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 +14 -1
- package/plugins/lisa-copilot/commands/lisa/setup/workstation.md +7 -0
- package/plugins/lisa-copilot/hooks/block-instruction-file-edits.sh +142 -0
- package/plugins/lisa-copilot/skills/lisa-setup-workstation/SKILL.md +121 -0
- package/plugins/lisa-copilot/skills/lisa-setup-workstation/scripts/catalogue.mjs +262 -0
- package/plugins/lisa-copilot/skills/lisa-setup-workstation/scripts/cli.mjs +315 -0
- package/plugins/lisa-copilot/skills/lisa-setup-workstation/scripts/workstation.mjs +403 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/commands/lisa/setup/workstation.md +7 -0
- package/plugins/lisa-cursor/hooks/block-instruction-file-edits.sh +142 -0
- package/plugins/lisa-cursor/hooks/hooks.json +8 -0
- package/plugins/lisa-cursor/skills/lisa-setup-workstation/SKILL.md +121 -0
- package/plugins/lisa-cursor/skills/lisa-setup-workstation/scripts/catalogue.mjs +262 -0
- package/plugins/lisa-cursor/skills/lisa-setup-workstation/scripts/cli.mjs +315 -0
- package/plugins/lisa-cursor/skills/lisa-setup-workstation/scripts/workstation.mjs +403 -0
- 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/.claude-plugin/plugin.json +13 -0
- package/plugins/src/base/commands/setup/workstation.md +7 -0
- package/plugins/src/base/hooks/block-instruction-file-edits.agy.sh +58 -0
- package/plugins/src/base/hooks/block-instruction-file-edits.sh +142 -0
- package/plugins/src/base/skills/lisa-setup-workstation/SKILL.md +121 -0
- package/plugins/src/base/skills/lisa-setup-workstation/scripts/catalogue.mjs +262 -0
- package/plugins/src/base/skills/lisa-setup-workstation/scripts/cli.mjs +315 -0
- package/plugins/src/base/skills/lisa-setup-workstation/scripts/workstation.mjs +403 -0
- package/scripts/build-plugins.sh +2 -1
- package/scripts/generate-agy-plugin-artifacts.mjs +11 -0
- package/scripts/lib/per-agent-hook-filter.mjs +10 -0
- package/scripts/lisa-enforcement-fallback.sh +4 -2
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a workstation can hold, and how each piece is installed.
|
|
3
|
+
*
|
|
4
|
+
* This is the layer beneath `remoteEnv.tools`: it describes a MACHINE, not a
|
|
5
|
+
* project, so nothing here is read from a checkout and no repository needs to
|
|
6
|
+
* exist. The project-scoped manifest still owns project-scoped tools.
|
|
7
|
+
*
|
|
8
|
+
* ## Install method is a per-tool property, not a global policy
|
|
9
|
+
*
|
|
10
|
+
* The obvious design — put every binary in `~/.local/bin` so one code path
|
|
11
|
+
* serves a laptop and a container alike — is wrong, and the agents show why:
|
|
12
|
+
*
|
|
13
|
+
* claude -> ~/.local/share/claude/versions/2.1.221
|
|
14
|
+
* cursor-agent -> ~/.local/share/cursor-agent/versions/2026.07.16-899851b
|
|
15
|
+
* codex -> ~/.codex/packages/standalone/current/bin/codex
|
|
16
|
+
*
|
|
17
|
+
* Each vendor manages its own version directory and `~/.local/bin` holds only a
|
|
18
|
+
* SYMLINK. Writing a raw binary there breaks the vendor's self-updater, which
|
|
19
|
+
* expects to swap a link target and would instead find a real file in its way.
|
|
20
|
+
*
|
|
21
|
+
* So each entry declares its own method, and the honest cost is recorded with
|
|
22
|
+
* it: a `vendor-script` entry is NOT pinned and NOT checksummed. That is a real
|
|
23
|
+
* weakening of the guarantee `assertPinned` exists to provide, so the kind is
|
|
24
|
+
* visible in the catalogue and in the report rather than blended in with
|
|
25
|
+
* checksummed entries. A reader can see which tools are trusted to a vendor.
|
|
26
|
+
* @module catalogue
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Lisa's supported coding agents. Kept in sync with the harness list. */
|
|
30
|
+
export const AGENTS = [
|
|
31
|
+
{
|
|
32
|
+
name: "claude",
|
|
33
|
+
label: "Claude Code",
|
|
34
|
+
kind: "vendor-script",
|
|
35
|
+
// The vendor script installs into ~/.local/share/claude/versions/<v> and
|
|
36
|
+
// links ~/.local/bin/claude at it, which is why we must not write there.
|
|
37
|
+
script: "https://claude.ai/install.sh",
|
|
38
|
+
selfUpdates: true,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "codex",
|
|
42
|
+
label: "Codex",
|
|
43
|
+
kind: "npm-global",
|
|
44
|
+
package: "@openai/codex",
|
|
45
|
+
selfUpdates: true,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "cursor-agent",
|
|
49
|
+
label: "Cursor",
|
|
50
|
+
kind: "vendor-script",
|
|
51
|
+
script: "https://cursor.com/install",
|
|
52
|
+
selfUpdates: true,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: "opencode",
|
|
56
|
+
label: "OpenCode",
|
|
57
|
+
kind: "vendor-script",
|
|
58
|
+
script: "https://opencode.ai/install",
|
|
59
|
+
// Installs to its own directory and appends a PATH line to the user's
|
|
60
|
+
// shell rc — which a non-interactive container never sources. Declared
|
|
61
|
+
// here so the run puts it on PATH itself rather than reporting a binary
|
|
62
|
+
// that is on disk as missing.
|
|
63
|
+
binDir: "~/.opencode/bin",
|
|
64
|
+
selfUpdates: true,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "agy",
|
|
68
|
+
label: "Antigravity",
|
|
69
|
+
kind: "manual",
|
|
70
|
+
// No headless installer published. Reported as absent with a pointer
|
|
71
|
+
// rather than silently skipped, because "not installed" and "cannot be
|
|
72
|
+
// installed by this tool" are different answers to the operator.
|
|
73
|
+
note: "no headless installer published; install from the vendor",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "copilot",
|
|
77
|
+
label: "GitHub Copilot CLI",
|
|
78
|
+
kind: "npm-global",
|
|
79
|
+
package: "@github/copilot",
|
|
80
|
+
selfUpdates: false,
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Credential managers, one of which a workstation may use.
|
|
86
|
+
*
|
|
87
|
+
* A CHOICE, not a fixed tool. `lisa-secrets-access` already treats the provider
|
|
88
|
+
* as an axis — bitwarden, 1password, doppler, vault, aws, or plain `env` — and
|
|
89
|
+
* a bootstrap that installed `bws` unconditionally would contradict that,
|
|
90
|
+
* pushing every workstation onto one vendor and quietly making the others
|
|
91
|
+
* second-class.
|
|
92
|
+
*
|
|
93
|
+
* `none` is a first-class answer. A machine using plain environment variables
|
|
94
|
+
* is a supported configuration, not a degraded one, so it must be selectable
|
|
95
|
+
* rather than merely what happens when a question is skipped.
|
|
96
|
+
*
|
|
97
|
+
* Only the SELECTED provider is installed. Installing all of them would put
|
|
98
|
+
* four unused credential CLIs on a machine, each an extra thing to keep patched
|
|
99
|
+
* for no benefit.
|
|
100
|
+
*/
|
|
101
|
+
export const PROVIDERS = [
|
|
102
|
+
{
|
|
103
|
+
name: "bitwarden",
|
|
104
|
+
binary: "bws",
|
|
105
|
+
label: "Bitwarden Secrets Manager",
|
|
106
|
+
// The one genuinely pinned credential CLI: Bitwarden publishes per-platform
|
|
107
|
+
// archives that can carry a checksum, and a credential bootstrap is exactly
|
|
108
|
+
// where an unverified download matters most.
|
|
109
|
+
kind: "release-zip",
|
|
110
|
+
version: "2.1.0",
|
|
111
|
+
platforms: {
|
|
112
|
+
"linux-x64": {
|
|
113
|
+
url: "https://github.com/bitwarden/sdk-sm/releases/download/bws-v2.1.0/bws-x86_64-unknown-linux-gnu-2.1.0.zip",
|
|
114
|
+
sha256:
|
|
115
|
+
"ba8233c3a4aee5d43e3c73bbd04d99e9bc5aba13bbbfd06d89b073abe732b860",
|
|
116
|
+
},
|
|
117
|
+
"linux-arm64": {
|
|
118
|
+
url: "https://github.com/bitwarden/sdk-sm/releases/download/bws-v2.1.0/bws-aarch64-unknown-linux-gnu-2.1.0.zip",
|
|
119
|
+
sha256:
|
|
120
|
+
"18253757286e119d450133a87eb463bf8c1ce418ce24c834f4f250d60cba6f9e",
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: "1password",
|
|
126
|
+
binary: "op",
|
|
127
|
+
label: "1Password CLI",
|
|
128
|
+
kind: "vendor-script",
|
|
129
|
+
note: "install from 1password.com/downloads/command-line",
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: "doppler",
|
|
133
|
+
binary: "doppler",
|
|
134
|
+
label: "Doppler CLI",
|
|
135
|
+
kind: "vendor-script",
|
|
136
|
+
script: "https://cli.doppler.com/install.sh",
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: "vault",
|
|
140
|
+
binary: "vault",
|
|
141
|
+
label: "HashiCorp Vault CLI",
|
|
142
|
+
kind: "vendor-script",
|
|
143
|
+
note: "install from developer.hashicorp.com/vault/install",
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: "aws",
|
|
147
|
+
binary: "aws",
|
|
148
|
+
label: "AWS Secrets Manager (via AWS CLI)",
|
|
149
|
+
kind: "vendor-script",
|
|
150
|
+
// Deliberately the same entry as the general AWS CLI below: a provider of
|
|
151
|
+
// `aws` needs exactly that binary and nothing extra.
|
|
152
|
+
note: "uses the AWS CLI, which is also a general tool",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "none",
|
|
156
|
+
binary: null,
|
|
157
|
+
label: "No credential manager (plain environment variables)",
|
|
158
|
+
kind: "none",
|
|
159
|
+
note: "supported configuration; nothing is installed",
|
|
160
|
+
},
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Tools every workstation needs regardless of project or provider.
|
|
165
|
+
*/
|
|
166
|
+
export const TOOLS = [
|
|
167
|
+
{
|
|
168
|
+
name: "git",
|
|
169
|
+
label: "git",
|
|
170
|
+
kind: "required",
|
|
171
|
+
note: "expected from the OS or base image",
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: "node",
|
|
175
|
+
label: "Node.js",
|
|
176
|
+
kind: "required",
|
|
177
|
+
minVersion: "22",
|
|
178
|
+
note: "expected from the OS or base image; the installers run on it",
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: "gh",
|
|
182
|
+
label: "GitHub CLI",
|
|
183
|
+
kind: "release-tar",
|
|
184
|
+
version: "2.83.0",
|
|
185
|
+
platforms: {
|
|
186
|
+
"linux-x64": {
|
|
187
|
+
url: "https://github.com/cli/cli/releases/download/v2.83.0/gh_2.83.0_linux_amd64.tar.gz",
|
|
188
|
+
sha256:
|
|
189
|
+
"a5cf6cdb40fc67751adf561126b3314044779cea81ba4f254fbe8e9a69f1676f",
|
|
190
|
+
binary: "gh_2.83.0_linux_amd64/bin/gh",
|
|
191
|
+
},
|
|
192
|
+
"linux-arm64": {
|
|
193
|
+
url: "https://github.com/cli/cli/releases/download/v2.83.0/gh_2.83.0_linux_arm64.tar.gz",
|
|
194
|
+
sha256:
|
|
195
|
+
"12311e320d4cfdb54d7fa2d58cd1e3a2ccb4c12e1c3abb32b0a2e48bd0f991bf",
|
|
196
|
+
binary: "gh_2.83.0_linux_arm64/bin/gh",
|
|
197
|
+
},
|
|
198
|
+
"darwin-arm64": {
|
|
199
|
+
url: "https://github.com/cli/cli/releases/download/v2.83.0/gh_2.83.0_macOS_arm64.zip",
|
|
200
|
+
sha256:
|
|
201
|
+
"fecba907bc361d5e33620dbf1145f11432c39fb2b388a839463cfbb89a84820b",
|
|
202
|
+
binary: "gh_2.83.0_macOS_arm64/bin/gh",
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: "aws",
|
|
208
|
+
label: "AWS CLI v2",
|
|
209
|
+
kind: "aws-cli",
|
|
210
|
+
// Not expressible as release-zip: the artifact is a ~73 MB archive
|
|
211
|
+
// containing an installer, not a single binary on PATH. It gets its own
|
|
212
|
+
// kind rather than a vendor-script URL because the steps in between —
|
|
213
|
+
// unzip, then `./aws/install` with an explicit prefix — are the whole job.
|
|
214
|
+
note: "archive wraps an installer rather than a binary",
|
|
215
|
+
selfUpdates: false,
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
name: "sonar",
|
|
219
|
+
label: "SonarQube CLI",
|
|
220
|
+
kind: "vendor-script",
|
|
221
|
+
// The vendor's real installer. The plausible-looking `sonarsource.com/install`
|
|
222
|
+
// that was here first 404s, and because a 404 piped to a shell exits 0, it
|
|
223
|
+
// was reported as installed on a machine that had no `sonar` at all.
|
|
224
|
+
script:
|
|
225
|
+
"https://raw.githubusercontent.com/SonarSource/sonarqube-cli/refs/heads/master/user-scripts/install.sh",
|
|
226
|
+
// Same shell-rc assumption as OpenCode.
|
|
227
|
+
binDir: "~/.local/share/sonarqube-cli/bin",
|
|
228
|
+
selfUpdates: true,
|
|
229
|
+
},
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
/** Install kinds this tool can actually perform, versus report only. */
|
|
233
|
+
export const INSTALLABLE = new Set([
|
|
234
|
+
"release-zip",
|
|
235
|
+
"release-tar",
|
|
236
|
+
"npm-global",
|
|
237
|
+
"vendor-script",
|
|
238
|
+
"aws-cli",
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
/** Kinds whose artifact is verified against a published checksum. */
|
|
242
|
+
export const CHECKSUMMED = new Set(["release-zip", "release-tar"]);
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Every directory a workstation must have on PATH.
|
|
246
|
+
*
|
|
247
|
+
* Vendors that manage their own version directory append a PATH line to the
|
|
248
|
+
* user's shell rc, which a non-interactive run — a container build, a cron, an
|
|
249
|
+
* agent — never sources. Collecting them here means one place decides what PATH
|
|
250
|
+
* must contain, used identically by the runtime and by the emitted Dockerfile.
|
|
251
|
+
* @param {string} home The home directory to expand `~` against.
|
|
252
|
+
* @returns {string[]} Absolute directories, most specific last.
|
|
253
|
+
*/
|
|
254
|
+
export function pathDirs(home) {
|
|
255
|
+
const declared = [...AGENTS, ...PROVIDERS, ...TOOLS]
|
|
256
|
+
.map(entry => entry.binDir)
|
|
257
|
+
.filter(Boolean)
|
|
258
|
+
.map(dir => dir.replace(/^~/, home));
|
|
259
|
+
// ~/.local/bin first: it is where the pinned, checksummed binaries land, and
|
|
260
|
+
// those must win over whatever an image happens to ship under the same name.
|
|
261
|
+
return [`${home}/.local/bin`, ...declared];
|
|
262
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Command-line entry for the workstation bootstrap.
|
|
4
|
+
*
|
|
5
|
+
* Kept separate from `workstation.mjs` so the planning logic stays importable
|
|
6
|
+
* and testable without a process boundary — a test should be able to ask "what
|
|
7
|
+
* would this do" without any risk of it doing it.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* cli.mjs report present vs missing
|
|
11
|
+
* cli.mjs --install provision what is missing
|
|
12
|
+
* cli.mjs --install --agents=claude,codex only these agents
|
|
13
|
+
* cli.mjs --provider=bitwarden credential manager (asked if a TTY)
|
|
14
|
+
* cli.mjs --json machine-readable plan
|
|
15
|
+
* cli.mjs --print-dockerfile an image that runs this same script
|
|
16
|
+
* @module cli
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { closeSync, existsSync, openSync, readSync, writeSync } from "node:fs";
|
|
20
|
+
import { dirname, resolve } from "node:path";
|
|
21
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
22
|
+
|
|
23
|
+
import { AGENTS, PROVIDERS, TOOLS, pathDirs } from "./catalogue.mjs";
|
|
24
|
+
import { installEntry, planWorkstation, renderPlan } from "./workstation.mjs";
|
|
25
|
+
|
|
26
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
|
|
28
|
+
/** Where pinned binaries land, and what must therefore be on PATH. */
|
|
29
|
+
export const BIN_DIR = `${process.env.HOME}/.local/bin`;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Borrow the pinned, checksummed installer the remote-env skill owns.
|
|
33
|
+
*
|
|
34
|
+
* Composed rather than reimplemented: the pins and checksums must be the same
|
|
35
|
+
* ones the remote surfaces use, or the two drift and only one gets fixed.
|
|
36
|
+
* Returns null when the sibling is absent, so a machine can still install the
|
|
37
|
+
* unpinned entries instead of failing wholesale — the report says which ones
|
|
38
|
+
* were skipped rather than passing them off as done.
|
|
39
|
+
* @returns {Promise<Function|null>} An installer, or null.
|
|
40
|
+
*/
|
|
41
|
+
export async function loadPinnedInstaller() {
|
|
42
|
+
const sibling = name =>
|
|
43
|
+
resolve(HERE, "..", "..", "lisa-setup-remote-env", "scripts", name);
|
|
44
|
+
const setup = sibling("setup-remote-env.mjs");
|
|
45
|
+
const toolchain = sibling("toolchain.mjs");
|
|
46
|
+
if (!existsSync(setup) || !existsSync(toolchain)) return null;
|
|
47
|
+
|
|
48
|
+
const { installTool, ensureBinDir } = await import(pathToFileURL(setup).href);
|
|
49
|
+
const { resolvePlatform } = await import(pathToFileURL(toolchain).href);
|
|
50
|
+
if (typeof installTool !== "function") return null;
|
|
51
|
+
|
|
52
|
+
return entry => {
|
|
53
|
+
// resolvePlatform must run first: installTool calls assertPinned, which
|
|
54
|
+
// refuses an entry that still carries a `platforms` map. Skipping it failed
|
|
55
|
+
// bws and gh with "platform-specific entry was not resolved before install"
|
|
56
|
+
// — the guard doing exactly its job.
|
|
57
|
+
const resolved = resolvePlatform(toManifestEntry(entry));
|
|
58
|
+
installTool(resolved, ensureBinDir ? ensureBinDir() : BIN_DIR);
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Translate a catalogue entry into the manifest shape the installer expects.
|
|
64
|
+
*
|
|
65
|
+
* The two vocabularies differ — the catalogue describes a machine, the manifest
|
|
66
|
+
* describes a project — so the seam is made explicit here rather than by giving
|
|
67
|
+
* the catalogue a shape it does not otherwise need. `kind` becomes `install`;
|
|
68
|
+
* everything else the manifest reads is already named the same.
|
|
69
|
+
* @param {object} entry Catalogue entry.
|
|
70
|
+
* @returns {object} A `remoteEnv.tools` style entry.
|
|
71
|
+
*/
|
|
72
|
+
export function toManifestEntry(entry) {
|
|
73
|
+
const { kind, label, binDir, note, selfUpdates, ...rest } = entry;
|
|
74
|
+
return { ...rest, install: kind };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Decide which credential manager to provision.
|
|
79
|
+
*
|
|
80
|
+
* Asked, never assumed. Defaulting to Bitwarden because that is what this repo
|
|
81
|
+
* happens to use would install a vendor CLI on a machine whose project resolves
|
|
82
|
+
* secrets some other way — and would make `none`, a supported configuration,
|
|
83
|
+
* reachable only by accident.
|
|
84
|
+
*
|
|
85
|
+
* Headless is the primary mode, so a missing answer without a TTY is `none`
|
|
86
|
+
* rather than a prompt that would hang a container build forever.
|
|
87
|
+
* @param {string|null} requested Value of --provider, if given.
|
|
88
|
+
* @param {object} io Injected ask/tty, for tests.
|
|
89
|
+
* @returns {string} A provider name from the catalogue.
|
|
90
|
+
*/
|
|
91
|
+
export function chooseProvider(requested, io = {}) {
|
|
92
|
+
if (requested) return requested;
|
|
93
|
+
|
|
94
|
+
const ask = io.ask ?? null;
|
|
95
|
+
const interactive = io.interactive ?? Boolean(process.stdin.isTTY);
|
|
96
|
+
if (!ask || !interactive) return "none";
|
|
97
|
+
|
|
98
|
+
const names = PROVIDERS.map(p => p.name);
|
|
99
|
+
const menu = PROVIDERS.map((p, i) => ` ${i + 1}) ${p.label}`).join("\n");
|
|
100
|
+
const answer = String(
|
|
101
|
+
ask(`Which credential manager does this machine use?\n${menu}\nChoice: `) ??
|
|
102
|
+
""
|
|
103
|
+
).trim();
|
|
104
|
+
|
|
105
|
+
// Accept either the menu number or the name, because an operator reading the
|
|
106
|
+
// list will reach for whichever is in front of them.
|
|
107
|
+
const index = Number.parseInt(answer, 10);
|
|
108
|
+
if (Number.isInteger(index) && index >= 1 && index <= names.length) {
|
|
109
|
+
return names[index - 1];
|
|
110
|
+
}
|
|
111
|
+
return names.includes(answer) ? answer : "none";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A Dockerfile that runs this same script, for a throwaway environment.
|
|
116
|
+
*
|
|
117
|
+
* Emitted rather than committed so it cannot drift from the catalogue it
|
|
118
|
+
* provisions. There is no Docker-specific code path anywhere in this skill —
|
|
119
|
+
* the image simply gives the script a `$HOME` that dies with the container.
|
|
120
|
+
*
|
|
121
|
+
* Deliberately bakes in NO repository. Clone inside the container, then run
|
|
122
|
+
* lisa-detect-tooling and lisa-setup-local-env for that project's own tools.
|
|
123
|
+
* @param {string[]} agents Agent names to install.
|
|
124
|
+
* @param {string} [provider] Credential manager to provision.
|
|
125
|
+
* @returns {string} Dockerfile text.
|
|
126
|
+
*/
|
|
127
|
+
export function renderDockerfile(agents, provider = "none") {
|
|
128
|
+
const list = agents.length ? agents.join(",") : "claude,codex";
|
|
129
|
+
const clean = "apt-get clean";
|
|
130
|
+
return [
|
|
131
|
+
"# Ephemeral coding environment. Build, use, discard.",
|
|
132
|
+
"# docker build -t lisa-workstation .",
|
|
133
|
+
"# docker run --rm -it lisa-workstation",
|
|
134
|
+
"FROM ubuntu:24.04",
|
|
135
|
+
"ENV DEBIAN_FRONTEND=noninteractive",
|
|
136
|
+
"RUN apt-get update -qq \\",
|
|
137
|
+
" && apt-get install -y -qq curl git unzip ca-certificates python3 \\",
|
|
138
|
+
" && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \\",
|
|
139
|
+
` && apt-get install -y -qq nodejs && ${clean}`,
|
|
140
|
+
// The whole skills/ tree, not just this skill: the checksummed installs are
|
|
141
|
+
// borrowed from lisa-setup-remote-env, and copying only this directory left
|
|
142
|
+
// bws and gh silently unprovisioned.
|
|
143
|
+
"COPY skills/ /opt/lisa/skills/",
|
|
144
|
+
// Set BEFORE the install, not after. Installers write into ~/.local/bin and
|
|
145
|
+
// the post-install check looks for the binary by bare name, so a PATH set
|
|
146
|
+
// afterwards would fail every verification in the image while passing on a
|
|
147
|
+
// laptop that already exports the directory.
|
|
148
|
+
`ENV PATH="${pathDirs("/root").join(":")}:\${PATH}"`,
|
|
149
|
+
// --provider is passed explicitly: the build has no TTY, so an omitted flag
|
|
150
|
+
// would resolve to `none` and produce an image with no way to reach secrets.
|
|
151
|
+
`RUN node /opt/lisa/skills/lisa-setup-workstation/scripts/cli.mjs \\`,
|
|
152
|
+
` --install --agents=${list} --provider=${provider}`,
|
|
153
|
+
'CMD ["bash"]',
|
|
154
|
+
].join("\n");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Run the CLI.
|
|
159
|
+
* @param {string[]} argv Arguments after the script name.
|
|
160
|
+
* @param {object} [io] Injected output sinks, for tests.
|
|
161
|
+
* @returns {number} Process exit code.
|
|
162
|
+
*/
|
|
163
|
+
export async function run(argv, io = {}) {
|
|
164
|
+
const out = io.log ?? console.log;
|
|
165
|
+
const err = io.error ?? console.error;
|
|
166
|
+
const flag = name => argv.includes(`--${name}`);
|
|
167
|
+
const value = name => {
|
|
168
|
+
const hit = argv.find(a => a.startsWith(`--${name}=`));
|
|
169
|
+
return hit ? hit.slice(name.length + 3) : null;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const selected = value("agents");
|
|
173
|
+
const agents = selected
|
|
174
|
+
? selected
|
|
175
|
+
.split(",")
|
|
176
|
+
.map(s => s.trim())
|
|
177
|
+
.filter(Boolean)
|
|
178
|
+
: null;
|
|
179
|
+
|
|
180
|
+
const provider = chooseProvider(value("provider"), io);
|
|
181
|
+
|
|
182
|
+
if (flag("print-dockerfile")) {
|
|
183
|
+
out(renderDockerfile(agents ?? [], provider));
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let plan;
|
|
188
|
+
try {
|
|
189
|
+
plan = planWorkstation({ agents, provider, ...(io.probes ?? {}) });
|
|
190
|
+
} catch (error) {
|
|
191
|
+
// A misspelled provider stops here rather than silently provisioning the
|
|
192
|
+
// wrong credential manager.
|
|
193
|
+
err(String(error.message));
|
|
194
|
+
return 1;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (flag("json")) {
|
|
198
|
+
out(JSON.stringify({ plan }, null, 2));
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
out(renderPlan(plan));
|
|
203
|
+
|
|
204
|
+
const blocked = plan.filter(r => r.action === "blocked");
|
|
205
|
+
if (blocked.length) {
|
|
206
|
+
err(
|
|
207
|
+
`\n${blocked.length} required tool(s) absent. These come from the OS or ` +
|
|
208
|
+
`base image and cannot be installed here.`
|
|
209
|
+
);
|
|
210
|
+
return 1;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const pending = plan.filter(r => r.action === "install");
|
|
214
|
+
if (!pending.length) {
|
|
215
|
+
// Idempotence made visible. A second run states it rather than going quiet,
|
|
216
|
+
// because silence reads as "did nothing because it broke".
|
|
217
|
+
out("\nNothing to install — this workstation is already prepared.");
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!flag("install")) {
|
|
222
|
+
out(
|
|
223
|
+
`\n${pending.length} missing. Re-run with --install to provision them. ` +
|
|
224
|
+
`Nothing has been installed or written.`
|
|
225
|
+
);
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
out("");
|
|
230
|
+
// Provider entries are keyed by vendor name but planned under their BINARY
|
|
231
|
+
// name (`bitwarden` -> `bws`), so they are re-keyed here or the lookup below
|
|
232
|
+
// silently finds nothing and the install is skipped without a word.
|
|
233
|
+
const catalogue = [
|
|
234
|
+
...AGENTS,
|
|
235
|
+
...TOOLS,
|
|
236
|
+
...PROVIDERS.filter(p => p.binary).map(p => ({ ...p, name: p.binary })),
|
|
237
|
+
];
|
|
238
|
+
const probes = io.probes ?? {};
|
|
239
|
+
const installPinned =
|
|
240
|
+
probes.installPinned ?? (await loadPinnedInstaller()) ?? undefined;
|
|
241
|
+
|
|
242
|
+
const results = [];
|
|
243
|
+
for (const row of pending) {
|
|
244
|
+
results.push(
|
|
245
|
+
installEntry(
|
|
246
|
+
catalogue.find(e => e.name === row.name),
|
|
247
|
+
row,
|
|
248
|
+
{ ...probes, installPinned }
|
|
249
|
+
)
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
for (const result of results) {
|
|
253
|
+
out(` ${result.action.padEnd(10)} ${result.label} ${result.reason}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const failed = results.filter(r => r.action === "failed");
|
|
257
|
+
if (failed.length) {
|
|
258
|
+
err(
|
|
259
|
+
`\n${failed.length} install(s) failed: ${failed
|
|
260
|
+
.map(r => r.name)
|
|
261
|
+
.join(", ")}. Nothing above was reported as installed unless the ` +
|
|
262
|
+
`binary was found on PATH afterwards.`
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
return failed.length ? 1 : 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Prompt on the terminal, synchronously.
|
|
270
|
+
*
|
|
271
|
+
* Reads `/dev/tty` rather than stdin so the question still works when the
|
|
272
|
+
* script is being piped — the ordinary way a bootstrap gets run.
|
|
273
|
+
* @param {string} prompt Text to display.
|
|
274
|
+
* @returns {string|null} The answer, or null if no terminal is attached.
|
|
275
|
+
*/
|
|
276
|
+
export function askTty(prompt) {
|
|
277
|
+
let fd;
|
|
278
|
+
try {
|
|
279
|
+
fd = openSync("/dev/tty", "r+");
|
|
280
|
+
} catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
writeSync(fd, prompt);
|
|
285
|
+
const buffer = Buffer.alloc(256);
|
|
286
|
+
const read = readSync(fd, buffer, 0, buffer.length, null);
|
|
287
|
+
return buffer.toString("utf8", 0, read).trim();
|
|
288
|
+
} catch {
|
|
289
|
+
return null;
|
|
290
|
+
} finally {
|
|
291
|
+
closeSync(fd);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Put the install directory on PATH before anything probes it.
|
|
297
|
+
*
|
|
298
|
+
* Installers write into `~/.local/bin`, and the post-install check then looks
|
|
299
|
+
* for the binary by bare name. That directory is on PATH by default on a
|
|
300
|
+
* developer workstation and is NOT on a minimal container, so without this
|
|
301
|
+
* every install in a container would report "exited 0 but still not on PATH"
|
|
302
|
+
* while every laptop run passed — the environment doing the verifying hiding
|
|
303
|
+
* the bug. Prepended, and compared entry-by-entry rather than by substring.
|
|
304
|
+
* @param {string} dir Directory to ensure is present.
|
|
305
|
+
* @param {object} [env] Environment to mutate.
|
|
306
|
+
*/
|
|
307
|
+
export function ensureOnPath(dir, env = process.env) {
|
|
308
|
+
const entries = (env.PATH ?? "").split(":").filter(Boolean);
|
|
309
|
+
if (!entries.includes(dir)) env.PATH = [dir, ...entries].join(":");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (process.argv[1] && process.argv[1].endsWith("cli.mjs")) {
|
|
313
|
+
for (const dir of pathDirs(process.env.HOME ?? "")) ensureOnPath(dir);
|
|
314
|
+
process.exitCode = await run(process.argv.slice(2), { ask: askTty });
|
|
315
|
+
}
|