@aspruyt/xfg 6.2.0 → 6.3.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.
Files changed (44) hide show
  1. package/dist/cli/program.js +41 -3
  2. package/dist/cli/secrets-command.d.ts +25 -0
  3. package/dist/cli/secrets-command.js +75 -0
  4. package/dist/cli/settings-factories.d.ts +2 -1
  5. package/dist/cli/settings-factories.js +6 -1
  6. package/dist/cli/settings-report-builder.d.ts +6 -1
  7. package/dist/cli/settings-report-builder.js +21 -2
  8. package/dist/cli/settings-runner.js +7 -0
  9. package/dist/cli/types.d.ts +4 -2
  10. package/dist/config/index.d.ts +2 -2
  11. package/dist/config/index.js +1 -1
  12. package/dist/config/normalizer.js +86 -1
  13. package/dist/config/types.d.ts +20 -0
  14. package/dist/config/validator.d.ts +4 -0
  15. package/dist/config/validator.js +174 -5
  16. package/dist/output/settings-report.d.ts +11 -0
  17. package/dist/output/settings-report.js +24 -0
  18. package/dist/secrets/encryption.d.ts +9 -0
  19. package/dist/secrets/encryption.js +29 -0
  20. package/dist/secrets/github-secrets-strategy.d.ts +17 -0
  21. package/dist/secrets/github-secrets-strategy.js +38 -0
  22. package/dist/secrets/index.d.ts +5 -0
  23. package/dist/secrets/index.js +3 -0
  24. package/dist/secrets/processor.d.ts +31 -0
  25. package/dist/secrets/processor.js +115 -0
  26. package/dist/secrets/types.d.ts +21 -0
  27. package/dist/secrets/types.js +1 -0
  28. package/dist/settings/index.d.ts +1 -0
  29. package/dist/settings/index.js +2 -0
  30. package/dist/settings/variables/diff.d.ts +10 -0
  31. package/dist/settings/variables/diff.js +39 -0
  32. package/dist/settings/variables/formatter.d.ts +16 -0
  33. package/dist/settings/variables/formatter.js +70 -0
  34. package/dist/settings/variables/github-variables-strategy.d.ts +17 -0
  35. package/dist/settings/variables/github-variables-strategy.js +40 -0
  36. package/dist/settings/variables/index.d.ts +4 -0
  37. package/dist/settings/variables/index.js +2 -0
  38. package/dist/settings/variables/processor.d.ts +19 -0
  39. package/dist/settings/variables/processor.js +60 -0
  40. package/dist/settings/variables/types.d.ts +18 -0
  41. package/dist/settings/variables/types.js +1 -0
  42. package/dist/shared/env-resolver.d.ts +16 -0
  43. package/dist/shared/env-resolver.js +33 -0
  44. package/package.json +3 -1
@@ -5,6 +5,8 @@ import { validateFileConfigFields, validateSettings, } from "./validators/shared
5
5
  import { validateGroups, validateConditionalGroups, } from "./validators/group-validator.js";
6
6
  import { validateRepoEntry } from "./validators/repo-entry-validator.js";
7
7
  const CONFIG_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
8
+ const VARIABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
9
+ const VARIABLE_RESERVED_KEYS = new Set(["deleteOrphaned", "inherit"]);
8
10
  const CONFIG_ID_MAX_LENGTH = 64;
