@codyswann/lisa 3.16.0 → 3.17.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-gates.mjs +1165 -0
- package/all/copy-overwrite/scripts/lisa-reconcile-policy.mjs +1188 -0
- package/all/copy-overwrite/scripts/lisa-run-gates.mjs +597 -0
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +24 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +72 -8
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +6 -2
- package/plugins/lisa/.claude-plugin/plugin.json +10 -1
- package/plugins/lisa/.codex-plugin/hooks.json +9 -0
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa/hooks/secrets-preflight.sh +72 -0
- package/plugins/lisa/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- 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 +10 -1
- package/plugins/lisa-copilot/hooks/secrets-preflight.sh +72 -0
- package/plugins/lisa-copilot/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/hooks.json +3 -0
- package/plugins/lisa-cursor/hooks/secrets-preflight.sh +72 -0
- package/plugins/lisa-cursor/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- 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 +9 -0
- package/plugins/src/base/hooks/secrets-preflight.sh +72 -0
- package/plugins/src/base/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/src/base/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/src/base/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/scripts/generate-lisa-owned-hash-ledger.mjs +10 -1
- package/scripts/lib/per-agent-hook-filter.mjs +18 -0
- package/typescript/copy-contents/.husky/pre-commit +130 -21
- package/typescript/copy-contents/.husky/pre-push +202 -22
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive the credentials a project's vendor routing already implies.
|
|
3
|
+
*
|
|
4
|
+
* `.lisa.config.json` declares `tracker` and `source` — which tracker receives
|
|
5
|
+
* ticket writes, which system hosts PRDs. Those two lines already determine a
|
|
6
|
+
* set of credentials without which nothing works: `tracker: "github"` cannot
|
|
7
|
+
* write an issue without `GH_TOKEN`, `source: "notion"` cannot read a PRD
|
|
8
|
+
* without `NOTION_API_TOKEN`.
|
|
9
|
+
*
|
|
10
|
+
* Until now that mapping lived only in prose, in `config-resolution.md`'s
|
|
11
|
+
* invariants, with nothing connecting it to `secrets.require`. So the two could
|
|
12
|
+
* drift silently and adding a vendor never added its credential to the asserted
|
|
13
|
+
* set — the list was hand-maintained when most of it was deducible.
|
|
14
|
+
*
|
|
15
|
+
* This module is that deduction, as code. The floor is *unioned* into the
|
|
16
|
+
* required set at resolution time rather than checked against a hand-written
|
|
17
|
+
* list, because correctness should not depend on someone having remembered to
|
|
18
|
+
* type `GH_TOKEN` into a file. A project still declares its extras — an
|
|
19
|
+
* `ATTIO_API_KEY` no routing can imply — in `secrets.require`.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately not exhaustive about *optional* integrations. A credential
|
|
22
|
+
* appears here only when the routing that implies it makes the project
|
|
23
|
+
* non-functional without it. Sonar, Sentry and PostHog are configured
|
|
24
|
+
* separately and a project runs fine with none of them, so they are extras a
|
|
25
|
+
* project declares, not a floor anything derives.
|
|
26
|
+
* @module routing-floor
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The credential each destination tracker cannot operate without.
|
|
31
|
+
*
|
|
32
|
+
* Confluence and JIRA share `ATLASSIAN_API_TOKEN` because they are one vendor
|
|
33
|
+
* behind one token — the same reason `lisa-atlassian-access` serves both.
|
|
34
|
+
*/
|
|
35
|
+
const TRACKER_CREDENTIALS = {
|
|
36
|
+
jira: ["ATLASSIAN_API_TOKEN"],
|
|
37
|
+
github: ["GH_TOKEN"],
|
|
38
|
+
linear: ["LINEAR_API_KEY"],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** The credential each PRD source cannot be read without. */
|
|
42
|
+
const SOURCE_CREDENTIALS = {
|
|
43
|
+
notion: ["NOTION_API_TOKEN"],
|
|
44
|
+
confluence: ["ATLASSIAN_API_TOKEN"],
|
|
45
|
+
jira: ["ATLASSIAN_API_TOKEN"],
|
|
46
|
+
github: ["GH_TOKEN"],
|
|
47
|
+
linear: ["LINEAR_API_KEY"],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The credentials a project's routing makes mandatory.
|
|
52
|
+
*
|
|
53
|
+
* An unknown or absent vendor contributes nothing rather than throwing. This
|
|
54
|
+
* function answers "what does routing imply", and a `tracker` Lisa does not
|
|
55
|
+
* recognise is a *routing* error that `config-resolution`'s dispatch already
|
|
56
|
+
* reports with a better message than a credential check could. Failing here
|
|
57
|
+
* too would report the same defect twice, in the wrong vocabulary, and would
|
|
58
|
+
* make a preflight the place a typo in `tracker` first surfaces.
|
|
59
|
+
* @param {{tracker?: string, source?: string}} [routing] Vendor routing.
|
|
60
|
+
* @returns {readonly string[]} Sorted, de-duplicated credential names.
|
|
61
|
+
*/
|
|
62
|
+
export function routingFloor(routing = {}) {
|
|
63
|
+
const tracker = normalize(routing.tracker);
|
|
64
|
+
const source = normalize(routing.source);
|
|
65
|
+
const names = [
|
|
66
|
+
...credentialsFor(TRACKER_CREDENTIALS, tracker),
|
|
67
|
+
...credentialsFor(SOURCE_CREDENTIALS, source),
|
|
68
|
+
];
|
|
69
|
+
return [...new Set(names)].sort((left, right) => left.localeCompare(right));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Explain which routing key implied each credential.
|
|
74
|
+
*
|
|
75
|
+
* The preflight reports a missing credential to whoever has to fix it, and
|
|
76
|
+
* "GH_TOKEN is required" is a much weaker sentence than "GH_TOKEN is required
|
|
77
|
+
* because tracker is github". The second names the line of config to change if
|
|
78
|
+
* the requirement itself is wrong, which is the other valid remedy.
|
|
79
|
+
* @param {{tracker?: string, source?: string}} [routing] Vendor routing.
|
|
80
|
+
* @returns {Record<string, string[]>} Credential name to the reasons for it.
|
|
81
|
+
*/
|
|
82
|
+
export function routingFloorReasons(routing = {}) {
|
|
83
|
+
const tracker = normalize(routing.tracker);
|
|
84
|
+
const source = normalize(routing.source);
|
|
85
|
+
const reasons = {};
|
|
86
|
+
for (const name of credentialsFor(TRACKER_CREDENTIALS, tracker)) {
|
|
87
|
+
reasons[name] = [`tracker is "${tracker}"`];
|
|
88
|
+
}
|
|
89
|
+
for (const name of credentialsFor(SOURCE_CREDENTIALS, source)) {
|
|
90
|
+
reasons[name] = [...(reasons[name] ?? []), `source is "${source}"`];
|
|
91
|
+
}
|
|
92
|
+
return reasons;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Look a vendor up without consulting anything it inherited.
|
|
97
|
+
*
|
|
98
|
+
* A routing value reaches these maps as an arbitrary string from a config file,
|
|
99
|
+
* and plain indexing answers `constructor` and `__proto__` with inherited
|
|
100
|
+
* members rather than with undefined — so `?? []` never fires and spreading the
|
|
101
|
+
* result throws `not iterable`. `routingFloor` runs inside `readConfig`, which
|
|
102
|
+
* means a typo in `tracker` would abort configuration loading with an
|
|
103
|
+
* iterability error, in place of the documented behavior: an unrecognised
|
|
104
|
+
* vendor contributes nothing, and routing dispatch reports the typo in its own
|
|
105
|
+
* vocabulary.
|
|
106
|
+
* @param {Record<string, string[]>} map Credential map to read.
|
|
107
|
+
* @param {string} key Normalized vendor name.
|
|
108
|
+
* @returns {string[]} Declared credentials, empty for anything not declared.
|
|
109
|
+
*/
|
|
110
|
+
function credentialsFor(map, key) {
|
|
111
|
+
return Object.hasOwn(map, key) ? map[key] : [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Substrates that satisfy a credential without the variable being set.
|
|
116
|
+
*
|
|
117
|
+
* A required name is a proxy for a capability, not an end in itself, and some
|
|
118
|
+
* capabilities have a second legitimate route. `gh` authenticates from its own
|
|
119
|
+
* keyring after `gh auth login`, so a laptop can drive every GitHub operation
|
|
120
|
+
* Lisa performs with `GH_TOKEN` unset — which is the normal state of a
|
|
121
|
+
* developer machine, not a misconfiguration.
|
|
122
|
+
*
|
|
123
|
+
* Without this the floor fails a local session over a credential that surface
|
|
124
|
+
* demonstrably does not need. That is worse than not checking: a control that
|
|
125
|
+
* fires when nothing is wrong is one people learn to skip, and it would have
|
|
126
|
+
* fired on every session in Lisa's own repository.
|
|
127
|
+
*
|
|
128
|
+
* This mirrors the credential-substrate-precedence contract the access skills
|
|
129
|
+
* already follow — a capability is proven by whichever substrate can serve it,
|
|
130
|
+
* not by one hardcoded variable.
|
|
131
|
+
*
|
|
132
|
+
* Kept deliberately small. An entry belongs here only when the alternative is
|
|
133
|
+
* genuinely equivalent for everything Lisa does with that credential and can be
|
|
134
|
+
* proven by a cheap local command. `ATLASSIAN_API_TOKEN` is not listed even
|
|
135
|
+
* though `acli` exists, because the contract ranks the token first and acli
|
|
136
|
+
* covers only part of the surface.
|
|
137
|
+
*/
|
|
138
|
+
export const SUBSTITUTE_SUBSTRATES = {
|
|
139
|
+
GH_TOKEN: {
|
|
140
|
+
command: "gh",
|
|
141
|
+
args: ["auth", "status"],
|
|
142
|
+
describes: "the gh CLI is authenticated (gh auth login)",
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Lower-case a routing value, treating blank and absent as the same.
|
|
148
|
+
* @param {unknown} value Raw routing value.
|
|
149
|
+
* @returns {string} A comparable key, empty when nothing was declared.
|
|
150
|
+
*/
|
|
151
|
+
function normalize(value) {
|
|
152
|
+
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
153
|
+
}
|
|
@@ -18,6 +18,7 @@ import { homedir } from "node:os";
|
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
|
|
20
20
|
import { bootstrapKeyFor } from "./providers.mjs";
|
|
21
|
+
import { routingFloor } from "./routing-floor.mjs";
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Capabilities, per surface.
|
|
@@ -188,7 +189,7 @@ export function materializedPaths(namespace, env = process.env) {
|
|
|
188
189
|
* provider means the environment *is* the provider. A credentials manager is
|
|
189
190
|
* the preferred path, never a required one.
|
|
190
191
|
* @param {string} [cwd] Directory to look in.
|
|
191
|
-
* @returns {object} Resolved configuration with defaults applied.
|
|
192
|
+
* @returns {{provider: string, bootstrap: {sources: string[], key: string|null}, require: string[]|null, requiredFloor: readonly string[], rotating: string[], namespace: string, narrow: object, surface: string, routing: {tracker?: string, source?: string}, capabilities: object}} Resolved configuration with defaults applied.
|
|
192
193
|
*/
|
|
193
194
|
export function readConfig(cwd = process.cwd(), env = process.env) {
|
|
194
195
|
const path = join(cwd, ".lisa.config.json");
|
|
@@ -203,13 +204,19 @@ export function readConfig(cwd = process.cwd(), env = process.env) {
|
|
|
203
204
|
// so they can come from the environment instead. Everything else keeps its
|
|
204
205
|
// default, and an environment that sets neither still gets the old behaviour.
|
|
205
206
|
if (!existsSync(path)) return withSurface(fromEnvironment(env));
|
|
206
|
-
let
|
|
207
|
+
let root;
|
|
207
208
|
try {
|
|
208
|
-
|
|
209
|
+
root = JSON.parse(readFileSync(path, "utf8"));
|
|
209
210
|
} catch (err) {
|
|
210
211
|
throw new Error(`.lisa.config.json is not readable: ${err.message}`);
|
|
211
212
|
}
|
|
212
|
-
|
|
213
|
+
const cfg = root.secrets;
|
|
214
|
+
// Read outside the `secrets` block on purpose. The routing that implies a
|
|
215
|
+
// credential is declared once, at the top level, and restating it under
|
|
216
|
+
// `secrets` would be a second copy free to disagree with the one every other
|
|
217
|
+
// skill dispatches on.
|
|
218
|
+
const routing = { tracker: root.tracker, source: root.source };
|
|
219
|
+
if (!cfg) return withSurface({ ...DEFAULTS, routing });
|
|
213
220
|
const provider = cfg.provider ?? DEFAULTS.provider;
|
|
214
221
|
const namespace = assertNamespace(cfg.namespace ?? DEFAULTS.namespace);
|
|
215
222
|
return withSurface({
|
|
@@ -221,6 +228,7 @@ export function readConfig(cwd = process.cwd(), env = process.env) {
|
|
|
221
228
|
namespace,
|
|
222
229
|
narrow: { ...DEFAULTS.narrow, ...(cfg.narrow ?? {}) },
|
|
223
230
|
surface: cfg.surface ?? null,
|
|
231
|
+
routing,
|
|
224
232
|
});
|
|
225
233
|
}
|
|
226
234
|
|
|
@@ -339,11 +347,51 @@ function fromEnvironment(env) {
|
|
|
339
347
|
}
|
|
340
348
|
|
|
341
349
|
/**
|
|
342
|
-
* Attach the resolved surface
|
|
350
|
+
* Attach the resolved surface, its capabilities, and the required sets.
|
|
351
|
+
*
|
|
352
|
+
* `require` is flattened here rather than at each caller because this is the
|
|
353
|
+
* one place that knows the surface, and a surface-scoped list cannot be read
|
|
354
|
+
* without it.
|
|
343
355
|
* @param {object} cfg Configuration without surface resolution.
|
|
344
|
-
* @returns {object} The same configuration plus
|
|
356
|
+
* @returns {object} The same configuration plus surface-derived fields.
|
|
345
357
|
*/
|
|
346
358
|
function withSurface(cfg) {
|
|
347
359
|
const surface = detectSurface(cfg.surface);
|
|
348
|
-
return {
|
|
360
|
+
return {
|
|
361
|
+
...cfg,
|
|
362
|
+
surface,
|
|
363
|
+
capabilities: SURFACES[surface],
|
|
364
|
+
require: resolveRequire(cfg.require, surface),
|
|
365
|
+
requiredFloor: routingFloor(cfg.routing ?? {}),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Flatten a `require` declaration for one surface.
|
|
371
|
+
*
|
|
372
|
+
* Two shapes are accepted. An array is every surface — the original meaning,
|
|
373
|
+
* and still correct for the many projects whose credentials do not vary. An
|
|
374
|
+
* object opts into scoping: `all` applies everywhere and a key matching the
|
|
375
|
+
* surface adds to it.
|
|
376
|
+
*
|
|
377
|
+
* Scoping exists because the required set genuinely differs by where the agent
|
|
378
|
+
* runs. `CLAUDE_ROUTINE_TOKEN` is mandatory on `claude-web` and meaningless on
|
|
379
|
+
* a laptop; asserting one flat list everywhere would fail a developer's session
|
|
380
|
+
* over a credential only a cloud surface uses, and the reliable response to a
|
|
381
|
+
* check that fails for the wrong reason is to stop believing it.
|
|
382
|
+
*
|
|
383
|
+
* Returns `null` — not `[]` — when nothing is declared, because the two mean
|
|
384
|
+
* different things downstream: `null` leaves resolution un-narrowed, while an
|
|
385
|
+
* empty array would declare that the project needs no secrets and make
|
|
386
|
+
* `assertDeclared` reject every name.
|
|
387
|
+
* @param {string[]|Record<string, string[]>|null|undefined} raw Declaration.
|
|
388
|
+
* @param {string} surface Resolved surface key.
|
|
389
|
+
* @returns {string[]|null} Names required on this surface, or null.
|
|
390
|
+
*/
|
|
391
|
+
function resolveRequire(raw, surface) {
|
|
392
|
+
if (!raw) return null;
|
|
393
|
+
if (Array.isArray(raw)) return raw;
|
|
394
|
+
if (typeof raw !== "object") return null;
|
|
395
|
+
const names = [...(raw.all ?? []), ...(raw[surface] ?? [])];
|
|
396
|
+
return names.length ? [...new Set(names)] : null;
|
|
349
397
|
}
|
|
@@ -206,22 +206,11 @@ export function validateSecrets(secrets) {
|
|
|
206
206
|
);
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
216
|
-
for (const name of value) {
|
|
217
|
-
if (typeof name !== "string" || !/^[A-Z][A-Z0-9_]*$/.test(name)) {
|
|
218
|
-
problems.push(
|
|
219
|
-
`secrets.${field} entry ${JSON.stringify(name)} is not an exact ` +
|
|
220
|
-
`UPPER_SNAKE_CASE environment-variable name. Lookup is never fuzzy.`
|
|
221
|
-
);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
209
|
+
// `rotating` is a flat list on every surface. `require` also accepts a
|
|
210
|
+
// surface-scoped object, because the required set genuinely differs by where
|
|
211
|
+
// the agent runs — see `resolveRequire` in surfaces.mjs.
|
|
212
|
+
validateKeyList("rotating", secrets.rotating, problems);
|
|
213
|
+
validateRequire(secrets.require, problems);
|
|
225
214
|
|
|
226
215
|
problems.push(...validatePropagating(secrets.propagating));
|
|
227
216
|
|
|
@@ -263,6 +252,73 @@ export function validateSecrets(secrets) {
|
|
|
263
252
|
* @param {object} entry The entry or platform block.
|
|
264
253
|
* @param {string[]} problems Accumulator.
|
|
265
254
|
*/
|
|
255
|
+
/**
|
|
256
|
+
* Whether a value parses as a dotted version the comparator can order.
|
|
257
|
+
* @param {unknown} value Candidate version.
|
|
258
|
+
* @returns {boolean} True for "20", "1.2", "1.2.3" and similar.
|
|
259
|
+
*/
|
|
260
|
+
function isVersionish(value) {
|
|
261
|
+
return typeof value === "string" && /^\d+(\.\d+)*$/.test(value.trim());
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Validate a flat list of exact environment-variable names.
|
|
266
|
+
* @param {string} field Config field name, for messages.
|
|
267
|
+
* @param {unknown} value The declared value.
|
|
268
|
+
* @param {string[]} problems Collector.
|
|
269
|
+
*/
|
|
270
|
+
function validateKeyList(field, value, problems) {
|
|
271
|
+
if (value === undefined || value === null) return;
|
|
272
|
+
if (!Array.isArray(value)) {
|
|
273
|
+
problems.push(`secrets.${field} must be an array of exact key names`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
for (const name of value) {
|
|
277
|
+
if (typeof name !== "string" || !/^[A-Z][A-Z0-9_]*$/.test(name)) {
|
|
278
|
+
problems.push(
|
|
279
|
+
`secrets.${field} entry ${JSON.stringify(name)} is not an exact ` +
|
|
280
|
+
`UPPER_SNAKE_CASE environment-variable name. Lookup is never fuzzy.`
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Validate `require` in either of its two shapes.
|
|
288
|
+
*
|
|
289
|
+
* An array means every surface. An object scopes per surface, with `all`
|
|
290
|
+
* applying everywhere. Unknown keys are rejected rather than ignored: a typo
|
|
291
|
+
* like `github_actions` for `github-actions` would otherwise resolve to an
|
|
292
|
+
* empty list and silently assert nothing on the one surface it was written
|
|
293
|
+
* for — a declaration that reads as protection and delivers none.
|
|
294
|
+
* @param {unknown} value The declared value.
|
|
295
|
+
* @param {string[]} problems Collector.
|
|
296
|
+
*/
|
|
297
|
+
function validateRequire(value, problems) {
|
|
298
|
+
if (value === undefined || value === null) return;
|
|
299
|
+
if (Array.isArray(value)) {
|
|
300
|
+
validateKeyList("require", value, problems);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (typeof value !== "object") {
|
|
304
|
+
problems.push(
|
|
305
|
+
`secrets.require must be an array of key names, or an object keyed by ` +
|
|
306
|
+
`surface with an optional "all" entry`
|
|
307
|
+
);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
for (const [key, names] of Object.entries(value)) {
|
|
311
|
+
if (key !== "all" && !SURFACES.has(key)) {
|
|
312
|
+
problems.push(
|
|
313
|
+
`secrets.require key "${key}" is not a known surface. ` +
|
|
314
|
+
`Known: all, ${[...SURFACES].join(", ")}.`
|
|
315
|
+
);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
validateKeyList(`require.${key}`, names, problems);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
266
322
|
function validateInstallArtifact(label, entry, problems) {
|
|
267
323
|
if (!INSTALL_METHODS.has(entry.install)) {
|
|
268
324
|
problems.push(
|
|
@@ -308,7 +364,23 @@ export function validateRemoteEnv(remoteEnv) {
|
|
|
308
364
|
const problems = [];
|
|
309
365
|
|
|
310
366
|
for (const tool of remoteEnv.tools?.require ?? []) {
|
|
311
|
-
if (!tool.name)
|
|
367
|
+
if (!tool.name) {
|
|
368
|
+
problems.push("remoteEnv.tools.require entry has no name");
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
// `minVersion` was accepted unvalidated while `install` entries had their
|
|
372
|
+
// shape checked in full. The asymmetry mattered: the value is fed to a
|
|
373
|
+
// version comparison, so `minVersion: "twenty"` sorted below every real
|
|
374
|
+
// version and the requirement silently passed for any installed release —
|
|
375
|
+
// a declared constraint that enforced nothing.
|
|
376
|
+
if (tool.minVersion !== undefined && !isVersionish(tool.minVersion)) {
|
|
377
|
+
problems.push(
|
|
378
|
+
`remoteEnv require "${tool.name}" has minVersion ` +
|
|
379
|
+
`${JSON.stringify(tool.minVersion)}, which is not a dotted version ` +
|
|
380
|
+
`like "20" or "1.2.3". It is compared numerically, so a value that ` +
|
|
381
|
+
`does not parse would accept every installed version.`
|
|
382
|
+
);
|
|
383
|
+
}
|
|
312
384
|
}
|
|
313
385
|
for (const tool of remoteEnv.tools?.install ?? []) {
|
|
314
386
|
if (!tool.name) {
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prove, before an agent starts work, that the CLIs it needs are on PATH.
|
|
3
|
+
*
|
|
4
|
+
* The symmetric half of `preflight-secrets.mjs`, and it exists for the same
|
|
5
|
+
* reason: the logic to answer the question was already good, and nothing asked
|
|
6
|
+
* it at the moment the answer mattered. `planToolchain` decides presence,
|
|
7
|
+
* minimum version, and installability correctly — but its only callers were
|
|
8
|
+
* `/lisa:setup:local-env`, which a human runs deliberately, and container
|
|
9
|
+
* provisioning. An agent on a laptop could claim a ticket, cut a branch, and
|
|
10
|
+
* discover `maestro` was never installed forty minutes later.
|
|
11
|
+
*
|
|
12
|
+
* **This is a caller, not a checker.** Every decision comes from
|
|
13
|
+
* `planToolchain`. Writing a second implementation is exactly the defect this
|
|
14
|
+
* change also fixes elsewhere — `verify-remote-env.mjs` grew its own toolchain
|
|
15
|
+
* check that forgot `minVersion`, so a container verified clean against a node
|
|
16
|
+
* older than the manifest demanded. One function, one verdict, no drift.
|
|
17
|
+
*
|
|
18
|
+
* **Two verdicts, not three.** `preflight-secrets` needs `unreachable` because
|
|
19
|
+
* a vault can fail to be asked. Probing a local binary cannot: it is present or
|
|
20
|
+
* it is not. The analogous trap — a tool whose version cannot be parsed — is
|
|
21
|
+
* already handled correctly upstream, because `planRequired` compares
|
|
22
|
+
* `found.version ?? "0"` and an unknown version loses every `minVersion`
|
|
23
|
+
* comparison. It fails closed without needing a verdict of its own.
|
|
24
|
+
*
|
|
25
|
+
* **The one thing tools have that credentials do not** is a self-service
|
|
26
|
+
* remedy. An `install` entry is pinned and checksummed, so "missing" splits
|
|
27
|
+
* into "Lisa can place this for you" and "you must act". A credential can never
|
|
28
|
+
* be self-provisioned, which is why that distinction has no secrets equivalent.
|
|
29
|
+
* @module preflight-tools
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
33
|
+
import { join } from "node:path";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
|
|
36
|
+
import { probe, readRemoteEnvConfig } from "./setup-remote-env.mjs";
|
|
37
|
+
import { currentPlatform, planToolchain } from "./toolchain.mjs";
|
|
38
|
+
import { toolFloor } from "./tool-floor.mjs";
|
|
39
|
+
|
|
40
|
+
/** Plan actions that mean the agent cannot use the tool right now. */
|
|
41
|
+
const BLOCKING = new Set(["missing", "invalid"]);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read the whole config, for the derivations that live outside `remoteEnv`.
|
|
45
|
+
*
|
|
46
|
+
* The floor is implied by `tracker`, `secrets.provider` and `quality`, none of
|
|
47
|
+
* which sit under `remoteEnv` — so this reads the root rather than reusing
|
|
48
|
+
* `readRemoteEnvConfig`, which deliberately returns only its own block.
|
|
49
|
+
*
|
|
50
|
+
* **Absent and damaged are not the same answer.** A project with no
|
|
51
|
+
* `.lisa.config.json` declares no tools, and `{}` states that correctly. A file
|
|
52
|
+
* that exists and does not parse states nothing — and returning `{}` for it
|
|
53
|
+
* derives an empty floor, which reports `ok` precisely when the declaration
|
|
54
|
+
* this check exists to enforce could not be read. That is the vacuous green
|
|
55
|
+
* `preflight-secrets` refuses in its own header, so this throws instead, the
|
|
56
|
+
* same way `readConfig` in `surfaces.mjs` does.
|
|
57
|
+
* @param {string} [cwd] Directory to look in.
|
|
58
|
+
* @returns {object} Parsed config root, empty only when the file is absent.
|
|
59
|
+
* @throws {Error} When the file exists but cannot be read or parsed.
|
|
60
|
+
*/
|
|
61
|
+
export function readConfigRoot(cwd = process.cwd()) {
|
|
62
|
+
const path = join(cwd, ".lisa.config.json");
|
|
63
|
+
if (!existsSync(path)) return {};
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
66
|
+
} catch (err) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`${path} is not readable, so the tools it requires could not be ` +
|
|
69
|
+
`derived and nothing was checked: ${err.message}`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Merge the derived floor into a declared manifest.
|
|
76
|
+
*
|
|
77
|
+
* A derived tool that the project already declares is dropped rather than
|
|
78
|
+
* duplicated, and the declaration wins: a project that pinned `minVersion` for
|
|
79
|
+
* `gh` has said something more specific than the derivation knows, and
|
|
80
|
+
* overriding it would discard the more informed statement.
|
|
81
|
+
* @param {{require?: object[], install?: object[]}} tools Declared manifest.
|
|
82
|
+
* @param {Array<{name: string, reason: string}>} floor Derived tools.
|
|
83
|
+
* @returns {{tools: {require: object[], install?: object[]}, reasons: Record<string, string>}} Merged manifest.
|
|
84
|
+
*/
|
|
85
|
+
export function mergeFloor(tools, floor) {
|
|
86
|
+
const declared = new Set([
|
|
87
|
+
...(tools.require ?? []).map(tool => tool.name),
|
|
88
|
+
...(tools.install ?? []).map(tool => tool.name),
|
|
89
|
+
]);
|
|
90
|
+
const added = floor.filter(entry => !declared.has(entry.name));
|
|
91
|
+
const reasons = Object.fromEntries(
|
|
92
|
+
floor.map(entry => [entry.name, entry.reason])
|
|
93
|
+
);
|
|
94
|
+
return {
|
|
95
|
+
tools: {
|
|
96
|
+
...tools,
|
|
97
|
+
require: [
|
|
98
|
+
...(tools.require ?? []),
|
|
99
|
+
...added.map(entry => ({ name: entry.name })),
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
reasons,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Check every required tool against this machine.
|
|
108
|
+
* @param {object} [config] Parsed config root.
|
|
109
|
+
* @param {object} [remoteEnv] Parsed `remoteEnv` block.
|
|
110
|
+
* @param {Function} [versionProbe] Version probe, injected for tests.
|
|
111
|
+
* @param {string} [platform] Platform key, injected for tests.
|
|
112
|
+
* @returns {{verdict: string, blocked: Array<{name: string, action: string, reason: string}>, installable: Array<{name: string, action: string, reason: string}>, reasons: Record<string, string>}}
|
|
113
|
+
*/
|
|
114
|
+
export function preflightTools(
|
|
115
|
+
config = readConfigRoot(),
|
|
116
|
+
remoteEnv = readRemoteEnvConfig(),
|
|
117
|
+
versionProbe = probe,
|
|
118
|
+
platform = currentPlatform()
|
|
119
|
+
) {
|
|
120
|
+
const { tools, reasons } = mergeFloor(
|
|
121
|
+
remoteEnv.tools ?? {},
|
|
122
|
+
toolFloor(config)
|
|
123
|
+
);
|
|
124
|
+
if (!(tools.require ?? []).length && !(tools.install ?? []).length) {
|
|
125
|
+
return { verdict: "ok", blocked: [], installable: [], reasons };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// "local" because this runs where the agent is, and it is the surface word
|
|
129
|
+
// `appliesToSurface` already understands — a tool narrowed to ["remote"] is
|
|
130
|
+
// correctly ignored on a laptop rather than reported as missing there.
|
|
131
|
+
const plan = planToolchain(tools, versionProbe, "local", platform);
|
|
132
|
+
const blocked = plan.filter(step => BLOCKING.has(step.action));
|
|
133
|
+
const installable = plan.filter(step => step.action === "install");
|
|
134
|
+
return {
|
|
135
|
+
verdict: blocked.length || installable.length ? "missing" : "ok",
|
|
136
|
+
blocked,
|
|
137
|
+
installable,
|
|
138
|
+
reasons,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Render a verdict for whoever has to act on it.
|
|
144
|
+
* @param {object} result A {@link preflightTools} result.
|
|
145
|
+
* @returns {string} Operator-readable report, empty when nothing needs saying.
|
|
146
|
+
*/
|
|
147
|
+
export function reportTools(result) {
|
|
148
|
+
if (result.verdict === "ok") return "";
|
|
149
|
+
|
|
150
|
+
// The header tracks the exit code. Only a blocked tool is a failure — one
|
|
151
|
+
// Lisa can install is an action with a command attached, and calling that
|
|
152
|
+
// "FAILED" while exiting zero teaches readers that the word means nothing.
|
|
153
|
+
const lines = [
|
|
154
|
+
result.blocked.length
|
|
155
|
+
? "Tooling preflight FAILED."
|
|
156
|
+
: "Tooling preflight — action available.",
|
|
157
|
+
];
|
|
158
|
+
if (result.installable.length) {
|
|
159
|
+
lines.push(
|
|
160
|
+
``,
|
|
161
|
+
`Lisa can install these itself — they are pinned and checksummed.`,
|
|
162
|
+
`Run /lisa:setup:local-env to place them:`,
|
|
163
|
+
``
|
|
164
|
+
);
|
|
165
|
+
for (const step of result.installable) {
|
|
166
|
+
lines.push(` ${step.name} — ${step.reason}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (result.blocked.length) {
|
|
170
|
+
lines.push(
|
|
171
|
+
``,
|
|
172
|
+
`These need you. Lisa has no pinned artifact it can place:`,
|
|
173
|
+
``
|
|
174
|
+
);
|
|
175
|
+
for (const step of result.blocked) {
|
|
176
|
+
const why = result.reasons[step.name];
|
|
177
|
+
lines.push(` ${step.name} — ${step.reason}`);
|
|
178
|
+
if (why) lines.push(` required because ${why}`);
|
|
179
|
+
}
|
|
180
|
+
lines.push(
|
|
181
|
+
``,
|
|
182
|
+
`Work needing one of these cannot be completed. Route the item to`,
|
|
183
|
+
`blocked with this reason rather than claiming it and stopping partway.`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return lines.join("\n");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* CLI entry point. Prints the report and exits non-zero when anything blocks.
|
|
191
|
+
*/
|
|
192
|
+
function main() {
|
|
193
|
+
let result;
|
|
194
|
+
try {
|
|
195
|
+
result = preflightTools();
|
|
196
|
+
} catch (err) {
|
|
197
|
+
// A config that cannot be read is its own outcome, and it is not "ok". Said
|
|
198
|
+
// in the operator's vocabulary rather than as a stack trace, because this
|
|
199
|
+
// text is what the session-start hook injects for someone to act on.
|
|
200
|
+
console.error(
|
|
201
|
+
[
|
|
202
|
+
`Tooling preflight could NOT be completed.`,
|
|
203
|
+
``,
|
|
204
|
+
` reason: ${err.message}`,
|
|
205
|
+
``,
|
|
206
|
+
`Nothing was checked, so this is not a report that the toolchain is`,
|
|
207
|
+
`fine. Repair the configuration, then start a new session.`,
|
|
208
|
+
].join("\n")
|
|
209
|
+
);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
const text = reportTools(result);
|
|
213
|
+
if (text) console.error(text);
|
|
214
|
+
if (result.blocked.length) process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Whether this module is the entry point node was asked to run.
|
|
219
|
+
*
|
|
220
|
+
* Both sides are realpath'd rather than compared as text: `import.meta.url` is
|
|
221
|
+
* the resolved path while `process.argv[1]` is whatever the caller typed, so a
|
|
222
|
+
* symlinked path — every git worktree, and every `/tmp` path on macOS — makes a
|
|
223
|
+
* raw comparison false. The module then loads, runs nothing, and exits 0, which
|
|
224
|
+
* is a readiness check reporting clean because it never ran. Same rule and same
|
|
225
|
+
* reasoning as `scripts/lib/invoked-as-script.mjs`, written out here because a
|
|
226
|
+
* plugin payload has no `./lib/` to import from.
|
|
227
|
+
* @param {string} moduleUrl This module's own `import.meta.url`.
|
|
228
|
+
* @param {string} [argv1] Entry path; defaults to `process.argv[1]`.
|
|
229
|
+
* @returns {boolean} Whether the CLI body should run.
|
|
230
|
+
*/
|
|
231
|
+
function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
|
|
232
|
+
if (!argv1) return false;
|
|
233
|
+
try {
|
|
234
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
|
|
235
|
+
} catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (invokedAsScript(import.meta.url)) {
|
|
241
|
+
main();
|
|
242
|
+
}
|