reposets 0.4.1 → 0.4.3

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.
@@ -0,0 +1,74 @@
1
+ import { ReposetsCredentialsFile, makeConfigFilesLive } from "../../services/ConfigFiles.js";
2
+ import { AppDirs } from "xdg-effect";
3
+ import { Effect, Option } from "effect";
4
+ import { Command, Options } from "@effect/cli";
5
+
6
+ //#region src/cli/commands/credentials.ts
7
+ const EMPTY_CREDENTIALS = { profiles: {} };
8
+ const profileOption = Options.text("profile").pipe(Options.withDescription("Credential profile name"));
9
+ const githubTokenOption = Options.text("github-token").pipe(Options.withDescription("GitHub personal access token"), Options.optional);
10
+ const opTokenOption = Options.text("op-token").pipe(Options.withDescription("1Password service account token"), Options.optional);
11
+ function redactToken(token) {
12
+ if (token.length <= 8) return "****";
13
+ return `${token.slice(0, 4)}...${token.slice(-4)}`;
14
+ }
15
+ const createCommand = Command.make("create", {
16
+ profile: profileOption,
17
+ githubToken: githubTokenOption,
18
+ opToken: opTokenOption
19
+ }, ({ profile, githubToken, opToken }) => Effect.gen(function* () {
20
+ yield* (yield* AppDirs).ensureConfig;
21
+ const credentialsFile = yield* ReposetsCredentialsFile;
22
+ if ((yield* credentialsFile.loadOrDefault(EMPTY_CREDENTIALS)).profiles[profile]) {
23
+ yield* Effect.logError(`Profile '${profile}' already exists. Delete it first.`);
24
+ return;
25
+ }
26
+ const newProfile = {};
27
+ if (githubToken._tag === "Some") newProfile.github_token = githubToken.value;
28
+ if (opToken._tag === "Some") newProfile.op_service_account_token = opToken.value;
29
+ if (!newProfile.github_token && !newProfile.op_service_account_token) {
30
+ yield* Effect.logError("Provide at least --github-token or --op-token.");
31
+ return;
32
+ }
33
+ yield* credentialsFile.update((current) => ({ profiles: {
34
+ ...current.profiles,
35
+ [profile]: {
36
+ github_token: newProfile.github_token ?? "",
37
+ ...newProfile
38
+ }
39
+ } }), EMPTY_CREDENTIALS);
40
+ yield* Effect.log(`Created profile '${profile}'.`);
41
+ }).pipe(Effect.provide(makeConfigFilesLive(Option.none())))).pipe(Command.withDescription("Add a credential profile"));
42
+ const listCredsCommand = Command.make("list", {}, () => Effect.gen(function* () {
43
+ const creds = yield* (yield* ReposetsCredentialsFile).loadOrDefault(EMPTY_CREDENTIALS);
44
+ if (Object.keys(creds.profiles).length === 0) {
45
+ yield* Effect.log("No credential profiles configured.");
46
+ return;
47
+ }
48
+ for (const [name, profile] of Object.entries(creds.profiles)) {
49
+ yield* Effect.log(`[${name}]`);
50
+ if (profile.github_token) yield* Effect.log(` github_token: ${redactToken(profile.github_token)}`);
51
+ if (profile.op_service_account_token) yield* Effect.log(` op_service_account_token: ${redactToken(profile.op_service_account_token)}`);
52
+ yield* Effect.log("");
53
+ }
54
+ }).pipe(Effect.provide(makeConfigFilesLive(Option.none())))).pipe(Command.withDescription("List profiles (tokens redacted)"));
55
+ const deleteCommand = Command.make("delete", { profile: profileOption }, ({ profile }) => Effect.gen(function* () {
56
+ yield* (yield* AppDirs).ensureConfig;
57
+ const credentialsFile = yield* ReposetsCredentialsFile;
58
+ const creds = yield* credentialsFile.loadOrDefault(EMPTY_CREDENTIALS);
59
+ if (!creds.profiles[profile]) {
60
+ yield* Effect.logError(`Profile '${profile}' not found.`);
61
+ return;
62
+ }
63
+ const { [profile]: _, ...remainingProfiles } = creds.profiles;
64
+ yield* credentialsFile.save({ profiles: remainingProfiles });
65
+ yield* Effect.log(`Deleted profile '${profile}'.`);
66
+ }).pipe(Effect.provide(makeConfigFilesLive(Option.none())))).pipe(Command.withDescription("Remove a profile"));
67
+ const credentialsCommand = Command.make("credentials").pipe(Command.withDescription("Manage credential profiles"), Command.withSubcommands([
68
+ createCommand,
69
+ listCredsCommand,
70
+ deleteCommand
71
+ ]));
72
+
73
+ //#endregion
74
+ export { credentialsCommand };
@@ -0,0 +1,154 @@
1
+ import { ReposetsConfigFile, makeConfigFilesLive } from "../../services/ConfigFiles.js";
2
+ import { Effect } from "effect";
3
+ import { readFileSync } from "node:fs";
4
+ import { Command, Options } from "@effect/cli";
5
+ import { parse } from "smol-toml";
6
+
7
+ //#region src/cli/commands/doctor.ts
8
+ const configOption = Options.file("config").pipe(Options.withDescription("Path to config directory or reposets.config.toml file"), Options.optional);
9
+ const KNOWN_CONFIG_KEYS = /* @__PURE__ */ new Set([
10
+ "owner",
11
+ "log_level",
12
+ "settings",
13
+ "secrets",
14
+ "variables",
15
+ "rulesets",
16
+ "environments",
17
+ "security",
18
+ "code_scanning",
19
+ "groups"
20
+ ]);
21
+ const KNOWN_GROUP_KEYS = /* @__PURE__ */ new Set([
22
+ "owner",
23
+ "repos",
24
+ "credentials",
25
+ "settings",
26
+ "secrets",
27
+ "variables",
28
+ "rulesets",
29
+ "environments",
30
+ "security",
31
+ "code_scanning",
32
+ "cleanup"
33
+ ]);
34
+ const KNOWN_CLEANUP_KEYS = /* @__PURE__ */ new Set([
35
+ "secrets",
36
+ "variables",
37
+ "rulesets",
38
+ "environments"
39
+ ]);
40
+ const KNOWN_CLEANUP_SECRETS_KEYS = /* @__PURE__ */ new Set([
41
+ "actions",
42
+ "dependabot",
43
+ "codespaces",
44
+ "environments"
45
+ ]);
46
+ const KNOWN_CLEANUP_VARIABLES_KEYS = /* @__PURE__ */ new Set(["actions", "environments"]);
47
+ function findClosestMatch(key, known) {
48
+ let best;
49
+ let bestDist = Number.POSITIVE_INFINITY;
50
+ for (const candidate of known) {
51
+ const dist = levenshtein(key, candidate);
52
+ if (dist < bestDist && dist <= 3) {
53
+ bestDist = dist;
54
+ best = candidate;
55
+ }
56
+ }
57
+ return best;
58
+ }
59
+ function levenshtein(a, b) {
60
+ const matrix = [];
61
+ for (let i = 0; i <= a.length; i++) matrix[i] = [i];
62
+ for (let j = 0; j <= b.length; j++) matrix[0][j] = j;
63
+ for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) {
64
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
65
+ matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
66
+ }
67
+ return matrix[a.length][b.length];
68
+ }
69
+ const doctorCommand = Command.make("doctor", { config: configOption }, ({ config }) => Effect.gen(function* () {
70
+ const configFile = yield* ReposetsConfigFile;
71
+ const discoverResult = yield* Effect.either(configFile.discover);
72
+ if (discoverResult._tag === "Left") {
73
+ yield* Effect.logError("No config found. Run 'reposets init' to create one.");
74
+ return;
75
+ }
76
+ const sources = discoverResult.right;
77
+ if (sources.length === 0) {
78
+ yield* Effect.logError("No config found. Run 'reposets init' to create one.");
79
+ return;
80
+ }
81
+ const configPath = sources[0].path;
82
+ let raw;
83
+ try {
84
+ raw = parse(readFileSync(configPath, "utf-8"));
85
+ } catch (err) {
86
+ yield* Effect.logError(`TOML parse error: ${err instanceof Error ? err.message : String(err)}`);
87
+ return;
88
+ }
89
+ let warnings = 0;
90
+ for (const key of Object.keys(raw)) if (!KNOWN_CONFIG_KEYS.has(key)) {
91
+ const suggestion = findClosestMatch(key, KNOWN_CONFIG_KEYS);
92
+ const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
93
+ yield* Effect.log(`Warning: unknown top-level key '${key}'${hint}`);
94
+ warnings++;
95
+ }
96
+ const groups = raw.groups;
97
+ if (groups && typeof groups === "object") {
98
+ for (const [groupName, group] of Object.entries(groups)) if (group && typeof group === "object") {
99
+ for (const key of Object.keys(group)) if (!KNOWN_GROUP_KEYS.has(key)) {
100
+ const suggestion = findClosestMatch(key, KNOWN_GROUP_KEYS);
101
+ const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
102
+ yield* Effect.log(`Warning: unknown key '${key}' in groups.${groupName}${hint}`);
103
+ warnings++;
104
+ }
105
+ }
106
+ }
107
+ if (groups && typeof groups === "object") for (const [groupName, group] of Object.entries(groups)) {
108
+ if (!group || typeof group !== "object") continue;
109
+ const cleanup = group.cleanup;
110
+ if (!cleanup || typeof cleanup !== "object") continue;
111
+ const cleanupObj = cleanup;
112
+ const prefix = `groups.${groupName}.cleanup`;
113
+ for (const key of Object.keys(cleanupObj)) if (!KNOWN_CLEANUP_KEYS.has(key)) {
114
+ const suggestion = findClosestMatch(key, KNOWN_CLEANUP_KEYS);
115
+ const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
116
+ yield* Effect.log(`Warning: unknown key '${key}' in ${prefix}${hint}`);
117
+ warnings++;
118
+ }
119
+ const secrets = cleanupObj.secrets;
120
+ if (secrets && typeof secrets === "object") {
121
+ for (const key of Object.keys(secrets)) if (!KNOWN_CLEANUP_SECRETS_KEYS.has(key)) {
122
+ const suggestion = findClosestMatch(key, KNOWN_CLEANUP_SECRETS_KEYS);
123
+ const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
124
+ yield* Effect.log(`Warning: unknown key '${key}' in ${prefix}.secrets${hint}`);
125
+ warnings++;
126
+ }
127
+ }
128
+ const variables = cleanupObj.variables;
129
+ if (variables && typeof variables === "object") {
130
+ for (const key of Object.keys(variables)) if (!KNOWN_CLEANUP_VARIABLES_KEYS.has(key)) {
131
+ const suggestion = findClosestMatch(key, KNOWN_CLEANUP_VARIABLES_KEYS);
132
+ const hint = suggestion ? ` -- did you mean '${suggestion}'?` : "";
133
+ yield* Effect.log(`Warning: unknown key '${key}' in ${prefix}.variables${hint}`);
134
+ warnings++;
135
+ }
136
+ }
137
+ }
138
+ yield* Effect.log("Schema validation: passed");
139
+ yield* Effect.log("\nRequired fine-grained token permissions:");
140
+ yield* Effect.log(" Repository permissions > Administration (Read and write) -- settings sync");
141
+ yield* Effect.log(" Repository permissions > Secrets (Read and write) -- Actions secrets");
142
+ yield* Effect.log(" Repository permissions > Variables (Read and write) -- Actions variables");
143
+ yield* Effect.log(" Repository permissions > Environments (Read and write) -- environment sync");
144
+ yield* Effect.log(" Repository permissions > Code scanning alerts (Read and write) -- code_scanning sync");
145
+ yield* Effect.log(" Repository permissions > Dependabot alerts (Read and write) -- security feature sync");
146
+ yield* Effect.log(" Repository permissions > Secret scanning alerts (Read and write) -- secret scanning delegation");
147
+ yield* Effect.log(" Account permissions > GPG keys (Read and write) -- secrets encryption key");
148
+ yield* Effect.log(" Organization permissions > Members (Read) -- resolve team slugs (org-level only)");
149
+ if (warnings === 0) yield* Effect.log("\nNo unknown keys detected.");
150
+ else yield* Effect.log(`\n${warnings} warning(s) found.`);
151
+ }).pipe(Effect.provide(makeConfigFilesLive(config)))).pipe(Command.withDescription("Deep config diagnostics with typo detection"));
152
+
153
+ //#endregion
154
+ export { doctorCommand };
@@ -0,0 +1,147 @@
1
+ import { makeConfigFilesLive } from "../../services/ConfigFiles.js";
2
+ import { AppDirs } from "xdg-effect";
3
+ import { Effect, Option } from "effect";
4
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { Command, Options } from "@effect/cli";
7
+
8
+ //#region src/cli/commands/init.ts
9
+ const projectOption = Options.boolean("project").pipe(Options.withDescription("Create config in current directory instead of XDG/home location"), Options.withDefault(false));
10
+ const CONFIG_TEMPLATE = `# reposets configuration
11
+ # See: https://github.com/spencerbeggs/reposets
12
+
13
+ # Default owner for all groups (can be overridden per group)
14
+ # owner = "your-github-username"
15
+
16
+ # --- Settings groups ---
17
+ # [settings.defaults]
18
+ # has_wiki = false
19
+ # has_issues = true
20
+ # delete_branch_on_merge = true
21
+
22
+ # --- Secret groups ---
23
+ # Secrets can be file, value, or resolved kind:
24
+ #
25
+ # [secrets.from-files.file]
26
+ # APP_KEY = "./private/app-key"
27
+ #
28
+ # [secrets.inline.value]
29
+ # STATIC_SECRET = "my-secret"
30
+ #
31
+ # [secrets.from-creds.resolved]
32
+ # NPM_TOKEN = "MY_NPM_TOKEN"
33
+
34
+ # --- Variable groups ---
35
+ # [variables.turbo.value]
36
+ # DO_NOT_TRACK = "1"
37
+ # TURBO_TELEMETRY_DISABLED = "1"
38
+ #
39
+ # [variables.bot.resolved]
40
+ # APP_BOT_NAME = "MY_BOT_NAME"
41
+
42
+ # --- Rulesets ---
43
+ # [rulesets.default-branch]
44
+ # name = "default-branch"
45
+ # enforcement = "active"
46
+ # target = "branch"
47
+ #
48
+ # [rulesets.default-branch.conditions.ref_name]
49
+ # include = ["~DEFAULT_BRANCH"]
50
+ # exclude = []
51
+ #
52
+ # [[rulesets.default-branch.rules]]
53
+ # type = "deletion"
54
+
55
+ # --- Advanced security ---
56
+ # Nested inside a settings group (folded into the same PATCH /repos call).
57
+ # Some fields are GHAS-licensed and only work on public repos or
58
+ # private repos with a GHAS subscription. Org-only fields are silently
59
+ # skipped on personal accounts.
60
+ #
61
+ # [settings.defaults.security_and_analysis]
62
+ # secret_scanning = "enabled"
63
+ # secret_scanning_push_protection = "enabled"
64
+ # dependabot_security_updates = "enabled"
65
+
66
+ # --- Security feature toggles ---
67
+ # Dedicated PUT/DELETE endpoints; omit a key to leave it untouched.
68
+ #
69
+ # [security.oss-defaults]
70
+ # vulnerability_alerts = true
71
+ # automated_security_fixes = true
72
+ # private_vulnerability_reporting = true
73
+
74
+ # --- CodeQL default setup ---
75
+ # Applies via PATCH /repos/{o}/{r}/code-scanning/default-setup.
76
+ # Languages not detected in the repo are skipped with a warning.
77
+ #
78
+ # [code_scanning.oss-defaults]
79
+ # state = "configured"
80
+ # languages = ["javascript-typescript", "python"]
81
+ # query_suite = "extended"
82
+ # threat_model = "remote"
83
+
84
+ # --- Cleanup defaults ---
85
+ # [cleanup]
86
+ # secrets = false
87
+ # variables = false
88
+ # rulesets = false
89
+
90
+ # --- Groups ---
91
+ # [groups.my-projects]
92
+ # repos = ["repo-one", "repo-two"]
93
+ # settings = ["defaults"]
94
+ # secrets = { actions = ["from-files", "from-creds"] }
95
+ # variables = { actions = ["turbo", "bot"] }
96
+ # rulesets = ["default-branch"]
97
+ # security = ["oss-defaults"]
98
+ # code_scanning = ["oss-defaults"]
99
+ `;
100
+ const CREDENTIALS_TEMPLATE = `# reposets credentials (keep this file private)
101
+ # See: https://github.com/spencerbeggs/reposets
102
+
103
+ # [profiles.personal]
104
+ # github_token = "ghp_your_token_here"
105
+ # op_service_account_token = "ops_your_token_here"
106
+ `;
107
+ const CREDENTIALS_FILE = "reposets.credentials.toml";
108
+ const CONFIG_FILE = "reposets.config.toml";
109
+ const initCommand = Command.make("init", { project: projectOption }, ({ project }) => Effect.gen(function* () {
110
+ const xdgConfigDir = yield* (yield* AppDirs).config;
111
+ const targetDir = project ? process.cwd() : xdgConfigDir;
112
+ if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
113
+ const configPath = join(targetDir, CONFIG_FILE);
114
+ const credsPath = join(targetDir, CREDENTIALS_FILE);
115
+ if (existsSync(configPath)) yield* Effect.log(`Config already exists: ${configPath}`);
116
+ else {
117
+ writeFileSync(configPath, CONFIG_TEMPLATE);
118
+ yield* Effect.log(`Created: ${configPath}`);
119
+ }
120
+ if (existsSync(credsPath)) yield* Effect.log(`Credentials already exists: ${credsPath}`);
121
+ else {
122
+ writeFileSync(credsPath, CREDENTIALS_TEMPLATE);
123
+ yield* Effect.log(`Created: ${credsPath}`);
124
+ }
125
+ if (project) {
126
+ const gitignorePath = join(targetDir, ".gitignore");
127
+ if (existsSync(gitignorePath)) {
128
+ if (!readFileSync(gitignorePath, "utf-8").includes(CREDENTIALS_FILE)) {
129
+ appendFileSync(gitignorePath, `\n${CREDENTIALS_FILE}\n`);
130
+ yield* Effect.log(`Added ${CREDENTIALS_FILE} to .gitignore`);
131
+ }
132
+ } else {
133
+ writeFileSync(gitignorePath, `${CREDENTIALS_FILE}\n`);
134
+ yield* Effect.log(`Created .gitignore with ${CREDENTIALS_FILE}`);
135
+ }
136
+ } else {
137
+ const gitignorePath = join(targetDir, ".gitignore");
138
+ if (!existsSync(gitignorePath)) {
139
+ writeFileSync(gitignorePath, `${CREDENTIALS_FILE}\n`);
140
+ yield* Effect.log(`Created .gitignore in ${targetDir}`);
141
+ }
142
+ }
143
+ yield* Effect.log("\nDone! Edit your config and credentials files to get started.");
144
+ }).pipe(Effect.provide(makeConfigFilesLive(Option.none())))).pipe(Command.withDescription("Scaffold config files"));
145
+
146
+ //#endregion
147
+ export { initCommand };
@@ -0,0 +1,49 @@
1
+ import { ReposetsConfigFile, makeConfigFilesLive } from "../../services/ConfigFiles.js";
2
+ import { Effect } from "effect";
3
+ import { Command, Options } from "@effect/cli";
4
+
5
+ //#region src/cli/commands/list.ts
6
+ const configOption = Options.file("config").pipe(Options.withDescription("Path to config directory or reposets.config.toml file"), Options.optional);
7
+ const listCommand = Command.make("list", { config: configOption }, ({ config }) => Effect.gen(function* () {
8
+ const sources = yield* (yield* ReposetsConfigFile).discover;
9
+ if (sources.length === 0) {
10
+ yield* Effect.logError("No config file found.");
11
+ return;
12
+ }
13
+ const parsedConfig = sources[0].value;
14
+ const defaultOwner = parsedConfig.owner ?? "(not set)";
15
+ yield* Effect.log(`Default owner: ${defaultOwner}\n`);
16
+ for (const [groupName, group] of Object.entries(parsedConfig.groups)) {
17
+ const owner = group.owner ?? parsedConfig.owner ?? "(not set)";
18
+ yield* Effect.log(`[${groupName}] (owner: ${owner})`);
19
+ for (const repo of group.repos) yield* Effect.log(` - ${owner}/${repo}`);
20
+ if (group.settings?.length) yield* Effect.log(` settings: ${group.settings.join(", ")}`);
21
+ if (group.environments?.length) yield* Effect.log(` environments: ${group.environments.join(", ")}`);
22
+ if (group.secrets) {
23
+ const parts = [];
24
+ if (group.secrets.actions?.length) parts.push(`actions:[${group.secrets.actions.join(",")}]`);
25
+ if (group.secrets.dependabot?.length) parts.push(`dependabot:[${group.secrets.dependabot.join(",")}]`);
26
+ if (group.secrets.codespaces?.length) parts.push(`codespaces:[${group.secrets.codespaces.join(",")}]`);
27
+ if (group.secrets.environments) {
28
+ for (const [envName, envGroups] of Object.entries(group.secrets.environments)) if (envGroups.length) parts.push(`environments.${envName}:[${envGroups.join(",")}]`);
29
+ }
30
+ if (parts.length) yield* Effect.log(` secrets: ${parts.join(", ")}`);
31
+ }
32
+ if (group.variables) {
33
+ const parts = [];
34
+ if (group.variables.actions?.length) parts.push(`actions:[${group.variables.actions.join(",")}]`);
35
+ if (group.variables.environments) {
36
+ for (const [envName, envGroups] of Object.entries(group.variables.environments)) if (envGroups.length) parts.push(`environments.${envName}:[${envGroups.join(",")}]`);
37
+ }
38
+ if (parts.length) yield* Effect.log(` variables: ${parts.join(", ")}`);
39
+ }
40
+ if (group.rulesets?.length) yield* Effect.log(` rulesets: ${group.rulesets.join(", ")}`);
41
+ if (group.security?.length) yield* Effect.log(` security: ${group.security.join(", ")}`);
42
+ if (group.code_scanning?.length) yield* Effect.log(` code_scanning: ${group.code_scanning.join(", ")}`);
43
+ if (group.credentials) yield* Effect.log(` credentials: ${group.credentials}`);
44
+ yield* Effect.log("");
45
+ }
46
+ }).pipe(Effect.provide(makeConfigFilesLive(config)))).pipe(Command.withDescription("Show config summary"));
47
+
48
+ //#endregion
49
+ export { listCommand };
@@ -0,0 +1,70 @@
1
+ import { ReposetsConfigFile, ReposetsCredentialsFile, makeConfigFilesLive } from "../../services/ConfigFiles.js";
2
+ import { OnePasswordClientLive } from "../../services/OnePasswordClient.js";
3
+ import { CredentialResolverLive } from "../../services/CredentialResolver.js";
4
+ import { GitHubClientLive } from "../../services/GitHubClient.js";
5
+ import { SyncLoggerLive } from "../../services/SyncLogger.js";
6
+ import { SyncEngine, SyncEngineLive } from "../../services/SyncEngine.js";
7
+ import { Effect, Layer } from "effect";
8
+ import { dirname } from "node:path";
9
+ import { Command, Options } from "@effect/cli";
10
+
11
+ //#region src/cli/commands/sync.ts
12
+ const configOption = Options.file("config").pipe(Options.withDescription("Path to config directory or reposets.config.toml file"), Options.optional);
13
+ const groupOption = Options.text("group").pipe(Options.withDescription("Sync only a specific repo group"), Options.optional);
14
+ const repoOption = Options.text("repo").pipe(Options.withDescription("Sync only a specific repo"), Options.optional);
15
+ const dryRunOption = Options.boolean("dry-run").pipe(Options.withDescription("Preview changes without making them"), Options.withDefault(false));
16
+ const noCleanupOption = Options.boolean("no-cleanup").pipe(Options.withDescription("Skip cleanup of undeclared resources"), Options.withDefault(false));
17
+ const logLevelOption = Options.choice("log-level", [
18
+ "silent",
19
+ "info",
20
+ "verbose",
21
+ "debug"
22
+ ]).pipe(Options.withDescription("Set output verbosity (overrides log_level in config)"), Options.optional);
23
+ const syncCommand = Command.make("sync", {
24
+ config: configOption,
25
+ group: groupOption,
26
+ repo: repoOption,
27
+ dryRun: dryRunOption,
28
+ noCleanup: noCleanupOption,
29
+ logLevel: logLevelOption
30
+ }, ({ config, group, repo, dryRun, noCleanup, logLevel: logLevelFlag }) => Effect.gen(function* () {
31
+ const sources = yield* (yield* ReposetsConfigFile).discover;
32
+ if (sources.length === 0) {
33
+ yield* Effect.logError("No config file found.");
34
+ return;
35
+ }
36
+ const parsedConfig = sources[0].value;
37
+ const configDir = dirname(sources[0].path);
38
+ const credentials = yield* (yield* ReposetsCredentialsFile).loadOrDefault({ profiles: {} });
39
+ const profileNames = Object.keys(credentials.profiles);
40
+ const defaultProfile = profileNames.length === 1 ? profileNames[0] : void 0;
41
+ const token = defaultProfile ? credentials.profiles[defaultProfile]?.github_token : void 0;
42
+ if (!token) {
43
+ yield* Effect.logError("No GitHub token found. Run 'reposets credentials create' first.");
44
+ return;
45
+ }
46
+ const logLevel = logLevelFlag._tag === "Some" ? logLevelFlag.value : parsedConfig.log_level;
47
+ const githubLayer = GitHubClientLive(token);
48
+ const opLayer = OnePasswordClientLive;
49
+ const resolverLayer = Layer.provide(CredentialResolverLive, opLayer);
50
+ const loggerLayer = SyncLoggerLive({
51
+ dryRun,
52
+ logLevel
53
+ });
54
+ const engineLayer = Layer.provideMerge(SyncEngineLive, Layer.merge(Layer.merge(githubLayer, resolverLayer), loggerLayer));
55
+ const groupFilter = group._tag === "Some" ? group.value : void 0;
56
+ const repoFilter = repo._tag === "Some" ? repo.value : void 0;
57
+ if (dryRun && logLevel !== "silent") yield* Effect.log("DRY RUN — no changes will be made\n");
58
+ yield* Effect.provide(Effect.gen(function* () {
59
+ yield* (yield* SyncEngine).syncAll(parsedConfig, credentials, {
60
+ dryRun,
61
+ noCleanup,
62
+ groupFilter,
63
+ repoFilter,
64
+ configDir
65
+ });
66
+ }), engineLayer);
67
+ }).pipe(Effect.provide(makeConfigFilesLive(config)))).pipe(Command.withDescription("Sync repos with GitHub"));
68
+
69
+ //#endregion
70
+ export { syncCommand };
@@ -0,0 +1,53 @@
1
+ import { ReposetsConfigFile, ReposetsCredentialsFile, makeConfigFilesLive } from "../../services/ConfigFiles.js";
2
+ import { Effect } from "effect";
3
+ import { existsSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { Command, Options } from "@effect/cli";
6
+
7
+ //#region src/cli/commands/validate.ts
8
+ const configOption = Options.file("config").pipe(Options.withDescription("Path to config directory or reposets.config.toml file"), Options.optional);
9
+ const validateCommand = Command.make("validate", { config: configOption }, ({ config }) => Effect.gen(function* () {
10
+ const configFile = yield* ReposetsConfigFile;
11
+ let hasErrors = false;
12
+ const configResult = yield* Effect.either(configFile.discover);
13
+ if (configResult._tag === "Left") {
14
+ yield* Effect.logError(`Config validation failed: ${configResult.left.message}`);
15
+ return;
16
+ }
17
+ const sources = configResult.right;
18
+ if (sources.length === 0) {
19
+ yield* Effect.logError("No config file found.");
20
+ return;
21
+ }
22
+ yield* Effect.log("Config schema: valid");
23
+ const parsedConfig = sources[0].value;
24
+ const configDir = dirname(sources[0].path);
25
+ for (const [groupName, group] of Object.entries(parsedConfig.secrets)) if ("file" in group) for (const [entryName, filePath] of Object.entries(group.file)) {
26
+ const fullPath = join(configDir, filePath);
27
+ if (!existsSync(fullPath)) {
28
+ yield* Effect.logError(`secrets.${groupName}.file.${entryName}: file not found: ${fullPath}`);
29
+ hasErrors = true;
30
+ }
31
+ }
32
+ for (const [groupName, group] of Object.entries(parsedConfig.variables)) if ("file" in group) for (const [entryName, filePath] of Object.entries(group.file)) {
33
+ const fullPath = join(configDir, filePath);
34
+ if (!existsSync(fullPath)) {
35
+ yield* Effect.logError(`variables.${groupName}.file.${entryName}: file not found: ${fullPath}`);
36
+ hasErrors = true;
37
+ }
38
+ }
39
+ const credentialsFile = yield* ReposetsCredentialsFile;
40
+ const credsResult = yield* Effect.either(credentialsFile.load);
41
+ if (credsResult._tag === "Left") yield* Effect.log("Credentials file: not found (optional)");
42
+ else {
43
+ yield* Effect.log("Credentials schema: valid");
44
+ for (const [groupName, group] of Object.entries(parsedConfig.groups)) if (group.credentials && !credsResult.right.profiles[group.credentials]) {
45
+ yield* Effect.logError(`Group '${groupName}': references unknown credentials profile '${group.credentials}'`);
46
+ hasErrors = true;
47
+ }
48
+ }
49
+ if (!hasErrors) yield* Effect.log("\nAll checks passed.");
50
+ }).pipe(Effect.provide(makeConfigFilesLive(config)))).pipe(Command.withDescription("Validate config without API calls"));
51
+
52
+ //#endregion
53
+ export { validateCommand };
package/errors.js ADDED
@@ -0,0 +1,12 @@
1
+ import { Data } from "effect";
2
+
3
+ //#region src/errors.ts
4
+ /* v8 ignore start -- TaggedError class declarations, covered via errors.test.ts */
5
+ var ResolveError = class extends Data.TaggedError("ResolveError") {};
6
+ var OnePasswordError = class extends Data.TaggedError("OnePasswordError") {};
7
+ var GitHubApiError = class extends Data.TaggedError("GitHubApiError") {};
8
+ var SyncError = class extends Data.TaggedError("SyncError") {};
9
+ /* v8 ignore stop */
10
+
11
+ //#endregion
12
+ export { GitHubApiError, OnePasswordError, ResolveError, SyncError };