9
11
  function validateConfigId(config) {
10
12
  if (!config.id || typeof config.id !== "string") {
@@ -42,6 +44,9 @@ function validateRootSettings(config) {
42
44
  if (config.settings.labels && "inherit" in config.settings.labels) {
43
45
  throw new ValidationError("'inherit' is a reserved key and cannot be used as a label name");
44
46
  }
47
+ if (config.settings.variables && "inherit" in config.settings.variables) {
48
+ throw new ValidationError("'inherit' is not allowed in root-level variables (nothing to inherit from)");
49
+ }
45
50
  }
46
51
  function validateGithubHosts(config) {
47
52
  if (config.githubHosts === undefined)
@@ -110,15 +115,18 @@ export function validateRawConfig(config) {
110
115
  const hasCondGrpFiles = hasConditionalGroupFiles(config);
111
116
  const hasCondGrpSettings = hasConditionalGroupSettingsPresent(config);
112
117
  const hasCondGrpPR = hasConditionalGroupPR(config);
118
+ const hasSecrets = isPlainObject(config.secrets) && Object.keys(config.secrets).length > 0;
113
119
  if (!hasFiles &&
114
120
  !hasSettings &&
115
121
  !hasGrpFiles &&
116
122
  !hasGrpSettings &&
117
123
  !hasCondGrpFiles &&
118
124
  !hasCondGrpSettings &&
119
- !hasCondGrpPR) {
120
- throw new ValidationError("Config requires at least one of: 'files' or 'settings'. " +
121
- "Use 'files' to sync configuration files, or 'settings' to manage repository settings.");
125
+ !hasCondGrpPR &&
126
+ !hasSecrets) {
127
+ throw new ValidationError("Config requires at least one of: 'files', 'settings', or 'secrets'. " +
128
+ "Use 'files' to sync configuration files, 'settings' to manage repository settings, " +
129
+ "or 'secrets' to manage GitHub Actions secrets.");
122
130
  }
123
131
  validateRootFiles(config);
124
132
  if (config.deleteOrphaned !== undefined &&
@@ -159,8 +167,111 @@ export function validateForSync(config) {
159
167
  !hasCondGrpFiles &&
160
168
  !hasCondGrpSettings &&
161
169
  !hasCondGrpPR) {
162
- throw new ValidationError("Config requires at least one of: 'files' or 'settings'. " +
163
- "Use 'files' to sync configuration files, or 'settings' to manage repository settings.");
170
+ throw new ValidationError("Config requires at least one of: 'files' or 'settings' (rulesets, labels, variables, repo config). " +
171
+ "Use 'files' to sync configuration files, or 'settings' to manage repository settings. " +
172
+ "For secrets, use 'xfg secrets sync'.");
173
+ }
174
+ // Validate variable names across all settings
175
+ const allSettings = [
176
+ config.settings,
177
+ ...config.repos.map((r) => r.settings),
178
+ ...Object.values(config.groups ?? {}).map((g) => g.settings),
179
+ ...(config.conditionalGroups ?? []).map((cg) => cg.settings),
180
+ ];
181
+ for (const settings of allSettings) {
182
+ if (!settings?.variables)
183
+ continue;
184
+ const vars = settings.variables;
185
+ if (vars.deleteOrphaned !== undefined &&
186
+ typeof vars.deleteOrphaned !== "boolean") {
187
+ throw new ValidationError("variables.deleteOrphaned must be a boolean");
188
+ }
189
+ if (vars.inherit !== undefined && typeof vars.inherit !== "boolean") {
190
+ throw new ValidationError("variables.inherit must be a boolean");
191
+ }
192
+ for (const [name, value] of Object.entries(vars)) {
193
+ if (VARIABLE_RESERVED_KEYS.has(name))
194
+ continue;
195
+ validateVariableName(name);
196
+ if (value !== false && typeof value !== "string") {
197
+ throw new ValidationError(`Variable '${name}' must have a string value (got ${typeof value}). Quote numeric values in YAML: "${String(value)}".`);
198
+ }
199
+ }
200
+ // Reject duplicate case-insensitive variable names
201
+ const seenVarNames = new Map();
202
+ for (const name of Object.keys(settings.variables)) {
203
+ if (VARIABLE_RESERVED_KEYS.has(name))
204
+ continue;
205
+ const upper = name.toUpperCase();
206
+ const existing = seenVarNames.get(upper);
207
+ if (existing) {
208
+ throw new ValidationError(`Duplicate variable name: '${name}' and '${existing}' collide (GitHub treats variable names case-insensitively).`);
209
+ }
210
+ seenVarNames.set(upper, name);
211
+ }
212
+ }
213
+ // Validate secret names and configs
214
+ validateSecretsConfig(config);
215
+ // Cross-validate: no overlap between global secret names and variable names
216
+ validateVariableSecretOverlaps(config);
217
+ }
218
+ export function validateVariableSecretOverlaps(config) {
219
+ if (!config.secrets)
220
+ return;
221
+ const { deleteOrphaned: _, ...secretEntries } = config.secrets;
222
+ // GitHub treats secret/variable names case-insensitively for collision purposes
223
+ const secretNames = new Set(Object.keys(secretEntries)
224
+ .filter((k) => typeof secretEntries[k] !== "boolean")
225
+ .map((n) => n.toUpperCase()));
226
+ if (secretNames.size === 0)
227
+ return;
228
+ // Check root-level variables
229
+ if (config.settings?.variables) {
230
+ const { deleteOrphaned: _rd, inherit: _ri, ...rootVarEntries } = config.settings.variables;
231
+ const rootVariableNames = Object.keys(rootVarEntries).filter((k) => typeof rootVarEntries[k] !== "boolean");
232
+ const overlapping = rootVariableNames.filter((n) => secretNames.has(n.toUpperCase()));
233
+ if (overlapping.length > 0) {
234
+ throw new ValidationError(`${overlapping.join(", ")} overlap between root variables and secrets. ` +
235
+ "GitHub does not allow variables and secrets with the same name.");
236
+ }
237
+ }
238
+ for (const repo of config.repos) {
239
+ const { deleteOrphaned: _d, inherit: _i, ...varEntries } = (repo.settings?.variables ?? {});
240
+ const variableNames = Object.keys(varEntries).filter((k) => typeof varEntries[k] !== "boolean");
241
+ const overlapping = variableNames.filter((n) => secretNames.has(n.toUpperCase()));
242
+ if (overlapping.length > 0) {
243
+ throw new ValidationError(`Repo '${repo.git}': ${overlapping.join(", ")} overlap between variables and secrets. ` +
244
+ "GitHub does not allow variables and secrets with the same name.");
245
+ }
246
+ }
247
+ // Check group-level variables
248
+ if (isPlainObject(config.groups)) {
249
+ for (const [groupName, group] of Object.entries(config.groups)) {
250
+ if (!group.settings?.variables)
251
+ continue;
252
+ const { deleteOrphaned: _gd, inherit: _gi, ...groupVarEntries } = group.settings.variables;
253
+ const groupVariableNames = Object.keys(groupVarEntries).filter((k) => typeof groupVarEntries[k] !== "boolean");
254
+ const overlapping = groupVariableNames.filter((n) => secretNames.has(n.toUpperCase()));
255
+ if (overlapping.length > 0) {
256
+ throw new ValidationError(`Group '${groupName}': ${overlapping.join(", ")} overlap between variables and secrets. ` +
257
+ "GitHub does not allow variables and secrets with the same name.");
258
+ }
259
+ }
260
+ }
261
+ // Check conditional group-level variables
262
+ if (Array.isArray(config.conditionalGroups)) {
263
+ for (let i = 0; i < config.conditionalGroups.length; i++) {
264
+ const cg = config.conditionalGroups[i];
265
+ if (!cg.settings?.variables)
266
+ continue;
267
+ const { deleteOrphaned: _cd, inherit: _ci, ...cgVarEntries } = cg.settings.variables;
268
+ const cgVariableNames = Object.keys(cgVarEntries).filter((k) => typeof cgVarEntries[k] !== "boolean");
269
+ const overlapping = cgVariableNames.filter((n) => secretNames.has(n.toUpperCase()));
270
+ if (overlapping.length > 0) {
271
+ throw new ValidationError(`Conditional group ${i}: ${overlapping.join(", ")} overlap between variables and secrets. ` +
272
+ "GitHub does not allow variables and secrets with the same name.");
273
+ }
274
+ }
164
275
  }
165
276
  }
166
277
  export function hasActionableSettings(settings) {
@@ -180,5 +291,63 @@ export function hasActionableSettings(settings) {
180
291
  if (settings.codeScanning) {
181
292
  return true;
182
293
  }
294
+ if (settings.variables) {
295
+ const { deleteOrphaned, inherit: _i, ...entries } = settings.variables;
296
+ if (Object.keys(entries).length > 0 || deleteOrphaned === true) {
297
+ return true;
298
+ }
299
+ }
183
300
  return false;
184
301
  }
302
+ export function validateVariableName(name) {
303
+ if (!VARIABLE_NAME_PATTERN.test(name)) {
304
+ throw new ValidationError(`Variable name '${name}' contains invalid characters. Only alphanumeric and underscore allowed.`);
305
+ }
306
+ if (name.startsWith("GITHUB_")) {
307
+ throw new ValidationError(`Variable name '${name}' cannot start with 'GITHUB_' (reserved prefix).`);
308
+ }
309
+ }
310
+ export function validateSecretName(name) {
311
+ if (!VARIABLE_NAME_PATTERN.test(name)) {
312
+ throw new ValidationError(`Secret name '${name}' contains invalid characters. Only alphanumeric and underscore allowed.`);
313
+ }
314
+ if (name.startsWith("GITHUB_")) {
315
+ throw new ValidationError(`Secret name '${name}' cannot start with 'GITHUB_' (reserved prefix).`);
316
+ }
317
+ }
318
+ function validateSecretEntry(name, config) {
319
+ validateSecretName(name);
320
+ if (!config.env || typeof config.env !== "string") {
321
+ throw new ValidationError(`Secret '${name}' requires an 'env' field (string) specifying the environment variable source.`);
322
+ }
323
+ }
324
+ export function validateSecretsConfig(config) {
325
+ if (!config.secrets)
326
+ return;
327
+ const { deleteOrphaned, ...entries } = config.secrets;
328
+ // Reject 'deleteOrphaned' used as a secret name (it's a reserved peer key)
329
+ if (deleteOrphaned !== undefined && typeof deleteOrphaned !== "boolean") {
330
+ throw new ValidationError("'deleteOrphaned' is a reserved key in secrets config and cannot be used as a secret name.");
331
+ }
332
+ // Reject boolean true — only false (opt-out) is valid
333
+ for (const [name, value] of Object.entries(entries)) {
334
+ if (value === true) {
335
+ throw new ValidationError(`Secret '${name}' is set to true, which is not valid. Use false to opt out, or provide a SecretConfig object.`);
336
+ }
337
+ }
338
+ // Reject duplicate case-insensitive secret names
339
+ const seen = new Map();
340
+ for (const name of Object.keys(entries)) {
341
+ const upper = name.toUpperCase();
342
+ const existing = seen.get(upper);
343
+ if (existing) {
344
+ throw new ValidationError(`Duplicate secret name: '${name}' and '${existing}' collide (GitHub treats secret names case-insensitively).`);
345
+ }
346
+ seen.set(upper, name);
347
+ }
348
+ for (const [name, value] of Object.entries(entries)) {
349
+ if (typeof value === "boolean")
350
+ continue;
351
+ validateSecretEntry(name, value);
352
+ }
353
+ }
@@ -17,6 +17,11 @@ export interface SettingsReport {
17
17
  update: number;
18
18
  delete: number;
19
19
  };
20
+ variables?: {
21
+ create: number;
22
+ update: number;
23
+ delete: number;
24
+ };
20
25
  };
21
26
  }
22
27
  export interface RepoChanges {
@@ -24,6 +29,12 @@ export interface RepoChanges {
24
29
  settings: SettingChange[];
25
30
  rulesets: RulesetChange[];
26
31
  labels: LabelChange[];
32
+ variables?: {
33
+ name: string;
34
+ action: ActiveAction;
35
+ oldValue?: string;
36
+ newValue?: string;
37
+ }[];
27
38
  error?: string;
28
39
  }
29
40
  export interface SettingChange {
@@ -90,6 +90,13 @@ function formatSettingsSummary(totals) {
90
90
  ]);
91
91
  if (labelsEntry)
92
92
  parts.push(labelsEntry);
93
+ const variablesEntry = formatCountEntry("variable", "variables", [
94
+ { label: "to create", value: totals.variables?.create ?? 0 },
95
+ { label: "to update", value: totals.variables?.update ?? 0 },
96
+ { label: "to delete", value: totals.variables?.delete ?? 0 },
97
+ ]);
98
+ if (variablesEntry)
99
+ parts.push(variablesEntry);
93
100
  if (parts.length === 0) {
94
101
  return "No changes";
95
102
  }
@@ -112,6 +119,7 @@ export function formatSettingsReportCLI(report) {
112
119
  if (repo.settings.length === 0 &&
113
120
  repo.rulesets.length === 0 &&
114
121
  repo.labels.length === 0 &&
122
+ (repo.variables ?? []).length === 0 &&
115
123
  !repo.error) {
116
124
  continue;
117
125
  }
@@ -231,6 +239,21 @@ export function renderRepoSettingsDiffLines(repo, diffLines) {
231
239
  diffLines.push(`- label "${label.name}"`);
232
240
  }
233
241
  }
242
+ // Blank line before variables if there was content above
243
+ if ((repo.variables ?? []).length > 0 && diffLines.length > startLength) {
244
+ diffLines.push("");
245
+ }
246
+ for (const variable of repo.variables ?? []) {
247
+ if (variable.action === "create") {
248
+ diffLines.push(`+ variable "${variable.name}": ${formatValuePlain(variable.newValue)}`);
249
+ }
250
+ else if (variable.action === "update") {
251
+ diffLines.push(`! variable "${variable.name}": ${formatValuePlain(variable.oldValue)} → ${formatValuePlain(variable.newValue)}`);
252
+ }
253
+ else if (variable.action === "delete") {
254
+ diffLines.push(`- variable "${variable.name}"`);
255
+ }
256
+ }
234
257
  if (repo.error) {
235
258
  diffLines.push(`- Error: ${repo.error}`);
236
259
  }
@@ -252,6 +275,7 @@ export function formatSettingsReportMarkdown(report, dryRun) {
252
275
  if (repo.settings.length === 0 &&
253
276
  repo.rulesets.length === 0 &&
254
277
  repo.labels.length === 0 &&
278
+ (repo.variables ?? []).length === 0 &&
255
279
  !repo.error) {
256
280
  continue;
257
281
  }
@@ -0,0 +1,9 @@
1
+ export interface ISecretEncryptor {
2
+ encrypt(value: string, publicKeyBase64: string): Promise<string>;
3
+ }
4
+ export declare class SodiumEncryptor implements ISecretEncryptor {
5
+ private sodium;
6
+ private initPromise;
7
+ private ensureInitialized;
8
+ encrypt(value: string, publicKeyBase64: string): Promise<string>;
9
+ }
@@ -0,0 +1,29 @@
1
+ export class SodiumEncryptor {
2
+ sodium;
3
+ initPromise = null;
4
+ ensureInitialized() {
5
+ if (!this.initPromise) {
6
+ this.initPromise = (async () => {
7
+ try {
8
+ const sodium = await import("libsodium-wrappers");
9
+ await sodium.default.ready;
10
+ this.sodium = sodium.default;
11
+ }
12
+ catch {
13
+ throw new Error("Failed to load libsodium-wrappers. Install it: npm install libsodium-wrappers");
14
+ }
15
+ })().catch((err) => {
16
+ this.initPromise = null;
17
+ throw err;
18
+ });
19
+ }
20
+ return this.initPromise.then(() => this.sodium);
21
+ }
22
+ async encrypt(value, publicKeyBase64) {
23
+ const sodium = await this.ensureInitialized();
24
+ const messageBytes = sodium.from_string(value);
25
+ const publicKey = sodium.from_base64(publicKeyBase64, sodium.base64_variants.ORIGINAL);
26
+ const encrypted = sodium.crypto_box_seal(messageBytes, publicKey);
27
+ return sodium.to_base64(encrypted, sodium.base64_variants.ORIGINAL);
28
+ }
29
+ }
@@ -0,0 +1,17 @@
1
+ import type { ICommandExecutor } from "../shared/command-executor.js";
2
+ import { type RepoInfo } from "../repo/index.js";
3
+ import { type GhApiOptions } from "../shared/gh-api-utils.js";
4
+ import type { ISecretsStrategy, GitHubSecret, GitHubPublicKey } from "./types.js";
5
+ interface GitHubSecretsStrategyOptions {
6
+ retries?: number;
7
+ cwd: string;
8
+ }
9
+ export declare class GitHubSecretsStrategy implements ISecretsStrategy {
10
+ private api;
11
+ constructor(executor: ICommandExecutor, options: GitHubSecretsStrategyOptions);
12
+ list(repoInfo: RepoInfo, options?: GhApiOptions): Promise<GitHubSecret[]>;
13
+ getPublicKey(repoInfo: RepoInfo, options?: GhApiOptions): Promise<GitHubPublicKey>;
14
+ upsert(repoInfo: RepoInfo, name: string, encryptedValue: string, keyId: string, options?: GhApiOptions): Promise<void>;
15
+ delete(repoInfo: RepoInfo, name: string, options?: GhApiOptions): Promise<void>;
16
+ }
17
+ export {};
@@ -0,0 +1,38 @@
1
+ import { assertGitHubRepo } from "../repo/index.js";
2
+ import { GhApiClient } from "../shared/gh-api-utils.js";
3
+ import { parseApiJson } from "../shared/json-utils.js";
4
+ export class GitHubSecretsStrategy {
5
+ api;
6
+ constructor(executor, options) {
7
+ this.api = new GhApiClient(executor, options.retries ?? 3, options.cwd);
8
+ }
9
+ async list(repoInfo, options) {
10
+ assertGitHubRepo(repoInfo, "GitHub Secrets strategy");
11
+ const endpoint = `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/secrets`;
12
+ const result = await this.api.call("GET", endpoint, {
13
+ options,
14
+ paginate: true,
15
+ });
16
+ const response = parseApiJson(result, "secrets response");
17
+ return response.secrets ?? [];
18
+ }
19
+ async getPublicKey(repoInfo, options) {
20
+ assertGitHubRepo(repoInfo, "GitHub Secrets strategy");
21
+ const endpoint = `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/secrets/public-key`;
22
+ const result = await this.api.call("GET", endpoint, { options });
23
+ return parseApiJson(result, "public key response");
24
+ }
25
+ async upsert(repoInfo, name, encryptedValue, keyId, options) {
26
+ assertGitHubRepo(repoInfo, "GitHub Secrets strategy");
27
+ const endpoint = `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/secrets/${encodeURIComponent(name)}`;
28
+ await this.api.call("PUT", endpoint, {
29
+ payload: { encrypted_value: encryptedValue, key_id: keyId },
30
+ options,
31
+ });
32
+ }
33
+ async delete(repoInfo, name, options) {
34
+ assertGitHubRepo(repoInfo, "GitHub Secrets strategy");
35
+ const endpoint = `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/secrets/${encodeURIComponent(name)}`;
36
+ await this.api.call("DELETE", endpoint, { options });
37
+ }
38
+ }
@@ -0,0 +1,5 @@
1
+ export { SecretsProcessor } from "./processor.js";
2
+ export type { SecretsProcessorOptions, SecretsProcessorResult, } from "./processor.js";
3
+ export { GitHubSecretsStrategy } from "./github-secrets-strategy.js";
4
+ export { SodiumEncryptor, type ISecretEncryptor } from "./encryption.js";
5
+ export type { ISecretsStrategy, GitHubSecret, GitHubPublicKey, } from "./types.js";
@@ -0,0 +1,3 @@
1
+ export { SecretsProcessor } from "./processor.js";
2
+ export { GitHubSecretsStrategy } from "./github-secrets-strategy.js";
3
+ export { SodiumEncryptor } from "./encryption.js";
@@ -0,0 +1,31 @@
1
+ import { type RepoInfo } from "../repo/index.js";
2
+ import type { ISecretsStrategy } from "./types.js";
3
+ import type { ISecretEncryptor } from "./encryption.js";
4
+ import type { IEnvResolver } from "../shared/env-resolver.js";
5
+ import type { SecretConfig } from "../config/index.js";
6
+ type SecretsConfig = Record<string, SecretConfig | boolean> & {
7
+ deleteOrphaned?: boolean;
8
+ };
9
+ export interface SecretsProcessorOptions {
10
+ dryRun?: boolean;
11
+ token?: string;
12
+ noDelete?: boolean;
13
+ }
14
+ export interface SecretsProcessorResult {
15
+ success: boolean;
16
+ repoName: string;
17
+ message: string;
18
+ skipped?: boolean;
19
+ dryRun?: boolean;
20
+ created: number;
21
+ updated: number;
22
+ deleted: number;
23
+ }
24
+ export declare class SecretsProcessor {
25
+ private readonly strategy;
26
+ private readonly encryptor;
27
+ private readonly envResolver;
28
+ constructor(strategy: ISecretsStrategy, encryptor: ISecretEncryptor, envResolver: IEnvResolver);
29
+ process(secretsConfig: SecretsConfig, repoInfo: RepoInfo, options: SecretsProcessorOptions): Promise<SecretsProcessorResult>;
30
+ }
31
+ export {};
@@ -0,0 +1,115 @@
1
+ import { isGitHubRepo, getRepoDisplayName, } from "../repo/index.js";
2
+ export class SecretsProcessor {
3
+ strategy;
4
+ encryptor;
5
+ envResolver;
6
+ constructor(strategy, encryptor, envResolver) {
7
+ this.strategy = strategy;
8
+ this.encryptor = encryptor;
9
+ this.envResolver = envResolver;
10
+ }
11
+ async process(secretsConfig, repoInfo, options) {
12
+ const repoName = getRepoDisplayName(repoInfo);
13
+ if (!isGitHubRepo(repoInfo)) {
14
+ return {
15
+ success: true,
16
+ repoName,
17
+ message: "Skipped: not a GitHub repository",
18
+ skipped: true,
19
+ created: 0,
20
+ updated: 0,
21
+ deleted: 0,
22
+ };
23
+ }
24
+ const githubRepo = repoInfo;
25
+ const { deleteOrphaned: configDeleteOrphaned = false, ...rawEntries } = secretsConfig;
26
+ const { dryRun, token, noDelete } = options;
27
+ const deleteOrphaned = configDeleteOrphaned && !(noDelete ?? false);
28
+ const strategyOptions = { token, host: githubRepo.host };
29
+ const secretEntries = Object.entries(rawEntries).filter((entry) => typeof entry[1] !== "boolean");
30
+ let resolvedValues;
31
+ if (!dryRun && secretEntries.length > 0) {
32
+ resolvedValues = this.envResolver.resolveAll(secretEntries.map(([name, config]) => ({
33
+ name,
34
+ envVar: config.env,
35
+ })));
36
+ }
37
+ else {
38
+ resolvedValues = new Map();
39
+ }
40
+ const currentSecrets = await this.strategy.list(githubRepo, strategyOptions);
41
+ const currentByName = new Set(currentSecrets.map((s) => s.name.toUpperCase()));
42
+ const desiredNames = new Set(secretEntries.map(([name]) => name.toUpperCase()));
43
+ let created = 0;
44
+ let updated = 0;
45
+ let deleted = 0;
46
+ if (!dryRun) {
47
+ if (secretEntries.length > 0) {
48
+ const publicKey = await this.strategy.getPublicKey(githubRepo, strategyOptions);
49
+ for (const [name] of secretEntries) {
50
+ const value = resolvedValues.get(name);
51
+ const encrypted = await this.encryptor.encrypt(value, publicKey.key);
52
+ await this.strategy.upsert(githubRepo, name, encrypted, publicKey.key_id, strategyOptions);
53
+ if (currentByName.has(name.toUpperCase())) {
54
+ updated++;
55
+ }
56
+ else {
57
+ created++;
58
+ }
59
+ }
60
+ }
61
+ if (deleteOrphaned) {
62
+ for (const current of currentSecrets) {
63
+ if (!desiredNames.has(current.name.toUpperCase())) {
64
+ await this.strategy.delete(githubRepo, current.name, strategyOptions);
65
+ deleted++;
66
+ }
67
+ }
68
+ }
69
+ }
70
+ else {
71
+ for (const [name] of secretEntries) {
72
+ if (currentByName.has(name.toUpperCase())) {
73
+ updated++;
74
+ }
75
+ else {
76
+ created++;
77
+ }
78
+ }
79
+ if (deleteOrphaned) {
80
+ for (const current of currentSecrets) {
81
+ if (!desiredNames.has(current.name.toUpperCase())) {
82
+ deleted++;
83
+ }
84
+ }
85
+ }
86
+ }
87
+ const parts = [];
88
+ if (created > 0)
89
+ parts.push(`${created} created`);
90
+ if (updated > 0)
91
+ parts.push(`${updated} updated`);
92
+ if (deleted > 0)
93
+ parts.push(`${deleted} deleted`);
94
+ const summary = parts.length > 0 ? parts.join(", ") : "no changes";
95
+ if (dryRun) {
96
+ return {
97
+ success: true,
98
+ repoName,
99
+ message: `[DRY RUN] ${summary}`,
100
+ dryRun: true,
101
+ created,
102
+ updated,
103
+ deleted,
104
+ };
105
+ }
106
+ return {
107
+ success: true,
108
+ repoName,
109
+ message: summary,
110
+ created,
111
+ updated,
112
+ deleted,
113
+ };
114
+ }
115
+ }
@@ -0,0 +1,21 @@
1
+ import type { RepoInfo } from "../repo/index.js";
2
+ import type { GhApiOptions } from "../shared/gh-api-utils.js";
3
+ export interface GitHubSecret {
4
+ name: string;
5
+ created_at: string;
6
+ updated_at: string;
7
+ }
8
+ export interface GitHubSecretsListResponse {
9
+ total_count: number;
10
+ secrets: GitHubSecret[];
11
+ }
12
+ export interface GitHubPublicKey {
13
+ key_id: string;
14
+ key: string;
15
+ }
16
+ export interface ISecretsStrategy {
17
+ list(repoInfo: RepoInfo, options?: GhApiOptions): Promise<GitHubSecret[]>;
18
+ getPublicKey(repoInfo: RepoInfo, options?: GhApiOptions): Promise<GitHubPublicKey>;
19
+ upsert(repoInfo: RepoInfo, name: string, encryptedValue: string, keyId: string, options?: GhApiOptions): Promise<void>;
20
+ delete(repoInfo: RepoInfo, name: string, options?: GhApiOptions): Promise<void>;
21
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -3,3 +3,4 @@ export { type PropertyDiff, type RulesetPlanEntry, RulesetProcessor, type IRules
3
3
  export { RepoSettingsProcessor, type IRepoSettingsProcessor, type RepoSettingsPlanEntry, GitHubRepoSettingsStrategy, } from "./repo-settings/index.js";
4
4
  export { type LabelsPlanEntry, LabelsProcessor, type ILabelsProcessor, GitHubLabelsStrategy, } from "./labels/index.js";
5
5
  export { type CodeScanningPlanEntry, CodeScanningProcessor, type ICodeScanningProcessor, GitHubCodeScanningStrategy, } from "./code-scanning/index.js";
6
+ export { type VariablesPlanEntry, VariablesProcessor, type IVariablesProcessor, GitHubVariablesStrategy, } from "./variables/index.js";
@@ -8,3 +8,5 @@ export { RepoSettingsProcessor, GitHubRepoSettingsStrategy, } from "./repo-setti
8
8
  export { LabelsProcessor, GitHubLabelsStrategy, } from "./labels/index.js";
9
9
  // Code scanning
10
10
  export { CodeScanningProcessor, GitHubCodeScanningStrategy, } from "./code-scanning/index.js";
11
+ // Variables
12
+ export { VariablesProcessor, GitHubVariablesStrategy, } from "./variables/index.js";
@@ -0,0 +1,10 @@
1
+ import type { GitHubVariable } from "./types.js";
2
+ import type { SettingsAction } from "../base-processor.js";
3
+ export type VariableAction = SettingsAction;
4
+ export interface VariableChange {
5
+ action: VariableAction;
6
+ name: string;
7
+ oldValue?: string;
8
+ newValue?: string;
9
+ }
10
+ export declare function diffVariables(current: GitHubVariable[], desired: Record<string, string>, deleteOrphaned: boolean): VariableChange[];
@@ -0,0 +1,39 @@
1
+ export function diffVariables(current, desired, deleteOrphaned) {
2
+ const changes = [];
3
+ const currentByName = new Map();
4
+ for (const v of current) {
5
+ currentByName.set(v.name.toUpperCase(), v);
6
+ }
7
+ const desiredUpper = new Set(Object.keys(desired).map((n) => n.toUpperCase()));
8
+ for (const [name, desiredValue] of Object.entries(desired)) {
9
+ const currentVar = currentByName.get(name.toUpperCase());
10
+ if (!currentVar) {
11
+ changes.push({ action: "create", name, newValue: desiredValue });
12
+ }
13
+ else if (currentVar.value !== desiredValue) {
14
+ changes.push({
15
+ action: "update",
16
+ name: currentVar.name,
17
+ oldValue: currentVar.value,
18
+ newValue: desiredValue,
19
+ });
20
+ }
21
+ else {
22
+ changes.push({ action: "unchanged", name: currentVar.name });
23
+ }
24
+ }
25
+ if (deleteOrphaned) {
26
+ for (const [nameUpper, currentVar] of currentByName) {
27
+ if (!desiredUpper.has(nameUpper)) {
28
+ changes.push({ action: "delete", name: currentVar.name });
29
+ }
30
+ }
31
+ }
32
+ const actionOrder = {
33
+ delete: 0,
34
+ update: 1,
35
+ create: 2,
36
+ unchanged: 3,
37
+ };
38
+ return changes.sort((a, b) => actionOrder[a.action] - actionOrder[b.action]);
39
+ }
@@ -0,0 +1,16 @@
1
+ import type { VariableChange, VariableAction } from "./diff.js";
2
+ export interface VariablesPlanEntry {
3
+ name: string;
4
+ action: VariableAction;
5
+ oldValue?: string;
6
+ newValue?: string;
7
+ }
8
+ export interface VariablesPlanResult {
9
+ lines: string[];
10
+ creates: number;
11
+ updates: number;
12
+ deletes: number;
13
+ unchanged: number;
14
+ entries: VariablesPlanEntry[];
15
+ }
16
+ export declare function formatVariablesPlan(changes: VariableChange[]): VariablesPlanResult;