@aspruyt/xfg 6.2.0 → 6.4.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 (52) hide show
  1. package/dist/cli/branch-utils.d.ts +1 -5
  2. package/dist/cli/branch-utils.js +1 -22
  3. package/dist/cli/program.js +41 -3
  4. package/dist/cli/repo-sync-runner.js +7 -2
  5. package/dist/cli/secrets-command.d.ts +25 -0
  6. package/dist/cli/secrets-command.js +75 -0
  7. package/dist/cli/settings-factories.d.ts +2 -1
  8. package/dist/cli/settings-factories.js +6 -1
  9. package/dist/cli/settings-report-builder.d.ts +6 -1
  10. package/dist/cli/settings-report-builder.js +21 -2
  11. package/dist/cli/settings-runner.js +7 -0
  12. package/dist/cli/types.d.ts +4 -2
  13. package/dist/config/index.d.ts +2 -2
  14. package/dist/config/index.js +1 -1
  15. package/dist/config/loader.js +46 -17
  16. package/dist/config/normalizer.js +86 -1
  17. package/dist/config/types.d.ts +21 -0
  18. package/dist/config/validator.d.ts +4 -0
  19. package/dist/config/validator.js +178 -5
  20. package/dist/config/validators/group-validator.js +7 -0
  21. package/dist/config/validators/repo-entry-validator.js +7 -0
  22. package/dist/output/settings-report.d.ts +11 -0
  23. package/dist/output/settings-report.js +24 -0
  24. package/dist/secrets/encryption.d.ts +9 -0
  25. package/dist/secrets/encryption.js +29 -0
  26. package/dist/secrets/github-secrets-strategy.d.ts +17 -0
  27. package/dist/secrets/github-secrets-strategy.js +38 -0
  28. package/dist/secrets/index.d.ts +5 -0
  29. package/dist/secrets/index.js +3 -0
  30. package/dist/secrets/processor.d.ts +31 -0
  31. package/dist/secrets/processor.js +115 -0
  32. package/dist/secrets/types.d.ts +21 -0
  33. package/dist/secrets/types.js +1 -0
  34. package/dist/settings/index.d.ts +1 -0
  35. package/dist/settings/index.js +2 -0
  36. package/dist/settings/variables/diff.d.ts +10 -0
  37. package/dist/settings/variables/diff.js +39 -0
  38. package/dist/settings/variables/formatter.d.ts +16 -0
  39. package/dist/settings/variables/formatter.js +70 -0
  40. package/dist/settings/variables/github-variables-strategy.d.ts +17 -0
  41. package/dist/settings/variables/github-variables-strategy.js +40 -0
  42. package/dist/settings/variables/index.d.ts +4 -0
  43. package/dist/settings/variables/index.js +2 -0
  44. package/dist/settings/variables/processor.d.ts +19 -0
  45. package/dist/settings/variables/processor.js +60 -0
  46. package/dist/settings/variables/types.d.ts +18 -0
  47. package/dist/settings/variables/types.js +1 -0
  48. package/dist/shared/branch-validation.d.ts +2 -0
  49. package/dist/shared/branch-validation.js +19 -0
  50. package/dist/shared/env-resolver.d.ts +16 -0
  51. package/dist/shared/env-resolver.js +33 -0
  52. package/package.json +3 -1
@@ -7,6 +7,7 @@ export interface PRMergeOptions {
7
7
  deleteBranch?: boolean;
8
8
  bypassReason?: string;
9
9
  labels?: string[];
10
+ branch?: string;
10
11
  }
11
12
  export type RulesetTarget = "branch" | "tag";
12
13
  export type RulesetEnforcement = "active" | "disabled" | "evaluate";
@@ -267,6 +268,9 @@ export interface CodeScanningSettings {
267
268
  querySuite?: CodeScanningQuerySuite;
268
269
  languages?: CodeScanningLanguage[];
269
270
  }
