reposets 0.4.3 → 1.0.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 (53) hide show
  1. package/README.md +87 -75
  2. package/bin/reposets.js +68 -17
  3. package/cli/commands/credentials.js +170 -49
  4. package/cli/commands/doctor.js +359 -86
  5. package/cli/commands/drift.js +48 -0
  6. package/cli/commands/history.js +203 -0
  7. package/cli/commands/init.js +110 -104
  8. package/cli/commands/list.js +60 -39
  9. package/cli/commands/nuke.js +127 -0
  10. package/cli/commands/sync.js +219 -59
  11. package/cli/commands/validate.js +57 -41
  12. package/cli/flags.js +36 -0
  13. package/cli/logger.js +48 -0
  14. package/index.d.ts +378 -1406
  15. package/index.js +3 -18
  16. package/lib/config-refs.js +76 -0
  17. package/lib/credential-labels.js +0 -0
  18. package/lib/fingerprint.js +52 -0
  19. package/lib/org-only.js +61 -0
  20. package/lib/schema-issues.js +50 -0
  21. package/package.json +11 -10
  22. package/schemas/annotations.js +81 -0
  23. package/schemas/common.js +83 -48
  24. package/schemas/config.js +214 -212
  25. package/schemas/credentials.js +190 -54
  26. package/schemas/environment.js +27 -21
  27. package/schemas/ruleset.js +235 -139
  28. package/services/ConfigFiles.js +126 -104
  29. package/services/CredentialResolver.js +97 -33
  30. package/services/OnePasswordClient.js +88 -16
  31. package/services/SyncLogger.js +107 -76
  32. package/store/AppliedState.js +0 -0
  33. package/store/RepoCache.js +86 -0
  34. package/store/SyncJournal.js +92 -0
  35. package/store/migrations.js +86 -0
  36. package/sync/SyncEngine.js +156 -0
  37. package/sync/decide.js +56 -0
  38. package/sync/phase.js +55 -0
  39. package/sync/phases/cleanup.js +220 -0
  40. package/sync/phases/code-scanning.js +187 -0
  41. package/sync/phases/environments.js +106 -0
  42. package/sync/phases/index.js +39 -0
  43. package/sync/phases/resource.js +149 -0
  44. package/sync/phases/rulesets.js +186 -0
  45. package/sync/phases/secrets.js +138 -0
  46. package/sync/phases/security.js +129 -0
  47. package/sync/phases/settings.js +274 -0
  48. package/sync/phases/variables.js +132 -0
  49. package/tsdoc-metadata.json +1 -1
  50. package/errors.js +0 -12
  51. package/lib/crypto.js +0 -27
  52. package/services/GitHubClient.js +0 -875
  53. package/services/SyncEngine.js +0 -580
