@codyswann/lisa 3.14.8 → 3.15.1

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.
Files changed (108) hide show
  1. package/all/copy-overwrite/scripts/check-state-classification.mjs +3 -5
  2. package/all/copy-overwrite/scripts/lib/invoked-as-script.mjs +96 -0
  3. package/all/copy-overwrite/scripts/lisa-command-envelope.mjs +3 -5
  4. package/all/copy-overwrite/scripts/lisa-work-item.mjs +3 -5
  5. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  6. package/dist/core/lisa-owned-hash-ledger.js +19 -0
  7. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  8. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  9. package/dist/core/upstream-evidence-manifest.js +47 -19
  10. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  11. package/expo/copy-overwrite/scripts/bdd/markdown-cell.mjs +128 -0
  12. package/expo/copy-overwrite/scripts/bdd/render.mjs +1 -14
  13. package/expo/copy-overwrite/scripts/bdd-matrix.mjs +3 -3
  14. package/expo/copy-overwrite/scripts/check-bdd-coverage.mjs +3 -3
  15. package/expo/copy-overwrite/scripts/check-e2e-coverage.mjs +3 -5
  16. package/expo/copy-overwrite/scripts/classify-maestro-failures.mjs +5 -15
  17. package/expo/copy-overwrite/scripts/lib/invoked-as-script.mjs +96 -0
  18. package/package.json +1 -1
  19. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  21. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/SKILL.md +49 -0
  22. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/inspect-vault.mjs +120 -0
  23. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/surfaces.mjs +2 -0
  24. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +481 -0
  25. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/validate-config.mjs +76 -0
  26. package/plugins/lisa/skills/lisa-secrets-access/SKILL.md +50 -1
  27. package/plugins/lisa/skills/lisa-secrets-access/scripts/inspect-vault.mjs +120 -0
  28. package/plugins/lisa/skills/lisa-secrets-access/scripts/surfaces.mjs +2 -0
  29. package/plugins/lisa/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +481 -0
  30. package/plugins/lisa/skills/lisa-secrets-access/scripts/validate-config.mjs +76 -0
  31. package/plugins/lisa-agy/plugin.json +1 -1
  32. package/plugins/lisa-agy/skills/lisa-secrets-access/SKILL.md +50 -1
  33. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/inspect-vault.mjs +120 -0
  34. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/surfaces.mjs +2 -0
  35. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +481 -0
  36. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/validate-config.mjs +76 -0
  37. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  40. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-copilot/skills/lisa-secrets-access/SKILL.md +50 -1
  44. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/inspect-vault.mjs +120 -0
  45. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/surfaces.mjs +2 -0
  46. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +481 -0
  47. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/validate-config.mjs +76 -0
  48. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-cursor/skills/lisa-secrets-access/SKILL.md +50 -1
  50. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/inspect-vault.mjs +120 -0
  51. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/surfaces.mjs +2 -0
  52. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +481 -0
  53. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/validate-config.mjs +76 -0
  54. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-expo-agy/plugin.json +1 -1
  57. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  62. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  66. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  67. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  71. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  72. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  76. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  77. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  81. package/plugins/lisa-rails-agy/plugin.json +1 -1
  82. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  86. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  87. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  91. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  92. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  94. package/plugins/src/base/skills/lisa-secrets-access/SKILL.md +50 -1
  95. package/plugins/src/base/skills/lisa-secrets-access/scripts/inspect-vault.mjs +120 -0
  96. package/plugins/src/base/skills/lisa-secrets-access/scripts/surfaces.mjs +2 -0
  97. package/plugins/src/base/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +481 -0
  98. package/plugins/src/base/skills/lisa-secrets-access/scripts/validate-config.mjs +76 -0
  99. package/scripts/build-plugins.sh +29 -0
  100. package/scripts/check-conflict-markers.mjs +4 -5
  101. package/scripts/detect-stale-workflow-inputs.mjs +4 -5
  102. package/scripts/lib/invoked-as-script.mjs +93 -0
  103. package/scripts/plugin-parity-drift.mjs +4 -5
  104. package/scripts/plugin-routing-validate.mjs +4 -5
  105. package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +2 -5
  106. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +3 -5
  107. package/typescript/copy-overwrite/scripts/check-verification-coverage.mjs +3 -5
  108. package/typescript/copy-overwrite/scripts/lib/invoked-as-script.mjs +96 -0
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Safe discovery: which secrets a vault grants, by name and size only.
4
+ *
5
+ * This exists because the obvious way to answer "what is this key called?" is
6
+ * `bws secret list -o tsv` (or `table`, or `env`), and **all three print
7
+ * VALUES**. Run once to find one name and every secret in the project is in a
8
+ * terminal, a CI log, or an agent transcript — nowhere any of them can be taken
9
+ * back from.
10
+ *
11
+ * Two properties, and the second one is the reason this is a file:
12
+ *
13
+ * - **Names and lengths, never values.** A length is enough to tell a populated
14
+ * secret from an empty one and to confirm you are looking at the credential
15
+ * you meant, which is all discovery ever needs.
16
+ * - **A file invoked with literal argv**, so the command is
17
+ * `bws run -- node .../inspect-vault.mjs` and what runs can be read before it
18
+ * runs. The inline alternative — `bws run --shell sh '...'` — is refused by
19
+ * agent sandboxes as unanalyzable, and the natural response to that refusal
20
+ * is to try variants until one slips through. The remedy is structural rather
21
+ * than a better incantation, and it is better than an inline pipeline whether
22
+ * or not a sandbox is watching.
23
+ *
24
+ * Deliberately no `--json` and no way to print a value. A tool that can be
25
+ * asked for one will eventually be asked for one.
26
+ *
27
+ * Usage:
28
+ * bws run -- node scripts/inspect-vault.mjs [PREFIX]
29
+ * @module inspect-vault
30
+ */
31
+
32
+ /**
33
+ * Variables present in any shell, which say nothing about the vault.
34
+ *
35
+ * Prefix-matched rather than listed exactly, because the point is to keep the
36
+ * output short enough to read — a hundred inherited variables buries the six
37
+ * that came from the vault.
38
+ */
39
+ const AMBIENT = [
40
+ "BASH",
41
+ "COLORTERM",
42
+ "COMMAND_MODE",
43
+ "DISPLAY",
44
+ "EDITOR",
45
+ "HOME",
46
+ "HOSTNAME",
47
+ "INFOPATH",
48
+ "LANG",
49
+ "LC_",
50
+ "LESS",
51
+ "LOGNAME",
52
+ "LS_COLORS",
53
+ "MAIL",
54
+ "MANPATH",
55
+ "NODE_",
56
+ "OLDPWD",
57
+ "PAGER",
58
+ "PATH",
59
+ "PS1",
60
+ "PWD",
61
+ "SHELL",
62
+ "SHLVL",
63
+ "SSH_",
64
+ "TERM",
65
+ "TMPDIR",
66
+ "TZ",
67
+ "USER",
68
+ "VISUAL",
69
+ "XPC_",
70
+ "_",
71
+ ];
72
+
73
+ /**
74
+ * Whether a variable is ordinary shell furniture rather than a vault entry.
75
+ * @param {string} key Variable name.
76
+ * @returns {boolean} Whether to hide it.
77
+ */
78
+ export function isAmbient(key) {
79
+ return AMBIENT.some(prefix => key === prefix || key.startsWith(prefix));
80
+ }
81
+
82
+ /**
83
+ * Describe the environment as names and sizes.
84
+ *
85
+ * Split from printing so the "no value ever appears in the output" property is
86
+ * testable directly, rather than inferred from reading the formatting code.
87
+ * @param {Record<string, string|undefined>} env Environment to describe.
88
+ * @param {string} [prefix] Optional name prefix to narrow to.
89
+ * @returns {Array<{name: string, bytes: number}>} One row per variable.
90
+ */
91
+ export function describeEnv(env, prefix = "") {
92
+ return Object.entries(env)
93
+ .filter(([key]) => /^[A-Z][A-Z0-9_]*$/.test(key) && !isAmbient(key))
94
+ .filter(([key]) => key.startsWith(prefix))
95
+ .map(([name, value]) => ({ name, bytes: Buffer.byteLength(value ?? "") }))
96
+ .sort((a, b) => a.name.localeCompare(b.name));
97
+ }
98
+
99
+ function main() {
100
+ const rows = describeEnv(process.env, process.argv[2] ?? "");
101
+ if (!rows.length) {
102
+ console.log(
103
+ "no vault-shaped variables in this environment.\n" +
104
+ "Run this UNDER the provider: bws run -- node <this file>"
105
+ );
106
+ return;
107
+ }
108
+ const width = Math.max(...rows.map(row => row.name.length));
109
+ for (const row of rows) {
110
+ // A length, never a value — and an explicit word for zero, because an empty
111
+ // secret is the one this is most often run to find.
112
+ const size = row.bytes === 0 ? "EMPTY" : `${row.bytes} bytes`;
113
+ console.log(` ${row.name.padEnd(width)} ${size}`);
114
+ }
115
+ console.log(`\n${rows.length} secret(s). Values are never printed.`);
116
+ }
117
+
118
+ if (import.meta.url === `file://${process.argv[1]}`) {
119
+ main();
120
+ }
@@ -111,6 +111,7 @@ const DEFAULTS = {
111
111
  bootstrap: { sources: ["env"], key: null },
112
112
  require: null,
113
113
  rotating: [],
114
+ propagating: [],
114
115
  namespace: "lisa",
115
116
  narrow: { projectIds: [], excludeKeys: [] },
116
117
  surface: null,
@@ -216,6 +217,7 @@ export function readConfig(cwd = process.cwd(), env = process.env) {
216
217
  bootstrap: resolveBootstrap(cfg.bootstrap, provider, namespace),
217
218
  require: cfg.require ?? null,
218
219
  rotating: cfg.rotating ?? [],
220
+ propagating: cfg.propagating ?? [],
219
221
  namespace,
220
222
  narrow: { ...DEFAULTS.narrow, ...(cfg.narrow ?? {}) },
221
223
  surface: cfg.surface ?? null,
@@ -0,0 +1,481 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The single path for PROPAGATING a secret into a second store.
4
+ *
5
+ * Propagation is copying a value from the provider it lives in into a
6
+ * *different* store that cannot read the provider — today, a GitHub Actions
7
+ * organization or repository secret. It is neither a read (the value leaves the
8
+ * resolution path and lands somewhere else) nor a rotation (the source value is
9
+ * unchanged), so it gets its own program and its own contract, exactly as
10
+ * `rotate-secret.mjs` does.
11
+ *
12
+ * The failure this exists to prevent is a **vacuous green**. A workflow gate
13
+ * that needs a credential and cannot find one warn-skips and reports success
14
+ * while verifying nothing: four repositories in this fleet ran
15
+ * `🔗 Work-Item Traceability` with `tracker: linear` and no `LINEAR_API_KEY`
16
+ * mapped, so the gate passed without checking a single work item. The
17
+ * credential existed in Bitwarden the whole time. Nothing described how to move
18
+ * it, so it was done by whatever pipeline shape someone reached for first —
19
+ * which is where the leaks are.
20
+ *
21
+ * Five properties, none of them optional:
22
+ *
23
+ * 1. **Refuse on an empty or absent value.** Piping empty into `gh secret set`
24
+ * overwrites a good secret with an empty one and reports success. Absence
25
+ * must never read as a pass.
26
+ * 2. **The value moves only through a pipe.** Never an argument — process
27
+ * arguments are visible to anything that can list processes on the host —
28
+ * never a temp file, never echoed. Only its length may be logged. This
29
+ * program accepts no value input at all; it reads the provider itself, so
30
+ * the value never passes through a shell.
31
+ * 3. **Verify by metadata, never by reading back.** GitHub cannot return a
32
+ * secret value. Confirmation is the destination *name* appearing in the
33
+ * store's own listing.
34
+ * 4. **Declared, never inferred.** Only a name in `secrets.propagating` may be
35
+ * pushed to a foreign store, so an agent cannot decide on its own to copy a
36
+ * credential outward.
37
+ * 5. **One-way.** Nothing is ever read back *from* the destination. The
38
+ * provider stays the single source of truth; the destination copy is
39
+ * expected to drift and is re-pushed, never reconciled.
40
+ *
41
+ * Usage:
42
+ * sync-secret-to-ci.mjs push NAME TARGET [DEST] # propagate, then verify
43
+ * sync-secret-to-ci.mjs verify NAME TARGET [DEST] # metadata check, no write
44
+ * sync-secret-to-ci.mjs list TARGET # destination names only
45
+ *
46
+ * TARGET is `<org>` or `<owner>/<repo>`. DEST defaults to NAME.
47
+ * @module sync-secret-to-ci
48
+ */
49
+
50
+ import { execFileSync } from "node:child_process";
51
+
52
+ import { ENV_KEY, fetchAll } from "./providers.mjs";
53
+ import { readConfig } from "./surfaces.mjs";
54
+
55
+ /**
56
+ * How many secrets to ask for per listing page.
57
+ *
58
+ * Pagination is load-bearing rather than tidiness. GitHub returns 30 secrets
59
+ * per page by default, so a verification that read only the first page would
60
+ * report FAILURE for a write that had in fact succeeded, on any organization
61
+ * with more than 30 secrets. That is the same class of bug as a listing parsed
62
+ * at the wrong key: a malformed verification that fails a successful write is
63
+ * worse than none, because it invites someone to write again and again.
64
+ */
65
+ const PAGE_SIZE = 100;
66
+
67
+ /** Ceiling on listing pages, so a paging bug cannot loop forever. */
68
+ const MAX_PAGES = 100;
69
+
70
+ /**
71
+ * Visibility applied to an organization secret when the operator names none.
72
+ *
73
+ * The narrow option on purpose. `all` exposes the credential to every public
74
+ * repository in the organization as well, and a default that widens exposure is
75
+ * a default nobody reviews. Widening is a decision, so it is spelled on the
76
+ * command line.
77
+ */
78
+ const DEFAULT_VISIBILITY = "private";
79
+
80
+ /** Visibilities the GitHub API accepts for an organization secret. */
81
+ const VISIBILITIES = new Set(["all", "private", "selected"]);
82
+
83
+ /**
84
+ * Assert a name is declared propagating, and that this destination is allowed.
85
+ *
86
+ * Declaration is config, never inference from a note: the note lives
87
+ * provider-side, is editable outside review, and a read-only account cannot
88
+ * correct a wrong one. Config is the surface where "this credential may leave
89
+ * its store" is reviewable.
90
+ *
91
+ * Two declaration shapes, and the difference matters. A bare string mirrors
92
+ * `secrets.rotating` and pins the *credential* only — any target may receive
93
+ * it. An object with `targets` additionally pins *where it may go*, which is
94
+ * the stronger statement and the one worth making for anything that is not
95
+ * already fleet-wide.
96
+ * @param {string} name Requested source name.
97
+ * @param {string} target Destination target, `<org>` or `<owner>/<repo>`.
98
+ * @param {object} cfg Resolved configuration.
99
+ */
100
+ export function assertPropagating(name, target, cfg) {
101
+ const entries = (cfg.propagating ?? []).map(entry =>
102
+ typeof entry === "string" ? { name: entry } : (entry ?? {})
103
+ );
104
+ const declared = entries.find(entry => entry.name === name);
105
+ if (!declared) {
106
+ throw new Error(
107
+ `${name} is not declared in secrets.propagating.\n` +
108
+ `Only a declared credential may be copied into a foreign store. ` +
109
+ `Propagation moves a value out of the store that owns it, so which ` +
110
+ `credentials may leave is a reviewed decision, not one this program ` +
111
+ `makes for you.`
112
+ );
113
+ }
114
+ const targets = declared.targets;
115
+ if (Array.isArray(targets) && !targets.includes(target)) {
116
+ throw new Error(
117
+ `${name} is declared propagating, but not to "${target}".\n` +
118
+ `Declared targets: ${targets.join(", ") || "(none)"}.`
119
+ );
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Refuse a value that would overwrite a live secret with nothing.
125
+ *
126
+ * This is the property most worth having and the easiest to omit, because the
127
+ * unsafe path *succeeds*: piping an empty string into `gh secret set` stores an
128
+ * empty secret and exits 0, so the destination reports a healthy, present,
129
+ * useless credential. Every consumer downstream then behaves exactly as it does
130
+ * when the secret was never set — which, for a warn-skipping gate, is a green
131
+ * check that verified nothing.
132
+ *
133
+ * Absence is checked with `trim()` because whitespace is the shape absence
134
+ * actually arrives in: a provider row set to `""`, a heredoc that contributed
135
+ * only a newline, a variable that expanded to nothing.
136
+ * @param {string} name The name being propagated, for the message.
137
+ * @param {unknown} value Candidate value.
138
+ * @returns {string} The value, unchanged, when it is safe to send.
139
+ */
140
+ export function assertValue(name, value) {
141
+ if (typeof value !== "string" || !value.trim()) {
142
+ throw new Error(
143
+ `${name} resolved to an empty value; refusing to propagate it.\n` +
144
+ `Writing empty would overwrite a working destination secret and report ` +
145
+ `success. Absence must never read as a pass.`
146
+ );
147
+ }
148
+ return value;
149
+ }
150
+
151
+ /**
152
+ * Parse a destination target into the API surface that describes it.
153
+ *
154
+ * `<org>` and `<owner>/<repo>` are different stores with different listings, so
155
+ * the ambiguity is resolved once, here, rather than at each call site. Anything
156
+ * else is refused outright: a target is interpolated into an API path, and a
157
+ * silently mis-parsed one would push a credential somewhere nobody named.
158
+ * @param {string} raw Target as typed.
159
+ * @returns {{kind: string, slug: string, listPath: string, scopeArgs: string[]}} Parsed target.
160
+ */
161
+ export function parseTarget(raw) {
162
+ const target = String(raw ?? "").trim();
163
+ const segment = "[A-Za-z0-9][A-Za-z0-9._-]*";
164
+ if (new RegExp(`^${segment}$`).test(target)) {
165
+ return {
166
+ kind: "org",
167
+ slug: target,
168
+ listPath: `orgs/${target}/actions/secrets`,
169
+ scopeArgs: ["--org", target],
170
+ };
171
+ }
172
+ if (new RegExp(`^${segment}/${segment}$`).test(target)) {
173
+ return {
174
+ kind: "repo",
175
+ slug: target,
176
+ listPath: `repos/${target}/actions/secrets`,
177
+ scopeArgs: ["--repo", target],
178
+ };
179
+ }
180
+ throw new Error(
181
+ `target "${raw}" is neither an organization nor an owner/repo.\n` +
182
+ `Expected "<org>" or "<owner>/<repo>".`
183
+ );
184
+ }
185
+
186
+ /**
187
+ * Reject a destination name the store could never expose as a variable.
188
+ * @param {string} dest Destination secret name.
189
+ * @returns {string} The name, unchanged, when valid.
190
+ */
191
+ export function assertDestName(dest) {
192
+ if (!ENV_KEY.test(dest)) {
193
+ throw new Error(
194
+ `destination name "${dest}" is not a valid environment-variable name.\n` +
195
+ `A workflow reads it as \${{ secrets.${dest} }}, so it must be one.`
196
+ );
197
+ }
198
+ return dest;
199
+ }
200
+
201
+ /**
202
+ * Pull the secret NAMES out of whatever shape a listing arrives in.
203
+ *
204
+ * This function is the one that was wrong first, and it is worth stating what
205
+ * it got wrong. `GET /orgs/{org}/actions/secrets` does **not** return an array
206
+ * — it returns `{ total_count, secrets: [...] }`. A filter written against a
207
+ * bare array finds nothing, so the verification printed a WARNING for a write
208
+ * that had already succeeded. A verification that fails a successful write
209
+ * teaches operators to ignore it, which costs more than having no verification
210
+ * at all.
211
+ *
212
+ * So every shape the client can hand us is accepted: the documented envelope, a
213
+ * bare array of secret objects, and an array of pages from a paginated read.
214
+ * None of them can be confused for one another, and the alternative is a parser
215
+ * that is silently correct only for the shape its author happened to test.
216
+ * @param {unknown} payload A parsed listing response, or an array of them.
217
+ * @returns {string[]} Every secret name present.
218
+ */
219
+ export function extractSecretNames(payload) {
220
+ if (Array.isArray(payload)) {
221
+ return payload.flatMap(entry =>
222
+ entry && typeof entry === "object" && !("name" in entry)
223
+ ? extractSecretNames(entry)
224
+ : typeof entry?.name === "string"
225
+ ? [entry.name]
226
+ : []
227
+ );
228
+ }
229
+ if (payload && typeof payload === "object") {
230
+ return extractSecretNames(payload.secrets ?? []);
231
+ }
232
+ return [];
233
+ }
234
+
235
+ /**
236
+ * The store's own count of secrets, when it reported one.
237
+ * @param {unknown} payload A parsed listing response.
238
+ * @returns {number|null} The count, or null when the shape carries none.
239
+ */
240
+ export function totalCount(payload) {
241
+ const count = payload?.total_count;
242
+ return typeof count === "number" ? count : null;
243
+ }
244
+
245
+ /**
246
+ * Decide whether a destination name is present in a listing.
247
+ *
248
+ * Split from the read so the decision is testable without a network, a token,
249
+ * or a real organization — which is the only way to prove the case that matters:
250
+ * that a *successful* write verifies as successful.
251
+ * @param {unknown} payload A parsed listing response, or pages of them.
252
+ * @param {string} dest Destination secret name.
253
+ * @returns {boolean} Whether the store lists that name.
254
+ */
255
+ export function confirmPresent(payload, dest) {
256
+ return extractSecretNames(payload).includes(dest);
257
+ }
258
+
259
+ /**
260
+ * Run `gh`, keeping its output off any shared stream.
261
+ * @param {string[]} args Arguments.
262
+ * @param {{input?: string}} [options] Optional stdin payload.
263
+ * @returns {string} Captured stdout.
264
+ */
265
+ function gh(args, options = {}) {
266
+ try {
267
+ return execFileSync("gh", args, {
268
+ encoding: "utf8",
269
+ stdio: ["pipe", "pipe", "pipe"],
270
+ ...options,
271
+ });
272
+ } catch (err) {
273
+ const detail = String(err.stderr || err.message)
274
+ .split("\n")
275
+ .filter(Boolean)[0];
276
+ throw new Error(`gh ${args[0]} failed: ${detail ?? "unknown error"}`);
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Read every secret name the destination store holds, following pages.
282
+ *
283
+ * Names only. This is the *only* thing ever read from a destination, and it is
284
+ * metadata rather than content — GitHub cannot return a secret value even to a
285
+ * caller entitled to write one. Nothing here is compared against the provider
286
+ * or written back to it: propagation is one-way by construction, so a
287
+ * destination copy that has drifted is re-pushed, never reconciled.
288
+ * @param {{listPath: string}} target Parsed target.
289
+ * @returns {string[]} Destination secret names.
290
+ */
291
+ export function listDestination(target) {
292
+ const names = [];
293
+ for (let page = 1; page <= MAX_PAGES; page += 1) {
294
+ const raw = gh([
295
+ "api",
296
+ "-H",
297
+ "Accept: application/vnd.github+json",
298
+ `${target.listPath}?per_page=${PAGE_SIZE}&page=${page}`,
299
+ ]);
300
+ const payload = JSON.parse(raw || "{}");
301
+ const batch = extractSecretNames(payload);
302
+ names.push(...batch);
303
+ const total = totalCount(payload);
304
+ if (!batch.length) break;
305
+ if (total === null || names.length >= total) break;
306
+ }
307
+ return names;
308
+ }
309
+
310
+ /**
311
+ * Resolve the value to propagate, from the provider and nowhere else.
312
+ *
313
+ * `fetchAll` applies the exposure boundary, so a name the provider does not
314
+ * grant this account is simply absent. An `excludeKeys` entry is reported on
315
+ * its own terms rather than as "not available": excluding a name and declaring
316
+ * it propagating are contradictory instructions, and guessing which one the
317
+ * operator meant is how a credential ends up somewhere nobody chose. The
318
+ * rotation path waives an exclusion because a credential it cannot see is one
319
+ * it cannot write *back* to its own record; there is no equivalent argument for
320
+ * copying one outward, so this path refuses instead.
321
+ * @param {string} name Requested source name.
322
+ * @param {object} cfg Resolved configuration.
323
+ * @returns {string} The value.
324
+ */
325
+ export function readValue(name, cfg) {
326
+ if ((cfg.narrow?.excludeKeys ?? []).includes(name)) {
327
+ throw new Error(
328
+ `${name} is in secrets.narrow.excludeKeys and in secrets.propagating.\n` +
329
+ `Those say opposite things about the same credential. Resolve it in ` +
330
+ `config; this program will not choose for you.`
331
+ );
332
+ }
333
+ const hit = fetchAll(cfg).get(name);
334
+ if (!hit) throw new Error(`${name} is not available to this account`);
335
+ return assertValue(name, hit.value);
336
+ }
337
+
338
+ /**
339
+ * Build the write command's arguments — every one of which excludes the value.
340
+ *
341
+ * Separated from the call so the property can be *proved* rather than asserted
342
+ * in a comment: the value is not a parameter here, so no future edit can add it
343
+ * to the argument vector without changing this function's signature. Process
344
+ * arguments are visible to anything that can list processes on the host, which
345
+ * is why the rotation path reads its replacement from stdin and why this one
346
+ * writes its value there.
347
+ * @param {{scopeArgs: string[], kind: string}} target Parsed target.
348
+ * @param {string} dest Destination secret name.
349
+ * @param {{visibility?: string, repos?: string}} [options] Exposure controls.
350
+ * @returns {string[]} Arguments for `gh`.
351
+ */
352
+ export function pushArgs(target, dest, options = {}) {
353
+ const args = ["secret", "set", dest, ...target.scopeArgs];
354
+ if (target.kind === "org") {
355
+ args.push("--visibility", options.visibility ?? DEFAULT_VISIBILITY);
356
+ if (options.repos) args.push("--repos", options.repos);
357
+ }
358
+ return args;
359
+ }
360
+
361
+ /**
362
+ * Send the value to the destination store through the child's stdin.
363
+ *
364
+ * `input` rather than an argument, and no temp file either — a value on disk is
365
+ * a copy that outlives the operation that needed it.
366
+ * @param {{scopeArgs: string[], kind: string}} target Parsed target.
367
+ * @param {string} dest Destination secret name.
368
+ * @param {string} value The value.
369
+ * @param {{visibility?: string, repos?: string}} options Exposure controls.
370
+ */
371
+ function pushValue(target, dest, value, options) {
372
+ gh(pushArgs(target, dest, options), { input: value });
373
+ }
374
+
375
+ /**
376
+ * Parse the optional flags, leaving positionals in order.
377
+ * @param {string[]} argv Arguments after the operation.
378
+ * @returns {{positional: string[], options: object}} Split arguments.
379
+ */
380
+ export function parseArgs(argv) {
381
+ const positional = [];
382
+ const options = {};
383
+ for (let i = 0; i < argv.length; i += 1) {
384
+ const arg = argv[i];
385
+ if (arg === "--visibility" || arg === "--repos") {
386
+ const value = argv[i + 1];
387
+ if (!value) throw new Error(`${arg} needs a value`);
388
+ options[arg.slice(2)] = value;
389
+ i += 1;
390
+ continue;
391
+ }
392
+ positional.push(arg);
393
+ }
394
+ if (options.visibility && !VISIBILITIES.has(options.visibility)) {
395
+ throw new Error(
396
+ `--visibility must be one of ${[...VISIBILITIES].join(", ")}`
397
+ );
398
+ }
399
+ if (options.visibility === "selected" && !options.repos) {
400
+ throw new Error(`--visibility selected needs --repos`);
401
+ }
402
+ return { positional, options };
403
+ }
404
+
405
+ /**
406
+ * Describe a value without disclosing it.
407
+ * @param {string} value The value.
408
+ * @returns {string} A length, and nothing else.
409
+ */
410
+ function describeValue(value) {
411
+ return `${Buffer.byteLength(value, "utf8")} bytes`;
412
+ }
413
+
414
+ function main() {
415
+ const [op, ...rest] = process.argv.slice(2);
416
+ const { positional, options } = parseArgs(rest);
417
+ const cfg = readConfig();
418
+
419
+ if (op === "list") {
420
+ const [rawTarget] = positional;
421
+ if (!rawTarget) throw new Error("usage: sync-secret-to-ci.mjs list TARGET");
422
+ const target = parseTarget(rawTarget);
423
+ const names = listDestination(target);
424
+ console.log(`${target.slug} (${target.kind}) holds ${names.length}:`);
425
+ for (const name of names.sort()) console.log(` ${name}`);
426
+ return;
427
+ }
428
+
429
+ if (op !== "push" && op !== "verify") {
430
+ throw new Error(
431
+ "usage: sync-secret-to-ci.mjs push|verify NAME TARGET [DEST]\n" +
432
+ " sync-secret-to-ci.mjs list TARGET"
433
+ );
434
+ }
435
+
436
+ const [name, rawTarget, rawDest] = positional;
437
+ if (!name || !rawTarget) {
438
+ throw new Error(`usage: sync-secret-to-ci.mjs ${op} NAME TARGET [DEST]`);
439
+ }
440
+ const target = parseTarget(rawTarget);
441
+ const dest = assertDestName(rawDest || name);
442
+
443
+ if (op === "verify") {
444
+ if (!confirmPresent(listDestination(target), dest)) {
445
+ throw new Error(
446
+ `${dest} is not present in ${target.slug}. Nothing consumes it there.`
447
+ );
448
+ }
449
+ console.log(`${dest}: present in ${target.slug} (${target.kind})`);
450
+ return;
451
+ }
452
+
453
+ // Declaration first, so an undeclared name never reaches the provider — the
454
+ // refusal should not depend on whether the credential happens to resolve.
455
+ assertPropagating(name, target.slug, cfg);
456
+ const value = readValue(name, cfg);
457
+ pushValue(target, dest, value, options);
458
+
459
+ // Verified against the store's own listing, because the write reporting
460
+ // success is not evidence the store accepted it, and the value can never be
461
+ // read back to compare.
462
+ if (!confirmPresent(listDestination(target), dest)) {
463
+ throw new Error(
464
+ `${dest} was written to ${target.slug} but does not appear in its ` +
465
+ `secrets listing. Treat the propagation as failed.`
466
+ );
467
+ }
468
+ console.log(
469
+ `${name} → ${target.slug} (${target.kind}) as ${dest}: ` +
470
+ `${describeValue(value)} pushed and confirmed present`
471
+ );
472
+ }
473
+
474
+ if (import.meta.url === `file://${process.argv[1]}`) {
475
+ try {
476
+ main();
477
+ } catch (err) {
478
+ console.error(err.message);
479
+ process.exit(1);
480
+ }
481
+ }