271
+ export interface SecretConfig {
272
+ env: string;
273
+ }
270
274
  export interface RepoSettings {
271
275
  /** GitHub rulesets keyed by name */
272
276
  rulesets?: Record<string, Ruleset>;
@@ -276,6 +280,10 @@ export interface RepoSettings {
276
280
  labels?: Record<string, Label>;
277
281
  /** GitHub code scanning default setup */
278
282
  codeScanning?: CodeScanningSettings;
283
+ /** GitHub Actions variables keyed by name */
284
+ variables?: Record<string, string> & {
285
+ deleteOrphaned?: boolean;
286
+ };
279
287
  deleteOrphaned?: boolean;
280
288
  }
281
289
  export type ContentValue = Record<string, unknown> | string | string[];
@@ -336,6 +344,9 @@ export interface RawRootSettings {
336
344
  repo?: GitHubRepoSettings | false;
337
345
  labels?: Record<string, Label | false>;
338
346
  codeScanning?: CodeScanningSettings | false;
347
+ variables?: Record<string, string | false> & {
348
+ deleteOrphaned?: boolean;
349
+ };
339
350
  deleteOrphaned?: boolean;
340
351
  }
341
352
  export interface RawRepoSettings {
@@ -347,6 +358,10 @@ export interface RawRepoSettings {
347
358
  inherit?: boolean;
348
359
  };
349
360
  codeScanning?: CodeScanningSettings | false;
361
+ variables?: Record<string, string | false> & {
362
+ inherit?: boolean;
363
+ deleteOrphaned?: boolean;
364
+ };
350
365
  deleteOrphaned?: boolean;
351
366
  }
352
367
  export interface RawRepoConfig {
@@ -373,6 +388,9 @@ export interface RawConfig {
373
388
  githubHosts?: string[];
374
389
  deleteOrphaned?: boolean;
375
390
  settings?: RawRootSettings;
391
+ secrets?: Record<string, SecretConfig | boolean> & {
392
+ deleteOrphaned?: boolean;
393
+ };
376
394
  }
377
395
  export interface FileContent {
378
396
  fileName: string;
@@ -402,5 +420,8 @@ export interface Config {
402
420
  githubHosts?: string[];
403
421
  deleteOrphaned?: boolean;
404
422
  settings?: RepoSettings;
423
+ secrets?: Record<string, SecretConfig | boolean> & {
424
+ deleteOrphaned?: boolean;
425
+ };
405
426
  }
406
427
  export {};
@@ -9,4 +9,8 @@ export declare function validateRawConfig(config: RawConfig): void;
9
9
  * @throws ValidationError if neither files nor settings are present
10
10
  */
11
11
  export declare function validateForSync(config: RawConfig): void;
12
+ export declare function validateVariableSecretOverlaps(config: RawConfig): void;
12
13
  export declare function hasActionableSettings(settings: RawRootSettings | RawRepoSettings | undefined): boolean;
14
+ export declare function validateVariableName(name: string): void;
15
+ export declare function validateSecretName(name: string): void;
16
+ export declare function validateSecretsConfig(config: RawConfig): void;
@@ -1,10 +1,13 @@
1
1
  import { validateFileName } from "./validators/file-validator.js";
2
2
  import { isPlainObject } from "../shared/type-guards.js";
3
3
  import { ValidationError } from "../shared/errors.js";
4
+ import { validateBranchName } from "../shared/branch-validation.js";
4
5
  import { validateFileConfigFields, validateSettings, } from "./validators/shared.js";
5
6
  import { validateGroups, validateConditionalGroups, } from "./validators/group-validator.js";
6
7
  import { validateRepoEntry } from "./validators/repo-entry-validator.js";
7
8
  const CONFIG_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
9
+ const VARIABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
10
+ const VARIABLE_RESERVED_KEYS = new Set(["deleteOrphaned", "inherit"]);
8
11
  const CONFIG_ID_MAX_LENGTH = 64;
9
12
  function validateConfigId(config) {
10
13
  if (!config.id || typeof config.id !== "string") {
@@ -42,6 +45,9 @@ function validateRootSettings(config) {
42
45
  if (config.settings.labels && "inherit" in config.settings.labels) {
43
46
  throw new ValidationError("'inherit' is a reserved key and cannot be used as a label name");
44
47
  }
48
+ if (config.settings.variables && "inherit" in config.settings.variables) {
49
+ throw new ValidationError("'inherit' is not allowed in root-level variables (nothing to inherit from)");
50
+ }
45
51
  }
46
52
  function validateGithubHosts(config) {
47
53
  if (config.githubHosts === undefined)
@@ -63,6 +69,9 @@ function validateGithubHosts(config) {
63
69
  }
64
70
  }
65
71
  function validatePrOptions(config) {
72
+ if (config.prOptions?.branch !== undefined) {
73
+ validateBranchName(config.prOptions.branch);
74
+ }
66
75
  if (config.prOptions?.labels === undefined)
67
76
  return;
68
77
  if (!Array.isArray(config.prOptions.labels)) {
@@ -110,15 +119,18 @@ export function validateRawConfig(config) {
110
119
  const hasCondGrpFiles = hasConditionalGroupFiles(config);
111
120
  const hasCondGrpSettings = hasConditionalGroupSettingsPresent(config);
112
121
  const hasCondGrpPR = hasConditionalGroupPR(config);
122
+ const hasSecrets = isPlainObject(config.secrets) && Object.keys(config.secrets).length > 0;
113
123
  if (!hasFiles &&
114
124
  !hasSettings &&
115
125
  !hasGrpFiles &&
116
126
  !hasGrpSettings &&
117
127
  !hasCondGrpFiles &&
118
128
  !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.");
129
+ !hasCondGrpPR &&
130
+ !hasSecrets) {
131
+ throw new ValidationError("Config requires at least one of: 'files', 'settings', or 'secrets'. " +
132
+ "Use 'files' to sync configuration files, 'settings' to manage repository settings, " +
133
+ "or 'secrets' to manage GitHub Actions secrets.");
122
134
  }
123
135
  validateRootFiles(config);
124
136
  if (config.deleteOrphaned !== undefined &&
@@ -159,8 +171,111 @@ export function validateForSync(config) {
159
171
  !hasCondGrpFiles &&
160
172
  !hasCondGrpSettings &&
161
173
  !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.");
174
+ throw new ValidationError("Config requires at least one of: 'files' or 'settings' (rulesets, labels, variables, repo config). " +
175
+ "Use 'files' to sync configuration files, or 'settings' to manage repository settings. " +
176
+ "For secrets, use 'xfg secrets sync'.");
177
+ }
178
+ // Validate variable names across all settings
179
+ const allSettings = [
180
+ config.settings,
181
+ ...config.repos.map((r) => r.settings),
182
+ ...Object.values(config.groups ?? {}).map((g) => g.settings),
183
+ ...(config.conditionalGroups ?? []).map((cg) => cg.settings),
184
+ ];
185
+ for (const settings of allSettings) {
186
+ if (!settings?.variables)
187
+ continue;
188
+ const vars = settings.variables;
189
+ if (vars.deleteOrphaned !== undefined &&
190
+ typeof vars.deleteOrphaned !== "boolean") {
191
+ throw new ValidationError("variables.deleteOrphaned must be a boolean");
192
+ }
193
+ if (vars.inherit !== undefined && typeof vars.inherit !== "boolean") {
194
+ throw new ValidationError("variables.inherit must be a boolean");
195
+ }
196
+ for (const [name, value] of Object.entries(vars)) {
197
+ if (VARIABLE_RESERVED_KEYS.has(name))
198
+ continue;
199
+ validateVariableName(name);
200
+ if (value !== false && typeof value !== "string") {
201
+ throw new ValidationError(`Variable '${name}' must have a string value (got ${typeof value}). Quote numeric values in YAML: "${String(value)}".`);
202
+ }
203
+ }
204
+ // Reject duplicate case-insensitive variable names
205
+ const seenVarNames = new Map();
206
+ for (const name of Object.keys(settings.variables)) {
207
+ if (VARIABLE_RESERVED_KEYS.has(name))
208
+ continue;
209
+ const upper = name.toUpperCase();
210
+ const existing = seenVarNames.get(upper);
211
+ if (existing) {
212
+ throw new ValidationError(`Duplicate variable name: '${name}' and '${existing}' collide (GitHub treats variable names case-insensitively).`);
213
+ }
214
+ seenVarNames.set(upper, name);
215
+ }
216
+ }
217
+ // Validate secret names and configs
218
+ validateSecretsConfig(config);
219
+ // Cross-validate: no overlap between global secret names and variable names
220
+ validateVariableSecretOverlaps(config);
221
+ }
222
+ export function validateVariableSecretOverlaps(config) {
223
+ if (!config.secrets)
224
+ return;
225
+ const { deleteOrphaned: _, ...secretEntries } = config.secrets;
226
+ // GitHub treats secret/variable names case-insensitively for collision purposes
227
+ const secretNames = new Set(Object.keys(secretEntries)
228
+ .filter((k) => typeof secretEntries[k] !== "boolean")
229
+ .map((n) => n.toUpperCase()));
230
+ if (secretNames.size === 0)
231
+ return;
232
+ // Check root-level variables
233
+ if (config.settings?.variables) {
234
+ const { deleteOrphaned: _rd, inherit: _ri, ...rootVarEntries } = config.settings.variables;
235
+ const rootVariableNames = Object.keys(rootVarEntries).filter((k) => typeof rootVarEntries[k] !== "boolean");
236
+ const overlapping = rootVariableNames.filter((n) => secretNames.has(n.toUpperCase()));
237
+ if (overlapping.length > 0) {
238
+ throw new ValidationError(`${overlapping.join(", ")} overlap between root variables and secrets. ` +
239
+ "GitHub does not allow variables and secrets with the same name.");
240
+ }
241
+ }
242
+ for (const repo of config.repos) {
243
+ const { deleteOrphaned: _d, inherit: _i, ...varEntries } = (repo.settings?.variables ?? {});
244
+ const variableNames = Object.keys(varEntries).filter((k) => typeof varEntries[k] !== "boolean");
245
+ const overlapping = variableNames.filter((n) => secretNames.has(n.toUpperCase()));
246
+ if (overlapping.length > 0) {
247
+ throw new ValidationError(`Repo '${repo.git}': ${overlapping.join(", ")} overlap between variables and secrets. ` +
248
+ "GitHub does not allow variables and secrets with the same name.");
249
+ }
250
+ }
251
+ // Check group-level variables
252
+ if (isPlainObject(config.groups)) {
253
+ for (const [groupName, group] of Object.entries(config.groups)) {
254
+ if (!group.settings?.variables)
255
+ continue;
256
+ const { deleteOrphaned: _gd, inherit: _gi, ...groupVarEntries } = group.settings.variables;
257
+ const groupVariableNames = Object.keys(groupVarEntries).filter((k) => typeof groupVarEntries[k] !== "boolean");
258
+ const overlapping = groupVariableNames.filter((n) => secretNames.has(n.toUpperCase()));
259
+ if (overlapping.length > 0) {
260
+ throw new ValidationError(`Group '${groupName}': ${overlapping.join(", ")} overlap between variables and secrets. ` +
261
+ "GitHub does not allow variables and secrets with the same name.");
262
+ }
263
+ }
264
+ }
265
+ // Check conditional group-level variables
266
+ if (Array.isArray(config.conditionalGroups)) {
267
+ for (let i = 0; i < config.conditionalGroups.length; i++) {
268
+ const cg = config.conditionalGroups[i];
269
+ if (!cg.settings?.variables)
270
+ continue;
271
+ const { deleteOrphaned: _cd, inherit: _ci, ...cgVarEntries } = cg.settings.variables;
272
+ const cgVariableNames = Object.keys(cgVarEntries).filter((k) => typeof cgVarEntries[k] !== "boolean");
273
+ const overlapping = cgVariableNames.filter((n) => secretNames.has(n.toUpperCase()));
274
+ if (overlapping.length > 0) {
275
+ throw new ValidationError(`Conditional group ${i}: ${overlapping.join(", ")} overlap between variables and secrets. ` +
276
+ "GitHub does not allow variables and secrets with the same name.");
277
+ }
278
+ }
164
279
  }
165
280
  }
166
281
  export function hasActionableSettings(settings) {
@@ -180,5 +295,63 @@ export function hasActionableSettings(settings) {
180
295
  if (settings.codeScanning) {
181
296
  return true;
182
297
  }
298
+ if (settings.variables) {
299
+ const { deleteOrphaned, inherit: _i, ...entries } = settings.variables;
300
+ if (Object.keys(entries).length > 0 || deleteOrphaned === true) {
301
+ return true;
302
+ }
303
+ }
183
304
  return false;
184
305
  }
306
+ export function validateVariableName(name) {
307
+ if (!VARIABLE_NAME_PATTERN.test(name)) {
308
+ throw new ValidationError(`Variable name '${name}' contains invalid characters. Only alphanumeric and underscore allowed.`);
309
+ }
310
+ if (name.startsWith("GITHUB_")) {
311
+ throw new ValidationError(`Variable name '${name}' cannot start with 'GITHUB_' (reserved prefix).`);
312
+ }
313
+ }
314
+ export function validateSecretName(name) {
315
+ if (!VARIABLE_NAME_PATTERN.test(name)) {
316
+ throw new ValidationError(`Secret name '${name}' contains invalid characters. Only alphanumeric and underscore allowed.`);
317
+ }
318
+ if (name.startsWith("GITHUB_")) {
319
+ throw new ValidationError(`Secret name '${name}' cannot start with 'GITHUB_' (reserved prefix).`);
320
+ }
321
+ }
322
+ function validateSecretEntry(name, config) {
323
+ validateSecretName(name);
324
+ if (!config.env || typeof config.env !== "string") {
325
+ throw new ValidationError(`Secret '${name}' requires an 'env' field (string) specifying the environment variable source.`);
326
+ }
327
+ }
328
+ export function validateSecretsConfig(config) {
329
+ if (!config.secrets)
330
+ return;
331
+ const { deleteOrphaned, ...entries } = config.secrets;
332
+ // Reject 'deleteOrphaned' used as a secret name (it's a reserved peer key)
333
+ if (deleteOrphaned !== undefined && typeof deleteOrphaned !== "boolean") {
334
+ throw new ValidationError("'deleteOrphaned' is a reserved key in secrets config and cannot be used as a secret name.");
335
+ }
336
+ // Reject boolean true — only false (opt-out) is valid
337
+ for (const [name, value] of Object.entries(entries)) {
338
+ if (value === true) {
339
+ throw new ValidationError(`Secret '${name}' is set to true, which is not valid. Use false to opt out, or provide a SecretConfig object.`);
340
+ }
341
+ }
342
+ // Reject duplicate case-insensitive secret names
343
+ const seen = new Map();
344
+ for (const name of Object.keys(entries)) {
345
+ const upper = name.toUpperCase();
346
+ const existing = seen.get(upper);
347
+ if (existing) {
348
+ throw new ValidationError(`Duplicate secret name: '${name}' and '${existing}' collide (GitHub treats secret names case-insensitively).`);
349
+ }
350
+ seen.set(upper, name);
351
+ }
352
+ for (const [name, value] of Object.entries(entries)) {
353
+ if (typeof value === "boolean")
354
+ continue;
355
+ validateSecretEntry(name, value);
356
+ }
357
+ }
@@ -1,6 +1,7 @@
1
1
  import { resolveExtendsChain } from "../extends-resolver.js";
2
2
  import { isPlainObject } from "../../shared/type-guards.js";
3
3
  import { ValidationError } from "../../shared/errors.js";
4
+ import { validateBranchName } from "../../shared/branch-validation.js";
4
5
  import { validateFileConfigFields, validateSettings, buildRootSettingsContext, } from "./shared.js";
5
6
  function validateGroupExtends(groupName, extends_, groupNames) {
6
7
  if (typeof extends_ === "string") {
@@ -102,6 +103,9 @@ export function validateGroups(config) {
102
103
  if (group.settings !== undefined) {
103
104
  validateSettings(group.settings, `groups.${groupName}`, rootCtx);
104
105
  }
106
+ if (group.prOptions?.branch !== undefined) {
107
+ validateBranchName(group.prOptions.branch);
108
+ }
105
109
  }
106
110
  validateNoCircularExtends(config.groups);
107
111
  }
@@ -163,5 +167,8 @@ export function validateConditionalGroups(config) {
163
167
  if (entry.settings !== undefined) {
164
168
  validateSettings(entry.settings, ctx, rootCtx);
165
169
  }
170
+ if (entry.prOptions?.branch !== undefined) {
171
+ validateBranchName(entry.prOptions.branch);
172
+ }
166
173
  }
167
174
  }
@@ -3,6 +3,7 @@ import { validateFileName } from "./file-validator.js";
3
3
  import { isPlainObject } from "../../shared/type-guards.js";
4
4
  import { escapeRegExp } from "../../shared/regex-utils.js";
5
5
  import { ValidationError } from "../../shared/errors.js";
6
+ import { validateBranchName } from "../../shared/branch-validation.js";
6
7
  import { validateFileConfigFields, validateSettings, buildRootSettingsContext, enrichSettingsContext, } from "./shared.js";
7
8
  function isValidGitUrl(url) {
8
9
  return /^git@[^:]+:.+$/.test(url) || /^https?:\/\/[^/]+\/.+$/.test(url);
@@ -156,10 +157,16 @@ function validateRepoSettingsEntry(config, repo, repoLabel) {
156
157
  }
157
158
  validateSettings(repo.settings, `Repo ${repoLabel}`, rootCtx);
158
159
  }
160
+ function validateRepoPrOptions(repo) {
161
+ if (repo.prOptions?.branch !== undefined) {
162
+ validateBranchName(repo.prOptions.branch);
163
+ }
164
+ }
159
165
  export function validateRepoEntry(config, repo, index) {
160
166
  const repoLabel = validateRepoGitField(repo, index);
161
167
  validateRepoOrigins(config, repo, repoLabel);
162
168
  validateRepoGroups(config, repo, index);
163
169
  validateRepoFiles(config, repo, index, repoLabel);
164
170
  validateRepoSettingsEntry(config, repo, repoLabel);
171
+ validateRepoPrOptions(repo);
165
172
  }
@@ -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 {};