@@ -0,0 +1,127 @@
1
+ import { CONFIG_FILENAME, CREDENTIALS_FILENAME } from "../../services/ConfigFiles.js";
2
+ import { Effect, FileSystem, Path } from "effect";
3
+ import { Command, Flag, Prompt } from "effect/unstable/cli";
4
+ import { AppDirs } from "@effected/xdg";
5
+
6
+ //#region src/cli/commands/nuke.ts
7
+ const forceFlag = Flag.boolean("force").pipe(Flag.withDescription("Delete without asking. Intended for scripts; there is no undo"));
8
+ /**
9
+ * Everything reposets has written to this machine.
10
+ *
11
+ * @remarks
12
+ * Assembled by **looking**, not by assuming: only paths that exist are
13
+ * returned, so the confirmation lists what will actually be deleted rather than
14
+ * everything that might. A prompt that overstates is a prompt people learn to
15
+ * skim.
16
+ *
17
+ * The upward walk mirrors the config resolver's own chain — a project-local
18
+ * file anywhere between the working directory and the filesystem root — because
19
+ * the file a user is thinking of is the one the CLI would load, and that is not
20
+ * always the one in the current directory.
21
+ */
22
+ const findTargets = (fs, path, appDirs, from) => Effect.gen(function* () {
23
+ const candidates = [];
24
+ let dir = from;
25
+ for (;;) {
26
+ candidates.push({
27
+ path: path.join(dir, CONFIG_FILENAME),
28
+ what: "project config",
29
+ cost: "your groups and settings"
30
+ }, {
31
+ path: path.join(dir, CREDENTIALS_FILENAME),
32
+ what: "project credentials",
33
+ cost: "token references, not tokens"
34
+ });
35
+ const parent = path.dirname(dir);
36
+ if (parent === dir) break;
37
+ dir = parent;
38
+ }
39
+ candidates.push({
40
+ path: path.join(appDirs.dirs.config, CONFIG_FILENAME),
41
+ what: "user config",
42
+ cost: "your groups and settings"
43
+ }, {
44
+ path: path.join(appDirs.dirs.config, CREDENTIALS_FILENAME),
45
+ what: "user credentials",
46
+ cost: "token references, not tokens"
47
+ }, {
48
+ path: path.join(appDirs.dirs.state, "store.db"),
49
+ what: "state database",
50
+ cost: "run history AND the drift baselines — drift detection restarts from nothing"
51
+ });
52
+ const present = [];
53
+ for (const candidate of candidates) {
54
+ const info = yield* fs.stat(candidate.path).pipe(Effect.option);
55
+ if (info._tag === "Some" && info.value.type === "File") present.push(candidate);
56
+ }
57
+ return present;
58
+ });
59
+ /**
60
+ * `reposets nuke` — remove everything reposets has written locally.
61
+ *
62
+ * @remarks
63
+ * **Nothing on GitHub is touched.** Every secret, variable, ruleset and setting
64
+ * this tool has ever applied stays exactly where it is; this removes the local
65
+ * files and the local database, which is what "leave no trace on this machine"
66
+ * means.
67
+ *
68
+ * Without `--force` it lists what it found and asks. The prompt defaults to
69
+ * **no**, and a non-interactive shell is refused rather than assumed: a
70
+ * destructive command that proceeds because nobody was there to answer is the
71
+ * one failure mode worth engineering against.
72
+ *
73
+ * @public
74
+ */
75
+ const nukeHandler = (force) => Effect.gen(function* () {
76
+ const fs = yield* FileSystem.FileSystem;
77
+ const path = yield* Path.Path;
78
+ const appDirs = yield* AppDirs;
79
+ const targets = yield* findTargets(fs, path, appDirs, process.cwd());
80
+ if (targets.length === 0) {
81
+ yield* Effect.log("Nothing to remove — no reposets files found on this machine.");
82
+ return;
83
+ }
84
+ yield* Effect.log("This will delete:");
85
+ for (const target of targets) {
86
+ yield* Effect.log(` ${target.path}`);
87
+ yield* Effect.log(` ${target.what} — loses ${target.cost}`);
88
+ }
89
+ yield* Effect.log("");
90
+ yield* Effect.log("Nothing on GitHub is touched. Everything reposets applied stays applied.");
91
+ if (!force) {
92
+ if (process.stdin.isTTY !== true) {
93
+ yield* Effect.logError("");
94
+ yield* Effect.logError("Refusing: not an interactive terminal, and --force was not given.");
95
+ yield* Effect.sync(() => {
96
+ process.exitCode = 1;
97
+ });
98
+ return;
99
+ }
100
+ yield* Effect.log("");
101
+ if (!(yield* Prompt.confirm({ message: `Delete ${targets.length} file${targets.length === 1 ? "" : "s"}?` }).pipe(Effect.orElseSucceed(() => false)))) {
102
+ yield* Effect.log("Nothing was deleted.");
103
+ return;
104
+ }
105
+ }
106
+ let removed = 0;
107
+ for (const target of targets) {
108
+ const outcome = yield* fs.remove(target.path).pipe(Effect.result);
109
+ if (outcome._tag === "Failure") {
110
+ yield* Effect.logError(` could not remove ${target.path} — ${String(outcome.failure)}`);
111
+ continue;
112
+ }
113
+ removed += 1;
114
+ yield* Effect.log(` removed ${target.path}`);
115
+ }
116
+ yield* Effect.log("");
117
+ yield* Effect.log(removed === targets.length ? `Done. ${removed} file${removed === 1 ? "" : "s"} removed.` : `Removed ${removed} of ${targets.length}; the rest are listed above.`);
118
+ });
119
+ /**
120
+ * The `nuke` command.
121
+ *
122
+ * @public
123
+ */
124
+ const nukeCommand = Command.make("nuke", { force: forceFlag }, ({ force }) => nukeHandler(force)).pipe(Command.withDescription("Delete every reposets file on this machine. Nothing on GitHub is touched"));
125
+
126
+ //#endregion
127
+ export { nukeCommand, nukeHandler };
@@ -1,70 +1,230 @@
1
- import { ReposetsConfigFile, ReposetsCredentialsFile, makeConfigFilesLive } from "../../services/ConfigFiles.js";
1
+ import { ReposetsConfigFile, ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
2
+ import { formatSchemaIssue } from "../../lib/schema-issues.js";
3
+ import { SyncJournal, SyncJournalLive } from "../../store/SyncJournal.js";
2
4
  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";
5
+ import { CredentialResolver, CredentialResolverLive } from "../../services/CredentialResolver.js";
6
+ import { danglingReferences } from "../../lib/config-refs.js";
7
+ import { SyncLogger, SyncLoggerLive } from "../../services/SyncLogger.js";
8
+ import { AppliedStateLive } from "../../store/AppliedState.js";
9
+ import { RepoCacheLive } from "../../store/RepoCache.js";
10
+ import { PHASE_NAMES } from "../../sync/phase.js";
11
+ import { allPhases } from "../../sync/phases/index.js";
12
+ import { SyncEngine, SyncEngineLive } from "../../sync/SyncEngine.js";
13
+ import { Effect, Layer, Option } from "effect";
14
+ import { Command, Flag } from "effect/unstable/cli";
15
+ import { CodeScanning, DeploymentEnvironment, GitHubClient, GitHubRepository, RepositorySecret, RepositorySecurity, RepositoryVariable, Ruleset, WorkflowDispatch } from "@effected/github";
10
16
 
11
17
  //#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.");
18
+ const isPhaseName = (value) => PHASE_NAMES.includes(value);
19
+ const phaseSet = (values) => new Set(values.filter(isPhaseName));
20
+ /**
21
+ * Phase names the user typed that do not exist.
22
+ *
23
+ * @remarks
24
+ * Unrecognised names used to be filtered out silently, and an empty `only` set
25
+ * reads downstream as "no filter" — so `--only nonsense` ran **all eight
26
+ * phases**. The flag reached for to limit the blast radius of a destructive
27
+ * command did the opposite of what it says, and reported success.
28
+ *
29
+ * Any unknown name is refused, including alongside valid ones: a typo in
30
+ * `--only settings,secrts` means the user asked for two phases and would
31
+ * otherwise get one, silently. That is a narrower wrong answer, not a safer one.
32
+ */
33
+ const unknownPhases = (values) => values.filter((v) => !isPhaseName(v));
34
+ /**
35
+ * Group names by the credential profile they authenticate as.
36
+ *
37
+ * @remarks
38
+ * A token is fixed at `GitHubClient` construction and every resource service is
39
+ * built from that client, so a second identity is a second service graph and
40
+ * there is no swapping one mid-run. Partitioning makes that explicit: a sync
41
+ * under two identities really is two runs' worth of authority.
42
+ *
43
+ * Insertion order is the config's own order of first appearance, so output
44
+ * still tracks the file a user is reading — with one exception inherited from
45
+ * JS rather than chosen here: `config.groups` is a plain object, so a group
46
+ * whose name parses as an integer is enumerated first whatever the file says.
47
+ *
48
+ * @param only - a `--group` filter. A name matching nothing yields an empty
49
+ * map, which the caller reports rather than treating as "nothing to do".
50
+ *
51
+ * @public
52
+ */
53
+ const partitionByProfile = (groups, only) => {
54
+ const partitions = /* @__PURE__ */ new Map();
55
+ for (const [groupName, group] of Object.entries(groups)) {
56
+ if (only !== void 0 && groupName !== only) continue;
57
+ const existing = partitions.get(group.credentials);
58
+ if (existing === void 0) partitions.set(group.credentials, [groupName]);
59
+ else existing.push(groupName);
60
+ }
61
+ return partitions;
62
+ };
63
+ /**
64
+ * `reposets sync` — apply the config to every selected repository.
65
+ *
66
+ * @remarks
67
+ * The layer graph is built here rather than at the entrypoint because it
68
+ * depends on values only known after the config and credentials have loaded:
69
+ * the GitHub token, and the logger's tier and dry-run flag.
70
+ *
71
+ * @public
72
+ */
73
+ const syncHandler = (input) => Effect.gen(function* () {
74
+ const configFile = yield* ReposetsConfigFile;
75
+ const credentialsFile = yield* ReposetsCredentialsFile;
76
+ const discovered = yield* configFile.discover.pipe(Effect.result);
77
+ if (discovered._tag === "Failure") {
78
+ yield* Effect.logError(String(discovered.failure));
79
+ const failure = discovered.failure;
80
+ for (const line of formatSchemaIssue(failure.issue)) yield* Effect.logError(` ${line}`);
81
+ if (failure.cause !== void 0) yield* Effect.logError(` ${String(failure.cause)}`);
82
+ yield* Effect.sync(() => {
83
+ process.exitCode = 1;
84
+ });
85
+ return;
86
+ }
87
+ const source = discovered.success[0];
88
+ if (source === void 0) {
89
+ yield* Effect.logError("No config found. Run 'reposets init' to create one.");
90
+ yield* Effect.sync(() => {
91
+ process.exitCode = 1;
92
+ });
93
+ return;
94
+ }
95
+ const config = source.value;
96
+ const configDir = source.path.slice(0, source.path.lastIndexOf("/"));
97
+ const credentials = yield* credentialsFile.loadOrDefault({ profiles: {} });
98
+ const badPhases = [...unknownPhases(input.only), ...unknownPhases(input.skip)];
99
+ if (badPhases.length > 0) {
100
+ yield* Effect.logError(`Unknown phase name(s): ${badPhases.join(", ")}. Valid phases: ${PHASE_NAMES.join(", ")}`);
101
+ yield* Effect.logError("Nothing was synced.");
102
+ yield* Effect.sync(() => {
103
+ process.exitCode = 1;
104
+ });
105
+ return;
106
+ }
107
+ const dangling = danglingReferences(config);
108
+ if (dangling.length > 0) {
109
+ yield* Effect.logError("Config references sections that do not exist:");
110
+ for (const ref of dangling) {
111
+ const defined = ref.defined.length === 0 ? "none defined" : `defined: ${ref.defined.join(", ")}`;
112
+ yield* Effect.logError(` ${ref.where}: '${ref.name}' does not exist — ${defined}`);
113
+ }
114
+ yield* Effect.logError("");
115
+ yield* Effect.logError("Nothing was synced. Fix the references or run 'reposets validate' for the full list.");
116
+ yield* Effect.sync(() => {
117
+ process.exitCode = 1;
118
+ });
34
119
  return;
35
120
  }
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.");
121
+ const partitions = partitionByProfile(config.groups, input.group);
122
+ if (partitions.size === 0) {
123
+ yield* Effect.logError(input.group === void 0 ? "No groups configured. Add a [groups.<name>] section to sync anything." : `No group named '${input.group}'. Configured: ${Object.keys(config.groups).join(", ") || "none"}`);
124
+ yield* Effect.sync(() => {
125
+ process.exitCode = 1;
126
+ });
44
127
  return;
45
128
  }
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);
129
+ const resolverLayer = Layer.provide(CredentialResolverLive, OnePasswordClientLive);
50
130
  const loggerLayer = SyncLoggerLive({
51
- dryRun,
52
- logLevel
131
+ dryRun: input.dryRun,
132
+ debug: input.debug
53
133
  });
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"));
134
+ const sharedLayer = Layer.mergeAll(SyncJournalLive, AppliedStateLive, RepoCacheLive, resolverLayer, loggerLayer);
135
+ const report = yield* Effect.gen(function* () {
136
+ const journal = yield* SyncJournal;
137
+ const logger = yield* SyncLogger;
138
+ const resolver = yield* CredentialResolver;
139
+ const runId = yield* journal.startRun({
140
+ group: input.group,
141
+ dryRun: input.dryRun
142
+ }).pipe(Effect.orElseSucceed(() => "unrecorded"));
143
+ let repos = 0;
144
+ let changes = 0;
145
+ let drifted = 0;
146
+ let errors = 0;
147
+ let firstError;
148
+ for (const [profileName, groupNames] of partitions) {
149
+ const profile = credentials.profiles[profileName];
150
+ if (profile === void 0) {
151
+ const message = `credential profile '${profileName}' does not exist (has: ${Object.keys(credentials.profiles).join(", ") || "none — run 'reposets credentials create'"}); skipping ${groupNames.join(", ")}`;
152
+ yield* logger.syncError(`profile ${profileName}`, message);
153
+ errors += 1;
154
+ firstError ??= `profile ${profileName}: ${message}`;
155
+ continue;
156
+ }
157
+ const resolved = yield* resolver.resolveGitHubToken(profile).pipe(Effect.result);
158
+ if (resolved._tag === "Failure") {
159
+ const message = `could not resolve the GitHub token for profile '${profileName}' — ${resolved.failure.reason}`;
160
+ yield* logger.syncError(`profile ${profileName}`, message);
161
+ errors += 1;
162
+ firstError ??= `profile ${profileName}: ${message}`;
163
+ continue;
164
+ }
165
+ const token = resolved.success;
166
+ const services = Layer.mergeAll(GitHubRepository.layer, Ruleset.layer, RepositorySecurity.layer, CodeScanning.layer, DeploymentEnvironment.layer, RepositorySecret.layer, RepositoryVariable.layer, WorkflowDispatch.layer).pipe(Layer.provideMerge(GitHubClient.layerFromToken({ token })));
167
+ const engineLayer = SyncEngineLive(allPhases).pipe(Layer.provide(services));
168
+ const scoped = {
169
+ ...config,
170
+ groups: Object.fromEntries(groupNames.map((name) => [name, config.groups[name]]))
171
+ };
172
+ const partial = yield* Effect.gen(function* () {
173
+ return yield* (yield* SyncEngine).syncAll(scoped, credentials, {
174
+ runId,
175
+ dryRun: input.dryRun,
176
+ noCleanup: input.noCleanup,
177
+ configDir,
178
+ group: input.group,
179
+ repo: input.repo,
180
+ only: phaseSet(input.only),
181
+ skip: phaseSet(input.skip)
182
+ });
183
+ }).pipe(Effect.provide(engineLayer));
184
+ repos += partial.repos;
185
+ changes += partial.changes;
186
+ drifted += partial.drifted;
187
+ errors += partial.errors;
188
+ if (partial.errorSummary !== void 0) firstError ??= partial.errorSummary;
189
+ }
190
+ yield* journal.finishRun(runId, errors === 0 ? "success" : "partial", firstError).pipe(Effect.ignore);
191
+ yield* logger.finish();
192
+ return {
193
+ repos,
194
+ changes,
195
+ drifted,
196
+ errors
197
+ };
198
+ }).pipe(Effect.provide(sharedLayer));
199
+ yield* Effect.log(`${report.repos} repo(s), ${report.changes} change(s), ${report.drifted} drifted, ${report.errors} error(s)`);
200
+ if (report.errors > 0 || input.failOnDrift && report.drifted > 0) yield* Effect.sync(() => {
201
+ process.exitCode = 1;
202
+ });
203
+ });
204
+ /**
205
+ * The `sync` command.
206
+ *
207
+ * @public
208
+ */
209
+ const syncCommand = Command.make("sync", {
210
+ dryRun: Flag.boolean("dry-run").pipe(Flag.withDescription("Report what would change without writing anything")),
211
+ noCleanup: Flag.boolean("no-cleanup").pipe(Flag.withDescription("Skip deletion of undeclared resources")),
212
+ failOnDrift: Flag.boolean("fail-on-drift").pipe(Flag.withDescription("Exit non-zero when a resource was changed outside reposets")),
213
+ group: Flag.string("group").pipe(Flag.withDescription("Sync only this group"), Flag.optional),
214
+ repo: Flag.string("repo").pipe(Flag.withDescription("Sync only this repository"), Flag.optional),
215
+ only: Flag.string("only").pipe(Flag.withDescription("Run only these phases"), (f) => Flag.atLeast(f, 0)),
216
+ skip: Flag.string("skip").pipe(Flag.withDescription("Skip these phases"), (f) => Flag.atLeast(f, 0)),
217
+ debug: Flag.boolean("debug").pipe(Flag.withDescription("Annotate output with value sources and the fingerprints behind a drift report"))
218
+ }, (input) => syncHandler({
219
+ dryRun: input.dryRun,
220
+ noCleanup: input.noCleanup,
221
+ failOnDrift: input.failOnDrift,
222
+ group: Option.getOrUndefined(input.group),
223
+ repo: Option.getOrUndefined(input.repo),
224
+ only: input.only,
225
+ skip: input.skip,
226
+ debug: input.debug
227
+ })).pipe(Command.withDescription("Apply the config to every repository in a group, or all groups"));
68
228
 
