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.
- package/README.md +87 -75
- package/bin/reposets.js +68 -17
- package/cli/commands/credentials.js +170 -49
- package/cli/commands/doctor.js +359 -86
- package/cli/commands/drift.js +48 -0
- package/cli/commands/history.js +203 -0
- package/cli/commands/init.js +110 -104
- package/cli/commands/list.js +60 -39
- package/cli/commands/nuke.js +127 -0
- package/cli/commands/sync.js +219 -59
- package/cli/commands/validate.js +57 -41
- package/cli/flags.js +36 -0
- package/cli/logger.js +48 -0
- package/index.d.ts +378 -1406
- package/index.js +3 -18
- package/lib/config-refs.js +76 -0
- package/lib/credential-labels.js +0 -0
- package/lib/fingerprint.js +52 -0
- package/lib/org-only.js +61 -0
- package/lib/schema-issues.js +50 -0
- package/package.json +11 -10
- package/schemas/annotations.js +81 -0
- package/schemas/common.js +83 -48
- package/schemas/config.js +214 -212
- package/schemas/credentials.js +190 -54
- package/schemas/environment.js +27 -21
- package/schemas/ruleset.js +235 -139
- package/services/ConfigFiles.js +126 -104
- package/services/CredentialResolver.js +97 -33
- package/services/OnePasswordClient.js +88 -16
- package/services/SyncLogger.js +107 -76
- package/store/AppliedState.js +0 -0
- package/store/RepoCache.js +86 -0
- package/store/SyncJournal.js +92 -0
- package/store/migrations.js +86 -0
- package/sync/SyncEngine.js +156 -0
- package/sync/decide.js +56 -0
- package/sync/phase.js +55 -0
- package/sync/phases/cleanup.js +220 -0
- package/sync/phases/code-scanning.js +187 -0
- package/sync/phases/environments.js +106 -0
- package/sync/phases/index.js +39 -0
- package/sync/phases/resource.js +149 -0
- package/sync/phases/rulesets.js +186 -0
- package/sync/phases/secrets.js +138 -0
- package/sync/phases/security.js +129 -0
- package/sync/phases/settings.js +274 -0
- package/sync/phases/variables.js +132 -0
- package/tsdoc-metadata.json +1 -1
- package/errors.js +0 -12
- package/lib/crypto.js +0 -27
- package/services/GitHubClient.js +0 -875
- 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 };
|
package/cli/commands/sync.js
CHANGED
|
@@ -1,70 +1,230 @@
|
|
|
1
|
-
import { ReposetsConfigFile, ReposetsCredentialsFile
|
|
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 {
|
|
5
|
-
import { SyncLoggerLive } from "../../services/SyncLogger.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
-
|
|
131
|
+
dryRun: input.dryRun,
|
|
132
|
+
debug: input.debug
|
|
53
133
|
});
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 };
|
package/cli/commands/validate.js
CHANGED
|
@@ -1,53 +1,69 @@
|
|
|
1
|
-
import { ReposetsConfigFile, ReposetsCredentialsFile
|
|
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 {
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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 };
|