@codyswann/lisa 2.316.2 → 2.317.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 +139 -9
- 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-atlassian-access/SKILL.md +17 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-automation-status/SKILL.md +23 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-doctor/SKILL.md +33 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-implement/SKILL.md +17 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-notion-access/SKILL.md +17 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-remote-dispatch/SKILL.md +99 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-remote-dispatch/agents/openai.yaml +4 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-remote-dispatch/scripts/dispatch.mjs +264 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/SKILL.md +137 -51
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +217 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/envfile.mjs +78 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +105 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/providers.mjs +240 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/read-secret-note.mjs +82 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/resolve-secret.mjs +151 -176
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/rotate-secret.mjs +266 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/surfaces.mjs +147 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/validate-config.mjs +224 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-automations/SKILL.md +69 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-automations/scripts/generate-workflow.mjs +185 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/SKILL.md +150 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/agents/openai.yaml +4 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/assets/setup.sh +45 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +227 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/toolchain.mjs +169 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +186 -0
- package/plugins/lisa/commands/implement.md +5 -3
- package/plugins/lisa/commands/setup/remote-env.md +7 -0
- package/plugins/lisa/skills/lisa-atlassian-access/SKILL.md +17 -0
- package/plugins/lisa/skills/lisa-automation-status/SKILL.md +23 -0
- package/plugins/lisa/skills/lisa-doctor/SKILL.md +33 -0
- package/plugins/lisa/skills/lisa-implement/SKILL.md +17 -0
- package/plugins/lisa/skills/lisa-notion-access/SKILL.md +17 -0
- package/plugins/lisa/skills/lisa-remote-dispatch/SKILL.md +99 -0
- package/plugins/lisa/skills/lisa-remote-dispatch/agents/openai.yaml +4 -0
- package/plugins/lisa/skills/lisa-remote-dispatch/scripts/dispatch.mjs +264 -0
- package/plugins/lisa/skills/lisa-secrets-access/SKILL.md +138 -52
- package/plugins/lisa/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +217 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/envfile.mjs +78 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +105 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/providers.mjs +240 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/read-secret-note.mjs +82 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/resolve-secret.mjs +151 -176
- package/plugins/lisa/skills/lisa-secrets-access/scripts/rotate-secret.mjs +266 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/surfaces.mjs +147 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/validate-config.mjs +224 -0
- package/plugins/lisa/skills/lisa-setup-automations/SKILL.md +69 -0
- package/plugins/lisa/skills/lisa-setup-automations/scripts/generate-workflow.mjs +185 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/SKILL.md +150 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/agents/openai.yaml +4 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/assets/setup.sh +45 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +227 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/toolchain.mjs +169 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +186 -0
- package/plugins/lisa-agy/commands/lisa/implement.md +5 -3
- package/plugins/lisa-agy/commands/lisa/setup/remote-env.md +7 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-atlassian-access/SKILL.md +17 -0
- package/plugins/lisa-agy/skills/lisa-automation-status/SKILL.md +23 -0
- package/plugins/lisa-agy/skills/lisa-doctor/SKILL.md +33 -0
- package/plugins/lisa-agy/skills/lisa-implement/SKILL.md +17 -0
- package/plugins/lisa-agy/skills/lisa-notion-access/SKILL.md +17 -0
- package/plugins/lisa-agy/skills/lisa-remote-dispatch/SKILL.md +99 -0
- package/plugins/lisa-agy/skills/lisa-remote-dispatch/scripts/dispatch.mjs +264 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/SKILL.md +138 -52
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +217 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/envfile.mjs +78 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +105 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/providers.mjs +240 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/read-secret-note.mjs +82 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/resolve-secret.mjs +151 -176
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/rotate-secret.mjs +266 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/surfaces.mjs +147 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/validate-config.mjs +224 -0
- package/plugins/lisa-agy/skills/lisa-setup-automations/SKILL.md +69 -0
- package/plugins/lisa-agy/skills/lisa-setup-automations/scripts/generate-workflow.mjs +185 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/SKILL.md +150 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/assets/setup.sh +45 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +227 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/toolchain.mjs +169 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +186 -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 +1 -1
- package/plugins/lisa-copilot/commands/lisa/implement.md +5 -3
- package/plugins/lisa-copilot/commands/lisa/setup/remote-env.md +7 -0
- package/plugins/lisa-copilot/skills/lisa-atlassian-access/SKILL.md +17 -0
- package/plugins/lisa-copilot/skills/lisa-automation-status/SKILL.md +23 -0
- package/plugins/lisa-copilot/skills/lisa-doctor/SKILL.md +33 -0
- package/plugins/lisa-copilot/skills/lisa-implement/SKILL.md +17 -0
- package/plugins/lisa-copilot/skills/lisa-notion-access/SKILL.md +17 -0
- package/plugins/lisa-copilot/skills/lisa-remote-dispatch/SKILL.md +99 -0
- package/plugins/lisa-copilot/skills/lisa-remote-dispatch/scripts/dispatch.mjs +264 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/SKILL.md +138 -52
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +217 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/envfile.mjs +78 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +105 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/providers.mjs +240 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/read-secret-note.mjs +82 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/resolve-secret.mjs +151 -176
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/rotate-secret.mjs +266 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/surfaces.mjs +147 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/validate-config.mjs +224 -0
- package/plugins/lisa-copilot/skills/lisa-setup-automations/SKILL.md +69 -0
- package/plugins/lisa-copilot/skills/lisa-setup-automations/scripts/generate-workflow.mjs +185 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/SKILL.md +150 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/assets/setup.sh +45 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +227 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/toolchain.mjs +169 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +186 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/commands/lisa/implement.md +5 -3
- package/plugins/lisa-cursor/commands/lisa/setup/remote-env.md +7 -0
- package/plugins/lisa-cursor/skills/lisa-atlassian-access/SKILL.md +17 -0
- package/plugins/lisa-cursor/skills/lisa-automation-status/SKILL.md +23 -0
- package/plugins/lisa-cursor/skills/lisa-doctor/SKILL.md +33 -0
- package/plugins/lisa-cursor/skills/lisa-implement/SKILL.md +17 -0
- package/plugins/lisa-cursor/skills/lisa-notion-access/SKILL.md +17 -0
- package/plugins/lisa-cursor/skills/lisa-remote-dispatch/SKILL.md +99 -0
- package/plugins/lisa-cursor/skills/lisa-remote-dispatch/scripts/dispatch.mjs +264 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/SKILL.md +138 -52
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +217 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/envfile.mjs +78 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +105 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/providers.mjs +240 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/read-secret-note.mjs +82 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/resolve-secret.mjs +151 -176
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/rotate-secret.mjs +266 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/surfaces.mjs +147 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/validate-config.mjs +224 -0
- package/plugins/lisa-cursor/skills/lisa-setup-automations/SKILL.md +69 -0
- package/plugins/lisa-cursor/skills/lisa-setup-automations/scripts/generate-workflow.mjs +185 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/SKILL.md +150 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/assets/setup.sh +45 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +227 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/toolchain.mjs +169 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +186 -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/commands/implement.md +5 -3
- package/plugins/src/base/commands/setup/remote-env.md +7 -0
- package/plugins/src/base/skills/lisa-atlassian-access/SKILL.md +17 -0
- package/plugins/src/base/skills/lisa-automation-status/SKILL.md +23 -0
- package/plugins/src/base/skills/lisa-doctor/SKILL.md +33 -0
- package/plugins/src/base/skills/lisa-implement/SKILL.md +17 -0
- package/plugins/src/base/skills/lisa-notion-access/SKILL.md +17 -0
- package/plugins/src/base/skills/lisa-remote-dispatch/SKILL.md +99 -0
- package/plugins/src/base/skills/lisa-remote-dispatch/scripts/dispatch.mjs +264 -0
- package/plugins/src/base/skills/lisa-secrets-access/SKILL.md +138 -52
- package/plugins/src/base/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +217 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/envfile.mjs +78 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +105 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/providers.mjs +240 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/read-secret-note.mjs +82 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/resolve-secret.mjs +151 -176
- package/plugins/src/base/skills/lisa-secrets-access/scripts/rotate-secret.mjs +266 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/surfaces.mjs +147 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/validate-config.mjs +224 -0
- package/plugins/src/base/skills/lisa-setup-automations/SKILL.md +69 -0
- package/plugins/src/base/skills/lisa-setup-automations/scripts/generate-workflow.mjs +185 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/SKILL.md +150 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/assets/setup.sh +45 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +227 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/toolchain.mjs +169 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +186 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The single-writer path for consumable credentials.
|
|
4
|
+
*
|
|
5
|
+
* A *consumable* credential is one where using it can invalidate the stored
|
|
6
|
+
* copy: an OAuth refresh token the issuer replaces on every exchange, a
|
|
7
|
+
* short-lived session, a single-use enrollment token. The property is not
|
|
8
|
+
* "OAuth" — it is that a successful use makes the value on record wrong.
|
|
9
|
+
*
|
|
10
|
+
* This is a deliberate sibling of the resolver rather than a mode of it. The
|
|
11
|
+
* resolver never writes, and that is what makes it safe to hand to anything. A
|
|
12
|
+
* credential that must be written back needs an authority the read path should
|
|
13
|
+
* not hold, so it gets its own program and its own contract.
|
|
14
|
+
*
|
|
15
|
+
* The failure this exists to prevent is not rotation. It is **rotation with no
|
|
16
|
+
* proven write path**: a job exchanges the token, the issuer invalidates the
|
|
17
|
+
* old one, the replacement cannot be saved, and every downstream consumer is
|
|
18
|
+
* broken until a human notices. So the write is proven *before* the
|
|
19
|
+
* irreversible use, never after.
|
|
20
|
+
*
|
|
21
|
+
* Usage:
|
|
22
|
+
* rotate-secret.mjs preflight NAME # prove the write path, change nothing
|
|
23
|
+
* rotate-secret.mjs checkout NAME # preflight, take the lease, emit value
|
|
24
|
+
* rotate-secret.mjs commit NAME # read replacement on stdin, release
|
|
25
|
+
* rotate-secret.mjs release NAME # release without writing
|
|
26
|
+
* rotate-secret.mjs leases # show current holders
|
|
27
|
+
* @module rotate-secret
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { readFileSync } from "node:fs";
|
|
31
|
+
import { hostname } from "node:os";
|
|
32
|
+
|
|
33
|
+
import { LEASE_KEY, fetchAll, rawByKey, writeSecret } from "./providers.mjs";
|
|
34
|
+
import { readConfig } from "./surfaces.mjs";
|
|
35
|
+
|
|
36
|
+
/** How long a lease is honoured before it is treated as abandoned. */
|
|
37
|
+
const LEASE_TTL_MS = 30 * 60 * 1000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Assert a name is declared rotating before any write path opens for it.
|
|
41
|
+
*
|
|
42
|
+
* Declaration is config, never inference from a note: the note lives
|
|
43
|
+
* provider-side, is editable outside review, and a read-only account cannot
|
|
44
|
+
* correct a wrong one. Config is the surface where this decision is reviewable.
|
|
45
|
+
* @param {string} name Requested name.
|
|
46
|
+
* @param {object} cfg Resolved configuration.
|
|
47
|
+
*/
|
|
48
|
+
function assertRotating(name, cfg) {
|
|
49
|
+
if (!(cfg.rotating ?? []).includes(name)) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`${name} is not declared in secrets.rotating.\n` +
|
|
52
|
+
`Only a declared consumable credential may use the write path. If this ` +
|
|
53
|
+
`credential really is replaced on use, declare it — silently rotating an ` +
|
|
54
|
+
`undeclared one is how a shared token gets stranded.`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Locate a secret and its provider-side identifier.
|
|
61
|
+
* @param {string} name Requested name.
|
|
62
|
+
* @param {object} cfg Resolved configuration.
|
|
63
|
+
* @returns {{value: string, id: string}} The current entry.
|
|
64
|
+
*/
|
|
65
|
+
function entryFor(name, cfg) {
|
|
66
|
+
const hit = fetchAll(cfg).get(name);
|
|
67
|
+
if (!hit) throw new Error(`${name} is not available to this account`);
|
|
68
|
+
if (!hit.id) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`${name} has no provider identifier, so it cannot be written back.\n` +
|
|
71
|
+
`Provider "${cfg.provider}" may not support rotation.`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return { value: hit.value, id: hit.id };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Prove the replacement could be saved, by writing the value already stored.
|
|
79
|
+
*
|
|
80
|
+
* A no-op re-write is the only honest proof: it exercises the exact permission
|
|
81
|
+
* the rotation will need, against the exact record, and changes nothing. A
|
|
82
|
+
* check that merely confirms the CLI exists proves a different thing than the
|
|
83
|
+
* one that fails — which is precisely how the original incident happened.
|
|
84
|
+
* @param {string} name Requested name.
|
|
85
|
+
* @param {object} cfg Resolved configuration.
|
|
86
|
+
* @returns {{value: string, id: string}} The verified entry.
|
|
87
|
+
*/
|
|
88
|
+
export function preflight(name, cfg) {
|
|
89
|
+
assertRotating(name, cfg);
|
|
90
|
+
const entry = entryFor(name, cfg);
|
|
91
|
+
try {
|
|
92
|
+
writeSecret(cfg, entry.id, entry.value);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`no write access to ${name}: ${String(err.message).split("\n")[0]}\n` +
|
|
96
|
+
`Refusing to continue. Rotating a credential whose replacement cannot ` +
|
|
97
|
+
`be persisted breaks every consumer of it.`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return entry;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Read the lease record, tolerating its absence.
|
|
105
|
+
* @param {object} cfg Resolved configuration.
|
|
106
|
+
* @returns {{row: object|null, leases: Record<string, object>}} Current state.
|
|
107
|
+
*/
|
|
108
|
+
function readLeases(cfg) {
|
|
109
|
+
const row = rawByKey(cfg, LEASE_KEY);
|
|
110
|
+
if (!row) return { row: null, leases: {} };
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(row.value || "{}");
|
|
113
|
+
return { row, leases: typeof parsed === "object" ? parsed : {} };
|
|
114
|
+
} catch {
|
|
115
|
+
return { row, leases: {} };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Persist the lease record.
|
|
121
|
+
*
|
|
122
|
+
* When the record does not exist the lease is skipped rather than invented.
|
|
123
|
+
* Creating provider entries is not this program's authority, and an advisory
|
|
124
|
+
* lock whose absence blocks a legitimate rotation would be worse than no lock.
|
|
125
|
+
* @param {object} cfg Resolved configuration.
|
|
126
|
+
* @param {object|null} row Existing lease row.
|
|
127
|
+
* @param {Record<string, object>} leases Updated lease map.
|
|
128
|
+
*/
|
|
129
|
+
function writeLeases(cfg, row, leases) {
|
|
130
|
+
if (!row?.id) return;
|
|
131
|
+
writeSecret(cfg, row.id, JSON.stringify(leases));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Identify this holder well enough for a human to find it.
|
|
136
|
+
* @param {object} cfg Resolved configuration.
|
|
137
|
+
* @returns {string} A holder label.
|
|
138
|
+
*/
|
|
139
|
+
function holderId(cfg) {
|
|
140
|
+
const run = process.env.GITHUB_RUN_ID;
|
|
141
|
+
return run ? `${cfg.surface}:run-${run}` : `${cfg.surface}:${hostname()}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Take the advisory lease, refusing while another holder's lease is live.
|
|
146
|
+
*
|
|
147
|
+
* Advisory is the honest word. True cross-surface exclusion is not achievable
|
|
148
|
+
* with per-surface primitives, and claiming otherwise would invite exactly the
|
|
149
|
+
* concurrent rotation this guards against. What this does give is a record every
|
|
150
|
+
* surface can see, and an expiry so a crashed holder heals itself.
|
|
151
|
+
* @param {string} name Requested name.
|
|
152
|
+
* @param {object} cfg Resolved configuration.
|
|
153
|
+
* @param {number} now Current epoch milliseconds.
|
|
154
|
+
*/
|
|
155
|
+
function acquire(name, cfg, now) {
|
|
156
|
+
const { row, leases } = readLeases(cfg);
|
|
157
|
+
const held = leases[name];
|
|
158
|
+
const holder = holderId(cfg);
|
|
159
|
+
if (held && held.holder !== holder && Number(held.expiresAt) > now) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`${name} is leased by ${held.holder} until ` +
|
|
162
|
+
`${new Date(Number(held.expiresAt)).toISOString()}.\n` +
|
|
163
|
+
`Two refresh loops race: each receives a new value and invalidates the ` +
|
|
164
|
+
`other, and whichever wrote last wins while the other copy is dead.`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
leases[name] = {
|
|
168
|
+
holder,
|
|
169
|
+
surface: cfg.surface,
|
|
170
|
+
acquiredAt: now,
|
|
171
|
+
expiresAt: now + LEASE_TTL_MS,
|
|
172
|
+
};
|
|
173
|
+
writeLeases(cfg, row, leases);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Release the lease if this holder owns it.
|
|
178
|
+
* @param {string} name Requested name.
|
|
179
|
+
* @param {object} cfg Resolved configuration.
|
|
180
|
+
*/
|
|
181
|
+
function release(name, cfg) {
|
|
182
|
+
const { row, leases } = readLeases(cfg);
|
|
183
|
+
if (leases[name]?.holder === holderId(cfg)) {
|
|
184
|
+
delete leases[name];
|
|
185
|
+
writeLeases(cfg, row, leases);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Read the replacement value from stdin.
|
|
191
|
+
*
|
|
192
|
+
* Stdin rather than an argument, because process arguments are visible to
|
|
193
|
+
* anything that can list processes on the host.
|
|
194
|
+
* @returns {string} The replacement value.
|
|
195
|
+
*/
|
|
196
|
+
function readStdin() {
|
|
197
|
+
const value = readFileSync(0, "utf8");
|
|
198
|
+
if (!value.trim()) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
"no replacement value on stdin; refusing to store an empty secret"
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return value;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function main() {
|
|
207
|
+
const [op, name] = process.argv.slice(2);
|
|
208
|
+
const cfg = readConfig();
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
|
|
211
|
+
if (op === "leases") {
|
|
212
|
+
const { leases } = readLeases(cfg);
|
|
213
|
+
const rows = Object.entries(leases);
|
|
214
|
+
if (!rows.length) {
|
|
215
|
+
console.log("no rotation leases held");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
for (const [key, lease] of rows) {
|
|
219
|
+
const state = Number(lease.expiresAt) > now ? "held" : "expired";
|
|
220
|
+
console.log(
|
|
221
|
+
` ${key.padEnd(30)} ${state.padEnd(8)} ${lease.holder} ` +
|
|
222
|
+
`until ${new Date(Number(lease.expiresAt)).toISOString()}`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!name) throw new Error(`usage: rotate-secret.mjs ${op ?? "OP"} NAME`);
|
|
229
|
+
|
|
230
|
+
if (op === "preflight") {
|
|
231
|
+
preflight(name, cfg);
|
|
232
|
+
console.log(`${name}: write path proven; nothing was changed`);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (op === "checkout") {
|
|
236
|
+
const entry = preflight(name, cfg);
|
|
237
|
+
acquire(name, cfg, now);
|
|
238
|
+
process.stdout.write(entry.value);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (op === "commit") {
|
|
242
|
+
assertRotating(name, cfg);
|
|
243
|
+
const { id } = entryFor(name, cfg);
|
|
244
|
+
writeSecret(cfg, id, readStdin());
|
|
245
|
+
release(name, cfg);
|
|
246
|
+
console.log(`${name}: replacement stored and lease released`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (op === "release") {
|
|
250
|
+
release(name, cfg);
|
|
251
|
+
console.log(`${name}: lease released`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
throw new Error(
|
|
255
|
+
"usage: rotate-secret.mjs preflight|checkout|commit|release|leases [NAME]"
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
260
|
+
try {
|
|
261
|
+
main();
|
|
262
|
+
} catch (err) {
|
|
263
|
+
console.error(err.message);
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Surface definitions and configuration for `lisa-secrets-access`.
|
|
4
|
+
*
|
|
5
|
+
* A *provider* is where secrets live. A *surface* is where the running code
|
|
6
|
+
* lives, and it determines how secrets reach that code. These are independent
|
|
7
|
+
* axes: the same Bitwarden project serves a laptop, a CI runner, and a remote
|
|
8
|
+
* agent container, but each of the three obtains its values differently.
|
|
9
|
+
*
|
|
10
|
+
* Surfaces are declared by capability rather than by name so adding one is a
|
|
11
|
+
* single entry here rather than a new branch in every consumer. That is the
|
|
12
|
+
* whole reason this table exists.
|
|
13
|
+
* @module surfaces
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Capabilities, per surface.
|
|
22
|
+
*
|
|
23
|
+
* `materialized` — the surface obtains values from files written before the
|
|
24
|
+
* code ran, so the resolver must consult them.
|
|
25
|
+
*
|
|
26
|
+
* `mayWriteValues` — the surface is permitted to write resolved values to
|
|
27
|
+
* disk. This is normally forbidden, because a value on disk is a copy that can
|
|
28
|
+
* drift and leak. It is *required* on surfaces whose bootstrap runs before the
|
|
29
|
+
* consuming process exists and which therefore have no other channel; a remote
|
|
30
|
+
* agent container prepares itself during setup, long before any task starts.
|
|
31
|
+
*/
|
|
32
|
+
export const SURFACES = {
|
|
33
|
+
local: { materialized: false, mayWriteValues: false },
|
|
34
|
+
"github-actions": { materialized: false, mayWriteValues: false },
|
|
35
|
+
"codex-cloud": { materialized: true, mayWriteValues: true },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Config defaults when `.lisa.config.json` carries no `secrets` block. */
|
|
39
|
+
const DEFAULTS = {
|
|
40
|
+
provider: "env",
|
|
41
|
+
bootstrap: { sources: ["env"], key: null },
|
|
42
|
+
require: null,
|
|
43
|
+
rotating: [],
|
|
44
|
+
namespace: "lisa",
|
|
45
|
+
narrow: { projectIds: [], excludeKeys: [] },
|
|
46
|
+
surface: null,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Identify the surface this process is running on.
|
|
51
|
+
*
|
|
52
|
+
* An explicit value always wins, so an operator can reproduce another
|
|
53
|
+
* surface's behaviour locally when diagnosing one. Detection is otherwise
|
|
54
|
+
* ordered most-specific first.
|
|
55
|
+
* @param {string|null} [configured] Surface named in `.lisa.config.json`.
|
|
56
|
+
* @param {Record<string, string|undefined>} [env] Environment to inspect.
|
|
57
|
+
* @returns {string} A key of {@link SURFACES}.
|
|
58
|
+
*/
|
|
59
|
+
export function detectSurface(configured = null, env = process.env) {
|
|
60
|
+
const explicit = (env.LISA_SECRETS_SURFACE ?? "").trim() || configured;
|
|
61
|
+
if (explicit) {
|
|
62
|
+
if (!SURFACES[explicit]) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`unknown surface "${explicit}".\n` +
|
|
65
|
+
`Known: ${Object.keys(SURFACES).join(", ")}`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return explicit;
|
|
69
|
+
}
|
|
70
|
+
if ((env.GITHUB_ACTIONS ?? "") === "true") return "github-actions";
|
|
71
|
+
if ((env.CODEX_SANDBOX ?? env.CODEX_HOME ?? "") !== "") return "codex-cloud";
|
|
72
|
+
return "local";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reject a namespace that is not exactly one safe path segment.
|
|
77
|
+
*
|
|
78
|
+
* The namespace is joined onto a config root, so anything containing a
|
|
79
|
+
* separator or a parent reference could redirect writes outside the intended
|
|
80
|
+
* directory. Validating here means every caller inherits the guard.
|
|
81
|
+
* @param {string} namespace Candidate namespace.
|
|
82
|
+
* @returns {string} The namespace, unchanged, when valid.
|
|
83
|
+
*/
|
|
84
|
+
export function assertNamespace(namespace) {
|
|
85
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(namespace) || namespace === "..") {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`secrets.namespace must be one safe path segment, got "${namespace}"`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return namespace;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Where a surface's materialized files live.
|
|
95
|
+
* @param {string} namespace Validated namespace.
|
|
96
|
+
* @param {Record<string, string|undefined>} [env] Environment to inspect.
|
|
97
|
+
* @returns {{dir: string, valuesFile: string, notesFile: string}} Paths.
|
|
98
|
+
*/
|
|
99
|
+
export function materializedPaths(namespace, env = process.env) {
|
|
100
|
+
const root = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), ".config");
|
|
101
|
+
const dir = join(root, assertNamespace(namespace));
|
|
102
|
+
return {
|
|
103
|
+
dir,
|
|
104
|
+
valuesFile: join(dir, "secrets.env"),
|
|
105
|
+
notesFile: join(dir, "secret-notes.json"),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Read the `secrets` block from `.lisa.config.json`.
|
|
111
|
+
*
|
|
112
|
+
* A project with no block is a supported state, not an error: the `env`
|
|
113
|
+
* provider means the environment *is* the provider. A credentials manager is
|
|
114
|
+
* the preferred path, never a required one.
|
|
115
|
+
* @param {string} [cwd] Directory to look in.
|
|
116
|
+
* @returns {object} Resolved configuration with defaults applied.
|
|
117
|
+
*/
|
|
118
|
+
export function readConfig(cwd = process.cwd()) {
|
|
119
|
+
const path = join(cwd, ".lisa.config.json");
|
|
120
|
+
if (!existsSync(path)) return withSurface(DEFAULTS);
|
|
121
|
+
let cfg;
|
|
122
|
+
try {
|
|
123
|
+
cfg = JSON.parse(readFileSync(path, "utf8")).secrets;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
throw new Error(`.lisa.config.json is not readable: ${err.message}`);
|
|
126
|
+
}
|
|
127
|
+
if (!cfg) return withSurface(DEFAULTS);
|
|
128
|
+
return withSurface({
|
|
129
|
+
provider: cfg.provider ?? DEFAULTS.provider,
|
|
130
|
+
bootstrap: { ...DEFAULTS.bootstrap, ...(cfg.bootstrap ?? {}) },
|
|
131
|
+
require: cfg.require ?? null,
|
|
132
|
+
rotating: cfg.rotating ?? [],
|
|
133
|
+
namespace: assertNamespace(cfg.namespace ?? DEFAULTS.namespace),
|
|
134
|
+
narrow: { ...DEFAULTS.narrow, ...(cfg.narrow ?? {}) },
|
|
135
|
+
surface: cfg.surface ?? null,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Attach the resolved surface and its capabilities to a configuration.
|
|
141
|
+
* @param {object} cfg Configuration without surface resolution.
|
|
142
|
+
* @returns {object} The same configuration plus `surface` and `capabilities`.
|
|
143
|
+
*/
|
|
144
|
+
function withSurface(cfg) {
|
|
145
|
+
const surface = detectSurface(cfg.surface);
|
|
146
|
+
return { ...cfg, surface, capabilities: SURFACES[surface] };
|
|
147
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Validate the `secrets`, `remoteEnv`, and `automations` blocks.
|
|
4
|
+
*
|
|
5
|
+
* These blocks are read by shell, by Node, and by generated workflows, so a
|
|
6
|
+
* malformed one surfaces late and somewhere unhelpful — a container that fails
|
|
7
|
+
* mid-setup, a scheduled loop that never fires, a dispatch that names a surface
|
|
8
|
+
* nobody provisioned. Checking the shape up front turns all of those into one
|
|
9
|
+
* message at `doctor` time.
|
|
10
|
+
*
|
|
11
|
+
* Structure only. Whether a credential resolves is `doctor-secrets.mjs`; this
|
|
12
|
+
* asks whether the declaration could ever be correct.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* validate-config.mjs
|
|
16
|
+
* @module validate-config
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
/** Providers with a read implementation today. */
|
|
23
|
+
const IMPLEMENTED_PROVIDERS = new Set(["bitwarden", "doppler", "env"]);
|
|
24
|
+
|
|
25
|
+
/** Providers named in the dispatch table but not yet implemented. */
|
|
26
|
+
const DECLARED_PROVIDERS = new Set(["1password", "aws", "vault"]);
|
|
27
|
+
|
|
28
|
+
/** Surfaces the resolver knows. */
|
|
29
|
+
const SURFACES = new Set(["local", "github-actions", "codex-cloud"]);
|
|
30
|
+
|
|
31
|
+
/** Install methods the toolchain runner supports. */
|
|
32
|
+
const INSTALL_METHODS = new Set(["release-zip", "npm-global"]);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate the `secrets` block.
|
|
36
|
+
* @param {object|undefined} secrets The block, if present.
|
|
37
|
+
* @returns {string[]} Problems found.
|
|
38
|
+
*/
|
|
39
|
+
export function validateSecrets(secrets) {
|
|
40
|
+
if (!secrets) return [];
|
|
41
|
+
const problems = [];
|
|
42
|
+
const provider = secrets.provider ?? "env";
|
|
43
|
+
|
|
44
|
+
if (DECLARED_PROVIDERS.has(provider)) {
|
|
45
|
+
problems.push(
|
|
46
|
+
`secrets.provider "${provider}" is documented but has no read ` +
|
|
47
|
+
`implementation yet. Add one in providers.mjs rather than configuring it.`
|
|
48
|
+
);
|
|
49
|
+
} else if (!IMPLEMENTED_PROVIDERS.has(provider)) {
|
|
50
|
+
problems.push(
|
|
51
|
+
`secrets.provider "${provider}" is unknown. ` +
|
|
52
|
+
`Known: ${[...IMPLEMENTED_PROVIDERS, ...DECLARED_PROVIDERS].join(", ")}.`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (
|
|
57
|
+
secrets.namespace &&
|
|
58
|
+
!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(secrets.namespace)
|
|
59
|
+
) {
|
|
60
|
+
problems.push(
|
|
61
|
+
`secrets.namespace "${secrets.namespace}" is not one safe path segment. ` +
|
|
62
|
+
`It is joined onto a config root, so a separator could redirect writes.`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (secrets.surface && !SURFACES.has(secrets.surface)) {
|
|
67
|
+
problems.push(
|
|
68
|
+
`secrets.surface "${secrets.surface}" is unknown. ` +
|
|
69
|
+
`Known: ${[...SURFACES].join(", ")}.`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const field of ["require", "rotating"]) {
|
|
74
|
+
const value = secrets[field];
|
|
75
|
+
if (value === undefined || value === null) continue;
|
|
76
|
+
if (!Array.isArray(value)) {
|
|
77
|
+
problems.push(`secrets.${field} must be an array of exact key names`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
for (const name of value) {
|
|
81
|
+
if (typeof name !== "string" || !/^[A-Z][A-Z0-9_]*$/.test(name)) {
|
|
82
|
+
problems.push(
|
|
83
|
+
`secrets.${field} entry ${JSON.stringify(name)} is not an exact ` +
|
|
84
|
+
`UPPER_SNAKE_CASE environment-variable name. Lookup is never fuzzy.`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if ((secrets.rotating ?? []).length && !secrets.bootstrap?.key) {
|
|
91
|
+
problems.push(
|
|
92
|
+
`secrets.rotating is declared but secrets.bootstrap.key is not. A ` +
|
|
93
|
+
`rotating credential whose replacement cannot be written back is ` +
|
|
94
|
+
`stranded the first time it is used.`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return problems;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Validate the `remoteEnv` block.
|
|
103
|
+
* @param {object|undefined} remoteEnv The block, if present.
|
|
104
|
+
* @returns {string[]} Problems found.
|
|
105
|
+
*/
|
|
106
|
+
export function validateRemoteEnv(remoteEnv) {
|
|
107
|
+
if (!remoteEnv) return [];
|
|
108
|
+
const problems = [];
|
|
109
|
+
|
|
110
|
+
for (const tool of remoteEnv.tools?.require ?? []) {
|
|
111
|
+
if (!tool.name) problems.push("remoteEnv.tools.require entry has no name");
|
|
112
|
+
}
|
|
113
|
+
for (const tool of remoteEnv.tools?.install ?? []) {
|
|
114
|
+
if (!tool.name) {
|
|
115
|
+
problems.push("remoteEnv.tools.install entry has no name");
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (!tool.version) {
|
|
119
|
+
problems.push(`remoteEnv install "${tool.name}" has no pinned version`);
|
|
120
|
+
}
|
|
121
|
+
if (!INSTALL_METHODS.has(tool.install)) {
|
|
122
|
+
problems.push(
|
|
123
|
+
`remoteEnv install "${tool.name}" has method ${JSON.stringify(tool.install)}. ` +
|
|
124
|
+
`Supported: ${[...INSTALL_METHODS].join(", ")}.`
|
|
125
|
+
);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (tool.install === "release-zip" && !(tool.url && tool.sha256)) {
|
|
129
|
+
problems.push(
|
|
130
|
+
`remoteEnv install "${tool.name}" needs both url and sha256. A pinned ` +
|
|
131
|
+
`version with no checksum still trusts whatever the URL serves today.`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (tool.install === "npm-global" && !tool.package) {
|
|
135
|
+
problems.push(`remoteEnv install "${tool.name}" needs a package`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const [surface, block] of Object.entries(remoteEnv.surfaces ?? {})) {
|
|
140
|
+
if (!SURFACES.has(surface)) {
|
|
141
|
+
problems.push(`remoteEnv.surfaces has unknown surface "${surface}"`);
|
|
142
|
+
}
|
|
143
|
+
if (!block.repository) {
|
|
144
|
+
problems.push(`remoteEnv.surfaces["${surface}"] has no repository`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return problems;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Validate the `automations` block against declared surfaces.
|
|
153
|
+
* @param {object|undefined} automations The block, if present.
|
|
154
|
+
* @param {object|undefined} remoteEnv The remote-environment block.
|
|
155
|
+
* @returns {string[]} Problems found.
|
|
156
|
+
*/
|
|
157
|
+
export function validateAutomations(automations, remoteEnv) {
|
|
158
|
+
if (!automations) return [];
|
|
159
|
+
const problems = [];
|
|
160
|
+
|
|
161
|
+
for (const [name, loop] of Object.entries(automations)) {
|
|
162
|
+
if (loop.scheduler !== "github-actions") continue;
|
|
163
|
+
if (!loop.schedule) {
|
|
164
|
+
problems.push(`automations["${name}"] has no schedule`);
|
|
165
|
+
}
|
|
166
|
+
if (!loop.executionEnv) {
|
|
167
|
+
problems.push(`automations["${name}"] has no executionEnv`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (!SURFACES.has(loop.executionEnv)) {
|
|
171
|
+
problems.push(
|
|
172
|
+
`automations["${name}"].executionEnv "${loop.executionEnv}" is unknown`
|
|
173
|
+
);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (!remoteEnv?.surfaces?.[loop.executionEnv]?.repository) {
|
|
177
|
+
problems.push(
|
|
178
|
+
`automations["${name}"] dispatches to "${loop.executionEnv}", which is ` +
|
|
179
|
+
`not provisioned. Run /lisa:setup:remote-env ${loop.executionEnv} first.`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return problems;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Validate every block this plan introduced.
|
|
189
|
+
* @param {object} cfg Parsed `.lisa.config.json`.
|
|
190
|
+
* @returns {string[]} Problems found.
|
|
191
|
+
*/
|
|
192
|
+
export function validateConfig(cfg) {
|
|
193
|
+
return [
|
|
194
|
+
...validateSecrets(cfg.secrets),
|
|
195
|
+
...validateRemoteEnv(cfg.remoteEnv),
|
|
196
|
+
...validateAutomations(cfg.automations, cfg.remoteEnv),
|
|
197
|
+
];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function main() {
|
|
201
|
+
const path = join(process.cwd(), ".lisa.config.json");
|
|
202
|
+
if (!existsSync(path)) {
|
|
203
|
+
console.log("no .lisa.config.json — nothing to validate");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const problems = validateConfig(JSON.parse(readFileSync(path, "utf8")));
|
|
207
|
+
if (!problems.length) {
|
|
208
|
+
console.log(
|
|
209
|
+
"config: secrets, remoteEnv and automations blocks are well-formed"
|
|
210
|
+
);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
for (const problem of problems) console.error(` ${problem}`);
|
|
214
|
+
throw new Error(`${problems.length} configuration problem(s)`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
218
|
+
try {
|
|
219
|
+
main();
|
|
220
|
+
} catch (err) {
|
|
221
|
+
console.error(err.message);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
}
|