69
229
  //#endregion
70
- export { syncCommand };
230
+ export { partitionByProfile, syncCommand, syncHandler };
@@ -1,53 +1,69 @@
1
- import { ReposetsConfigFile, ReposetsCredentialsFile, makeConfigFilesLive } from "../../services/ConfigFiles.js";
1
+ import { ReposetsConfigFile, ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
2
+ import { danglingReferences } from "../../lib/config-refs.js";
3
+ import { undefinedCredentialLabels } from "../../lib/credential-labels.js";
4
+ import { orgOnlyViolations } from "../../lib/org-only.js";
2
5
  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
+ import { Command } from "effect/unstable/cli";
6
7
 
7
8
  //#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* () {
9
+ /**
10
+ * `reposets validate` — discovers, decodes and reports the config file.
11
+ *
12
+ * @remarks
13
+ * The walking skeleton's vertical slice. Running it exercises `App.layer` (for
14
+ * `AppDirs`/`Xdg`), `AppConfig.layer` with a caller-supplied resolver chain,
15
+ * `TomlCodec`, and Schema v4 decoding — so a green run proves the composition
16
+ * the rebuild stands on.
17
+ *
18
+ * The handler simply *requires* `ReposetsConfigFile`; how it gets built from
19
+ * `--config` is the root command's business, not this command's.
20
+ *
21
+ * @public
22
+ */
23
+ const validateCommand = Command.make("validate", {}, () => Effect.gen(function* () {
10
24
  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}`);
25
+ const credentialsFile = yield* ReposetsCredentialsFile;
26
+ const sources = yield* configFile.discover;
27
+ const value = yield* configFile.load;
28
+ const credentials = yield* credentialsFile.loadOrDefault({ profiles: {} });
29
+ const dangling = danglingReferences(value);
30
+ if (dangling.length > 0) {
31
+ yield* Effect.logError(`Invalid: ${sources[0]?.path ?? "config"}`);
32
+ for (const ref of dangling) {
33
+ const defined = ref.defined.length === 0 ? "none defined" : `defined: ${ref.defined.join(", ")}`;
34
+ yield* Effect.logError(` ${ref.where}: '${ref.name}' does not exist — ${defined}`);
35
+ }
36
+ yield* Effect.sync(() => {
37
+ process.exitCode = 1;
38
+ });
15
39
  return;
16
40
  }
17
- const sources = configResult.right;
18
- if (sources.length === 0) {
19
- yield* Effect.logError("No config file found.");
41
+ const labels = undefinedCredentialLabels(value, credentials);
42
+ const violations = orgOnlyViolations(value, credentials);
43
+ if (labels.length > 0) {
44
+ yield* Effect.logError(`Invalid: ${sources[0]?.path ?? "config"}`);
45
+ for (const label of labels) yield* Effect.logError(` [${label.group}] ${label.where}: credential label '${label.label}' is not declared in profile '${label.profile}'`);
46
+ yield* Effect.logError("");
47
+ yield* Effect.logError("Add it to that profile's [resolve] section in reposets.credentials.toml, or correct the name.");
48
+ yield* Effect.sync(() => {
49
+ process.exitCode = 1;
50
+ });
20
51
  return;
21
52
  }
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
- }
53
+ if (violations.length > 0) {
54
+ yield* Effect.logError(`Invalid: ${sources[0]?.path ?? "config"}`);
55
+ for (const violation of violations) yield* Effect.logError(` [${violation.group}] ${violation.where}: ${violation.detail}, but profile '${violation.profile}' is a personal account`);
56
+ yield* Effect.logError("");
57
+ yield* Effect.logError("Either move these repositories to a profile declaring `org`, or drop the settings.");
58
+ yield* Effect.sync(() => {
59
+ process.exitCode = 1;
60
+ });
61
+ return;
48
62
  }
49
- if (!hasErrors) yield* Effect.log("\nAll checks passed.");
50
- }).pipe(Effect.provide(makeConfigFilesLive(config)))).pipe(Command.withDescription("Validate config without API calls"));
63
+ const groups = Object.keys(value.groups).length;
64
+ yield* Effect.log(`Valid: ${sources[0]?.path ?? "config"}`);
65
+ yield* Effect.log(` groups: ${groups === 0 ? "none declared yet" : String(groups)}`);
66
+ })).pipe(Command.withDescription("Validate reposets.config.toml against the schema"));
51
67
 
52
68
  //#endregion
53
69
  export { validateCommand };
package/cli/flags.js ADDED
@@ -0,0 +1,36 @@
1
+ import { makeConfigFilesLive } from "../services/ConfigFiles.js";
2
+ import { Effect, Layer, Option } from "effect";
3
+ import { Flag, GlobalFlag } from "effect/unstable/cli";
4
+
5
+ //#region src/cli/flags.ts
6
+ /**
7
+ * `--config`, as a global flag.
8
+ *
9
+ * @remarks
10
+ * `GlobalFlag.setting` returns a `Context.Service` carrying the parsed value,
11
+ * which is the bridge between parse time and layer-construction time: a layer
12
+ * can require the flag in `R` instead of receiving it as a constructor argument.
13
+ *
14
+ * Global rather than a parent flag deliberately — a global flag is accepted on
15
+ * either side of the subcommand name (`reposets --config x validate` and
16
+ * `reposets validate --config x` both parse), where a plain parent flag rejects
17
+ * the trailing form with `UnrecognizedOption`.
18
+ *
19
+ * @public
20
+ */
21
+ const ConfigFlag = GlobalFlag.setting("config")({ flag: Flag.string("config").pipe(Flag.withDescription("Path to reposets.config.toml, or a directory containing it"), Flag.optional) });
22
+ /**
23
+ * The config-file layer, parameterized by `--config` read from the ambient flag
24
+ * service.
25
+ *
26
+ * @remarks
27
+ * Provided once at the root command. Subcommand requirements bubble into the
28
+ * parent's `R` through `withSubcommands`, so one `Command.provide` covers every
29
+ * subcommand rather than each repeating it.
30
+ *
31
+ * @public
32
+ */
33
+ const ConfigLive = Layer.unwrap(Effect.map(ConfigFlag, (flag) => makeConfigFilesLive(Option.getOrUndefined(flag))));
34
+
35
+ //#endregion
36
+ export { ConfigFlag, ConfigLive };
package/cli/logger.js ADDED
@@ -0,0 +1,48 @@
1
+ import { Console, LogLevel, Logger } from "effect";
2
+
3
+ //#region src/cli/logger.ts
4
+ /**
5
+ * Renders one log record as a plain CLI line.
6
+ *
7
+ * @remarks
8
+ * Effect's default logger emits `[23:04:05.891] INFO (#2): message`, which is
9
+ * the right shape for a service and the wrong one for a command-line tool: the
10
+ * timestamp, level and fiber id are noise in front of output a human is reading,
11
+ * and they make a formatted block — `doctor`'s permission table, for instance —
12
+ * unreadable.
13
+ *
14
+ * A message that is an array is joined with a space, matching how `Effect.log`
15
+ * accepts variadic parts.
16
+ */
17
+ const render = (message) => Array.isArray(message) ? message.map(String).join(" ") : String(message);
18
+ /**
19
+ * The CLI's output logger: plain lines, errors on stderr.
20
+ *
21
+ * @remarks
22
+ * Routing by level is the contract `SyncLogger` is written against — it emits
23
+ * failures with `Effect.logError` precisely so that `reposets sync > log.txt`
24
+ * still shows them on the terminal while the log captures progress. Without a
25
+ * logger that honors the distinction, that choice does nothing.
26
+ *
27
+ * `Logger.layer` replaces the default logger rather than merging with it, so
28
+ * nothing is emitted twice.
29
+ *
30
+ * A `Logger`'s callback is **synchronous**, so it cannot yield an `Effect` and
31
+ * therefore cannot reach `Stdio`'s sinks. The way out is not to write to
32
+ * `process.stdout` — it is the one core's own loggers take: read the `Console`
33
+ * off the fiber. `Console.Console` is a `Context.Reference`, so it carries a
34
+ * default and never appears in `R`, and a test swaps the reference instead of
35
+ * stubbing a global.
36
+ *
37
+ * `console.log`/`console.error` supply their own newline, which is why nothing
38
+ * here appends one.
39
+ *
40
+ * @public
41
+ */
42
+ const CliLoggerLive = Logger.layer([Logger.make(({ fiber, logLevel, message }) => {
43
+ const console = fiber.getRef(Console.Console);
44
+ (LogLevel.isGreaterThanOrEqualTo(logLevel, "Error") ? console.error : console.log)(render(message));
45
+ })]);
46
+
47
+ //#endregion
48
+ export { CliLoggerLive };