@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.
- package/dist/cli/branch-utils.d.ts +1 -5
- package/dist/cli/branch-utils.js +1 -22
- package/dist/cli/program.js +41 -3
- package/dist/cli/repo-sync-runner.js +7 -2
- package/dist/cli/secrets-command.d.ts +25 -0
- package/dist/cli/secrets-command.js +75 -0
- package/dist/cli/settings-factories.d.ts +2 -1
- package/dist/cli/settings-factories.js +6 -1
- package/dist/cli/settings-report-builder.d.ts +6 -1
- package/dist/cli/settings-report-builder.js +21 -2
- package/dist/cli/settings-runner.js +7 -0
- package/dist/cli/types.d.ts +4 -2
- package/dist/config/index.d.ts +2 -2
- package/dist/config/index.js +1 -1
- package/dist/config/loader.js +46 -17
- package/dist/config/normalizer.js +86 -1
- package/dist/config/types.d.ts +21 -0
- package/dist/config/validator.d.ts +4 -0
- package/dist/config/validator.js +178 -5
- package/dist/config/validators/group-validator.js +7 -0
- package/dist/config/validators/repo-entry-validator.js +7 -0
- package/dist/output/settings-report.d.ts +11 -0
- package/dist/output/settings-report.js +24 -0
- package/dist/secrets/encryption.d.ts +9 -0
- package/dist/secrets/encryption.js +29 -0
- package/dist/secrets/github-secrets-strategy.d.ts +17 -0
- package/dist/secrets/github-secrets-strategy.js +38 -0
- package/dist/secrets/index.d.ts +5 -0
- package/dist/secrets/index.js +3 -0
- package/dist/secrets/processor.d.ts +31 -0
- package/dist/secrets/processor.js +115 -0
- package/dist/secrets/types.d.ts +21 -0
- package/dist/secrets/types.js +1 -0
- package/dist/settings/index.d.ts +1 -0
- package/dist/settings/index.js +2 -0
- package/dist/settings/variables/diff.d.ts +10 -0
- package/dist/settings/variables/diff.js +39 -0
- package/dist/settings/variables/formatter.d.ts +16 -0
- package/dist/settings/variables/formatter.js +70 -0
- package/dist/settings/variables/github-variables-strategy.d.ts +17 -0
- package/dist/settings/variables/github-variables-strategy.js +40 -0
- package/dist/settings/variables/index.d.ts +4 -0
- package/dist/settings/variables/index.js +2 -0
- package/dist/settings/variables/processor.d.ts +19 -0
- package/dist/settings/variables/processor.js +60 -0
- package/dist/settings/variables/types.d.ts +18 -0
- package/dist/settings/variables/types.js +1 -0
- package/dist/shared/branch-validation.d.ts +2 -0
- package/dist/shared/branch-validation.js +19 -0
- package/dist/shared/env-resolver.d.ts +16 -0
- package/dist/shared/env-resolver.js +33 -0
- package/package.json +3 -1
|
@@ -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 {};
|
package/dist/settings/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/settings/index.js
CHANGED
|
@@ -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;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { countActions } from "../base-processor.js";
|
|
3
|
+
export function formatVariablesPlan(changes) {
|
|
4
|
+
const lines = [];
|
|
5
|
+
const entries = [];
|
|
6
|
+
const { create: creates, update: updates, delete: deletes, unchanged, } = countActions(changes);
|
|
7
|
+
const grouped = {
|
|
8
|
+
create: [],
|
|
9
|
+
update: [],
|
|
10
|
+
delete: [],
|
|
11
|
+
unchanged: [],
|
|
12
|
+
};
|
|
13
|
+
for (const c of changes) {
|
|
14
|
+
grouped[c.action].push(c);
|
|
15
|
+
}
|
|
16
|
+
if (grouped.create.length > 0) {
|
|
17
|
+
lines.push(chalk.bold(" Create:"));
|
|
18
|
+
for (const change of grouped.create) {
|
|
19
|
+
lines.push(chalk.green(` + variable "${change.name}"`));
|
|
20
|
+
if (change.newValue !== undefined) {
|
|
21
|
+
lines.push(chalk.green(` value: "${change.newValue}"`));
|
|
22
|
+
}
|
|
23
|
+
entries.push({
|
|
24
|
+
name: change.name,
|
|
25
|
+
action: "create",
|
|
26
|
+
newValue: change.newValue,
|
|
27
|
+
});
|
|
28
|
+
lines.push("");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (grouped.update.length > 0) {
|
|
32
|
+
lines.push(chalk.bold(" Update:"));
|
|
33
|
+
for (const change of grouped.update) {
|
|
34
|
+
lines.push(chalk.yellow(` ~ variable "${change.name}"`));
|
|
35
|
+
if (change.oldValue !== undefined && change.newValue !== undefined) {
|
|
36
|
+
lines.push(chalk.yellow(` value: "${change.oldValue}" → "${change.newValue}"`));
|
|
37
|
+
}
|
|
38
|
+
entries.push({
|
|
39
|
+
name: change.name,
|
|
40
|
+
action: "update",
|
|
41
|
+
oldValue: change.oldValue,
|
|
42
|
+
newValue: change.newValue,
|
|
43
|
+
});
|
|
44
|
+
lines.push("");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (grouped.delete.length > 0) {
|
|
48
|
+
lines.push(chalk.bold(" Delete:"));
|
|
49
|
+
for (const change of grouped.delete) {
|
|
50
|
+
lines.push(chalk.red(` - variable "${change.name}"`));
|
|
51
|
+
entries.push({ name: change.name, action: "delete" });
|
|
52
|
+
}
|
|
53
|
+
lines.push("");
|
|
54
|
+
}
|
|
55
|
+
for (const change of grouped.unchanged) {
|
|
56
|
+
entries.push({ name: change.name, action: "unchanged" });
|
|
57
|
+
}
|
|
58
|
+
const total = creates + updates + deletes;
|
|
59
|
+
if (total > 0) {
|
|
60
|
+
const parts = [];
|
|
61
|
+
if (creates > 0)
|
|
62
|
+
parts.push(`${creates} to create`);
|
|
63
|
+
if (updates > 0)
|
|
64
|
+
parts.push(`${updates} to update`);
|
|
65
|
+
if (deletes > 0)
|
|
66
|
+
parts.push(`${deletes} to delete`);
|
|
67
|
+
lines.push(` Plan: ${total} variables (${parts.join(", ")})`);
|
|
68
|
+
}
|
|
69
|
+
return { lines, creates, updates, deletes, unchanged, entries };
|
|
70
|
+
}
|
|
@@ -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 { IVariablesStrategy, GitHubVariable } from "./types.js";
|
|
5
|
+
interface GitHubVariablesStrategyOptions {
|
|
6
|
+
retries?: number;
|
|
7
|
+
cwd: string;
|
|
8
|
+
}
|
|
9
|
+
export declare class GitHubVariablesStrategy implements IVariablesStrategy {
|
|
10
|
+
private api;
|
|
11
|
+
constructor(executor: ICommandExecutor, options: GitHubVariablesStrategyOptions);
|
|
12
|
+
list(repoInfo: RepoInfo, options?: GhApiOptions): Promise<GitHubVariable[]>;
|
|
13
|
+
create(repoInfo: RepoInfo, name: string, value: string, options?: GhApiOptions): Promise<void>;
|
|
14
|
+
update(repoInfo: RepoInfo, name: string, value: string, options?: GhApiOptions): Promise<void>;
|
|
15
|
+
delete(repoInfo: RepoInfo, name: string, options?: GhApiOptions): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
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 GitHubVariablesStrategy {
|
|
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 Variables strategy");
|
|
11
|
+
const endpoint = `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/variables`;
|
|
12
|
+
const result = await this.api.call("GET", endpoint, {
|
|
13
|
+
options,
|
|
14
|
+
paginate: true,
|
|
15
|
+
});
|
|
16
|
+
const response = parseApiJson(result, "variables response");
|
|
17
|
+
return response.variables ?? [];
|
|
18
|
+
}
|
|
19
|
+
async create(repoInfo, name, value, options) {
|
|
20
|
+
assertGitHubRepo(repoInfo, "GitHub Variables strategy");
|
|
21
|
+
const endpoint = `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/variables`;
|
|
22
|
+
await this.api.call("POST", endpoint, {
|
|
23
|
+
payload: { name, value },
|
|
24
|
+
options,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
async update(repoInfo, name, value, options) {
|
|
28
|
+
assertGitHubRepo(repoInfo, "GitHub Variables strategy");
|
|
29
|
+
const endpoint = `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/variables/${encodeURIComponent(name)}`;
|
|
30
|
+
await this.api.call("PATCH", endpoint, {
|
|
31
|
+
payload: { value },
|
|
32
|
+
options,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
async delete(repoInfo, name, options) {
|
|
36
|
+
assertGitHubRepo(repoInfo, "GitHub Variables strategy");
|
|
37
|
+
const endpoint = `/repos/${repoInfo.owner}/${repoInfo.repo}/actions/variables/${encodeURIComponent(name)}`;
|
|
38
|
+
await this.api.call("DELETE", endpoint, { options });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { type VariableChange, type VariableAction } from "./diff.js";
|
|
2
|
+
export { type VariablesPlanEntry } from "./formatter.js";
|
|
3
|
+
export { VariablesProcessor, type IVariablesProcessor } from "./processor.js";
|
|
4
|
+
export { GitHubVariablesStrategy } from "./github-variables-strategy.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { RepoConfig } from "../../config/index.js";
|
|
2
|
+
import type { RepoInfo } from "../../repo/index.js";
|
|
3
|
+
import { type VariablesPlanResult } from "./formatter.js";
|
|
4
|
+
import type { IVariablesStrategy } from "./types.js";
|
|
5
|
+
import { type BaseProcessorOptions, type BaseProcessorResult, type ISettingsProcessor, type ChangeCounts } from "../base-processor.js";
|
|
6
|
+
export type IVariablesProcessor = ISettingsProcessor<VariablesProcessorOptions, VariablesProcessorResult>;
|
|
7
|
+
export interface VariablesProcessorOptions extends BaseProcessorOptions {
|
|
8
|
+
noDelete?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface VariablesProcessorResult extends BaseProcessorResult {
|
|
11
|
+
changes?: ChangeCounts;
|
|
12
|
+
planOutput?: VariablesPlanResult;
|
|
13
|
+
}
|
|
14
|
+
export declare class VariablesProcessor implements IVariablesProcessor {
|
|
15
|
+
private readonly strategy;
|
|
16
|
+
constructor(strategy: IVariablesStrategy);
|
|
17
|
+
process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: VariablesProcessorOptions): Promise<VariablesProcessorResult>;
|
|
18
|
+
private applySettings;
|
|
19
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { diffVariables } from "./diff.js";
|
|
2
|
+
import { formatVariablesPlan } from "./formatter.js";
|
|
3
|
+
import { withGitHubGuards, countActions, buildDryRunResult, buildApplyResult, } from "../base-processor.js";
|
|
4
|
+
export class VariablesProcessor {
|
|
5
|
+
strategy;
|
|
6
|
+
constructor(strategy) {
|
|
7
|
+
this.strategy = strategy;
|
|
8
|
+
}
|
|
9
|
+
async process(repoConfig, repoInfo, options) {
|
|
10
|
+
return withGitHubGuards(repoConfig, repoInfo, options, {
|
|
11
|
+
hasDesiredSettings: (rc) => {
|
|
12
|
+
const vars = rc.settings?.variables ?? {};
|
|
13
|
+
const { deleteOrphaned, ...entries } = vars;
|
|
14
|
+
return Object.keys(entries).length > 0 || deleteOrphaned === true;
|
|
15
|
+
},
|
|
16
|
+
emptySettingsMessage: "No variables configured",
|
|
17
|
+
applySettings: (githubRepo, rc, opts, token, repoName) => this.applySettings(githubRepo, rc, opts, token, repoName),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
async applySettings(githubRepo, repoConfig, options, effectiveToken, repoName) {
|
|
21
|
+
const { dryRun, noDelete } = options;
|
|
22
|
+
const settings = repoConfig.settings;
|
|
23
|
+
const { deleteOrphaned: varDeleteOrphaned = false, ...desiredVariables } = (settings?.variables ?? {});
|
|
24
|
+
const deleteOrphaned = varDeleteOrphaned && !(noDelete ?? false);
|
|
25
|
+
const strategyOptions = { token: effectiveToken, host: githubRepo.host };
|
|
26
|
+
const currentVariables = await this.strategy.list(githubRepo, strategyOptions);
|
|
27
|
+
const changes = diffVariables(currentVariables, desiredVariables, deleteOrphaned);
|
|
28
|
+
const changeCounts = countActions(changes);
|
|
29
|
+
const planOutput = formatVariablesPlan(changes);
|
|
30
|
+
if (dryRun) {
|
|
31
|
+
return buildDryRunResult(repoName, changeCounts, { planOutput });
|
|
32
|
+
}
|
|
33
|
+
let appliedCount = 0;
|
|
34
|
+
for (const change of changes) {
|
|
35
|
+
switch (change.action) {
|
|
36
|
+
case "create":
|
|
37
|
+
if (change.newValue !== undefined) {
|
|
38
|
+
await this.strategy.create(githubRepo, change.name, change.newValue, strategyOptions);
|
|
39
|
+
appliedCount++;
|
|
40
|
+
}
|
|
41
|
+
break;
|
|
42
|
+
case "update":
|
|
43
|
+
if (change.newValue !== undefined) {
|
|
44
|
+
await this.strategy.update(githubRepo, change.name, change.newValue, strategyOptions);
|
|
45
|
+
appliedCount++;
|
|
46
|
+
}
|
|
47
|
+
break;
|
|
48
|
+
case "delete":
|
|
49
|
+
await this.strategy.delete(githubRepo, change.name, strategyOptions);
|
|
50
|
+
appliedCount++;
|
|
51
|
+
break;
|
|
52
|
+
case "unchanged":
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return buildApplyResult(repoName, changeCounts, appliedCount, {
|
|
57
|
+
planOutput,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { RepoInfo } from "../../repo/index.js";
|
|
2
|
+
import type { GhApiOptions } from "../../shared/gh-api-utils.js";
|
|
3
|
+
export interface GitHubVariable {
|
|
4
|
+
name: string;
|
|
5
|
+
value: string;
|
|
6
|
+
created_at: string;
|
|
7
|
+
updated_at: string;
|
|
8
|
+
}
|
|
9
|
+
export interface GitHubVariablesListResponse {
|
|
10
|
+
total_count: number;
|
|
11
|
+
variables: GitHubVariable[];
|
|
12
|
+
}
|
|
13
|
+
export interface IVariablesStrategy {
|
|
14
|
+
list(repoInfo: RepoInfo, options?: GhApiOptions): Promise<GitHubVariable[]>;
|
|
15
|
+
create(repoInfo: RepoInfo, name: string, value: string, options?: GhApiOptions): Promise<void>;
|
|
16
|
+
update(repoInfo: RepoInfo, name: string, value: string, options?: GhApiOptions): Promise<void>;
|
|
17
|
+
delete(repoInfo: RepoInfo, name: string, options?: GhApiOptions): Promise<void>;
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ValidationError } from "./errors.js";
|
|
2
|
+
/** Validates a user-provided branch name against git's naming rules. @throws ValidationError if the branch name is invalid */
|
|
3
|
+
export function validateBranchName(branchName) {
|
|
4
|
+
if (!branchName || branchName.trim() === "") {
|
|
5
|
+
throw new ValidationError("Branch name cannot be empty");
|
|
6
|
+
}
|
|
7
|
+
if (branchName.startsWith(".") || branchName.startsWith("-")) {
|
|
8
|
+
throw new ValidationError('Branch name cannot start with "." or "-"');
|
|
9
|
+
}
|
|
10
|
+
// Git disallows: space, ~, ^, :, ?, *, [, \, and consecutive dots (..)
|
|
11
|
+
if (/[\s~^:?*[\\]/.test(branchName) || branchName.includes("..")) {
|
|
12
|
+
throw new ValidationError("Branch name contains invalid characters");
|
|
13
|
+
}
|
|
14
|
+
if (branchName.endsWith("/") ||
|
|
15
|
+
branchName.endsWith(".lock") ||
|
|
16
|
+
branchName.endsWith(".")) {
|
|
17
|
+
throw new ValidationError("Branch name has invalid ending");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface IEnvResolver {
|
|
2
|
+
resolve(envName: string): string;
|
|
3
|
+
resolveAll(entries: {
|
|
4
|
+
name: string;
|
|
5
|
+
envVar: string;
|
|
6
|
+
}[]): Map<string, string>;
|
|
7
|
+
}
|
|
8
|
+
export declare class EnvResolver implements IEnvResolver {
|
|
9
|
+
private readonly env;
|
|
10
|
+
constructor(env: Record<string, string | undefined>);
|
|
11
|
+
resolve(envName: string): string;
|
|
12
|
+
resolveAll(entries: {
|
|
13
|
+
name: string;
|
|
14
|
+
envVar: string;
|
|
15
|
+
}[]): Map<string, string>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export class EnvResolver {
|
|
2
|
+
env;
|
|
3
|
+
constructor(env) {
|
|
4
|
+
this.env = env;
|
|
5
|
+
}
|
|
6
|
+
resolve(envName) {
|
|
7
|
+
const value = this.env[envName];
|
|
8
|
+
if (value === undefined) {
|
|
9
|
+
throw new Error(`Environment variable '${envName}' is not set.`);
|
|
10
|
+
}
|
|
11
|
+
if (value === "") {
|
|
12
|
+
throw new Error(`Environment variable '${envName}' is empty.`);
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
resolveAll(entries) {
|
|
17
|
+
const missing = new Set();
|
|
18
|
+
const result = new Map();
|
|
19
|
+
for (const { name, envVar } of entries) {
|
|
20
|
+
const value = this.env[envVar];
|
|
21
|
+
if (value === undefined || value === "") {
|
|
22
|
+
missing.add(envVar);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
result.set(name, value);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (missing.size > 0) {
|
|
29
|
+
throw new Error(`Missing environment variables: ${[...missing].join(", ")}`);
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aspruyt/xfg",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.4.0",
|
|
4
4
|
"description": "Manage files, settings, and repositories across GitHub, Azure DevOps, and GitLab — declaratively, from a single YAML config",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -74,10 +74,12 @@
|
|
|
74
74
|
"chalk": "^5.3.0",
|
|
75
75
|
"commander": "^14.0.2",
|
|
76
76
|
"json5": "^2.2.3",
|
|
77
|
+
"libsodium-wrappers": "^0.8.4",
|
|
77
78
|
"p-retry": "^8.0.0",
|
|
78
79
|
"yaml": "^2.4.5"
|
|
79
80
|
},
|
|
80
81
|
"devDependencies": {
|
|
82
|
+
"@types/libsodium-wrappers": "^0.8.0",
|
|
81
83
|
"@types/node": "^24.12.2",
|
|
82
84
|
"bottleneck": "^2.19.5",
|
|
83
85
|
"c8": "^11.0.0",
|