reposets 1.0.21 → 2.0.1
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 +1 -1
- package/bin/reposets.js +73 -24
- package/cli/commands/credentials.js +28 -42
- package/cli/commands/doctor.js +45 -38
- package/cli/commands/drift.js +1 -1
- package/cli/commands/history.js +21 -34
- package/cli/commands/init.js +20 -10
- package/cli/commands/list.js +12 -10
- package/cli/commands/nuke.js +19 -22
- package/cli/commands/sync.js +13 -41
- package/cli/commands/validate.js +22 -19
- package/cli/flags.js +1 -1
- package/package.json +10 -9
- package/services/CredentialResolver.js +20 -8
- package/services/Invocation.js +38 -0
- package/services/OnePasswordClient.js +20 -8
- package/services/SyncLogger.js +10 -5
- package/tsdoc-metadata.json +1 -1
- package/cli/logger.js +0 -48
- package/lib/schema-issues.js +0 -50
package/README.md
CHANGED
|
@@ -99,7 +99,7 @@ The owner is not in the config. It belongs to the credential profile, which decl
|
|
|
99
99
|
| `reposets nuke` | Delete every local reposets file. Nothing on GitHub is touched |
|
|
100
100
|
| `reposets credentials` | Manage credential profiles: `create`, `list`, `delete` |
|
|
101
101
|
|
|
102
|
-
Every command accepts `--config
|
|
102
|
+
Every command accepts `--config`. Command output goes to stdout and diagnostics and errors to stderr, so `reposets sync > /dev/null` is quiet on success and loud on failure. Usage errors exit `64`; findings exit `1`.
|
|
103
103
|
|
|
104
104
|
## Configuration
|
|
105
105
|
|
package/bin/reposets.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { CredentialsFilesLive } from "../services/ConfigFiles.js";
|
|
3
|
-
import {
|
|
3
|
+
import { Invocation } from "../services/Invocation.js";
|
|
4
4
|
import { migrations } from "../store/migrations.js";
|
|
5
5
|
import { SyncJournalLive } from "../store/SyncJournal.js";
|
|
6
6
|
import { credentialsCommand } from "../cli/commands/credentials.js";
|
|
@@ -13,14 +13,26 @@ import { initCommand } from "../cli/commands/init.js";
|
|
|
13
13
|
import { listCommand } from "../cli/commands/list.js";
|
|
14
14
|
import { nukeCommand } from "../cli/commands/nuke.js";
|
|
15
15
|
import { validateCommand } from "../cli/commands/validate.js";
|
|
16
|
-
import { CliLoggerLive } from "../cli/logger.js";
|
|
17
16
|
import { Effect, Layer } from "effect";
|
|
18
17
|
import { App } from "@effected/app";
|
|
19
18
|
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
20
|
-
import {
|
|
19
|
+
import { CliColor, CliRuntime, ConfigIssueRenderer } from "@effected/cli";
|
|
20
|
+
import { Command } from "effect/cli";
|
|
21
21
|
|
|
22
22
|
//#region src/cli/index.ts
|
|
23
23
|
/**
|
|
24
|
+
* The build's version.
|
|
25
|
+
*
|
|
26
|
+
* @remarks
|
|
27
|
+
* The bundler substitutes `process.env.__PACKAGE_VERSION__` with a literal at
|
|
28
|
+
* build time, so this is not a runtime environment read. Only the exact
|
|
29
|
+
* `process.env.__PACKAGE_VERSION__` spelling is substituted — a destructured
|
|
30
|
+
* `env.__PACKAGE_VERSION__` is not, which is why `doctor` printed
|
|
31
|
+
* `0.0.0 (dev build)` from every build until the version was threaded down
|
|
32
|
+
* from here through {@link Invocation}.
|
|
33
|
+
*/
|
|
34
|
+
const VERSION = "2.0.1";
|
|
35
|
+
/**
|
|
24
36
|
* The application control plane.
|
|
25
37
|
*
|
|
26
38
|
* @remarks
|
|
@@ -35,6 +47,21 @@ const AppLive = App.layer({
|
|
|
35
47
|
store: { migrations }
|
|
36
48
|
});
|
|
37
49
|
/**
|
|
50
|
+
* Everything this module reads from `process`, handed down as plain values.
|
|
51
|
+
*
|
|
52
|
+
* @remarks
|
|
53
|
+
* This file is the only one under `src/` allowed to read `process`. Commands and
|
|
54
|
+
* services require {@link Invocation} instead, so a test can hand them a
|
|
55
|
+
* different working directory. Two things are deliberately not here: the
|
|
56
|
+
* environment, which every reader takes through Effect's `Config` (the default
|
|
57
|
+
* `ConfigProvider` reads the process environment, so nothing here has to), and
|
|
58
|
+
* whether stdin is a terminal, which `nuke` asks core's `Stdio.stdinIsTerminal`.
|
|
59
|
+
*/
|
|
60
|
+
const InvocationLive = Invocation.layer({
|
|
61
|
+
cwd: process.cwd(),
|
|
62
|
+
version: VERSION
|
|
63
|
+
});
|
|
64
|
+
/**
|
|
38
65
|
* The root command.
|
|
39
66
|
*
|
|
40
67
|
* @remarks
|
|
@@ -53,33 +80,55 @@ const cli = Command.make("reposets", {}, () => Effect.void).pipe(Command.withDes
|
|
|
53
80
|
historyCommand,
|
|
54
81
|
credentialsCommand
|
|
55
82
|
]), Command.provide(ConfigLive), Command.provide(CredentialsFilesLive), Command.provide(SyncJournalLive), Command.withGlobalFlags([ConfigFlag]));
|
|
56
|
-
const MainLive = Layer.mergeAll(Layer.provideMerge(AppLive, NodeServices.layer), CliLoggerLive);
|
|
57
83
|
/**
|
|
58
|
-
*
|
|
84
|
+
* The platform: Node's services, the application control plane over them, the
|
|
85
|
+
* invocation facts, and one colour-aware help/error formatter.
|
|
59
86
|
*
|
|
60
87
|
* @remarks
|
|
61
|
-
* `
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
88
|
+
* `provideMerge` rather than `provide` at each step, because commands require
|
|
89
|
+
* the platform services (`FileSystem`, `Path`, `Stdio`) directly as well as
|
|
90
|
+
* through `App`. `CliColor.formatterLayer()` needs `Stdio` to decide whether
|
|
91
|
+
* colour is on, so it sits above `NodeServices.layer` rather than beside it.
|
|
92
|
+
*/
|
|
93
|
+
const PlatformLive = Layer.mergeAll(AppLive, CliColor.formatterLayer(), InvocationLive).pipe(Layer.provideMerge(NodeServices.layer));
|
|
94
|
+
/** Whether a failure is a tagged error of a given tag. */
|
|
95
|
+
const isTagged = (error, tag) => typeof error === "object" && error !== null && "_tag" in error && error._tag === tag;
|
|
96
|
+
/**
|
|
97
|
+
* Render a failure that escaped a command, one line at a time, to stderr.
|
|
98
|
+
*
|
|
99
|
+
* @remarks
|
|
100
|
+
* `CliRuntime.main` hands this every failure it does not already know how to
|
|
101
|
+
* skip — a usage error `Command.runWith` printed itself never reaches it — and
|
|
102
|
+
* logs each returned line through `CliLogger` before exiting `1`.
|
|
103
|
+
*
|
|
104
|
+
* A config that fails to decode arrives here as a `ConfigValidationError`,
|
|
105
|
+
* whose message names the file and stops there. The structured cause is on its
|
|
106
|
+
* `issue`, which `ConfigIssueRenderer` turns into `unknown key at …` lines, so
|
|
107
|
+
* the failure describes itself rather than telling a CI log to go run another
|
|
108
|
+
* command. The `doctor` pointer stays only when something was misspelled — a
|
|
109
|
+
* missing key has no near-match to suggest, and pointing a confused user at a
|
|
110
|
+
* command that cannot help them is worse than saying nothing.
|
|
65
111
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
112
|
+
* Any other failure that carries a `cause` gets it printed on a second line. A
|
|
113
|
+
* TOML syntax error is a `ConfigCodecError` whose own message is the bare
|
|
114
|
+
* "toml parse failed" — the line and column live on the cause, and printing it
|
|
115
|
+
* is the difference between "your config is broken somewhere" and "1:9,
|
|
116
|
+
* expected ] to close the table header".
|
|
68
117
|
*/
|
|
69
|
-
const
|
|
70
|
-
|
|
118
|
+
const render = (error) => {
|
|
119
|
+
const lines = [String(error)];
|
|
71
120
|
if (isTagged(error, "ConfigValidationError")) {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
if (
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
121
|
+
const issues = ConfigIssueRenderer.render(error);
|
|
122
|
+
lines.push(...issues.map((line) => ` ${line}`));
|
|
123
|
+
if (issues.some((line) => line.startsWith("unknown key"))) lines.push(" Run 'reposets doctor' for suggested spellings.");
|
|
124
|
+
} else if (typeof error === "object" && error !== null && "cause" in error && error.cause !== void 0) lines.push(` ${String(error.cause)}`);
|
|
125
|
+
return lines;
|
|
126
|
+
};
|
|
127
|
+
NodeRuntime.runMain(CliRuntime.main(Command.run(cli, { version: VERSION }), {
|
|
128
|
+
platform: PlatformLive,
|
|
129
|
+
render,
|
|
130
|
+
helpOnUsageError: "stderr"
|
|
131
|
+
}));
|
|
83
132
|
|
|
84
133
|
//#endregion
|
|
85
134
|
export { };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { profileOwner } from "../../schemas/credentials.js";
|
|
2
2
|
import { ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
|
|
3
|
-
import { Effect, Option } from "effect";
|
|
4
|
-
import { Command, Flag } from "effect/
|
|
3
|
+
import { Console, Effect, Option } from "effect";
|
|
4
|
+
import { CliError, Command, Flag } from "effect/cli";
|
|
5
5
|
import { AppDirs } from "@effected/xdg";
|
|
6
6
|
|
|
7
7
|
//#region src/cli/commands/credentials.ts
|
|
@@ -50,6 +50,16 @@ const saveWhereRead = (file, value) => Effect.gen(function* () {
|
|
|
50
50
|
yield* file.write(value, existing.path);
|
|
51
51
|
return existing.path;
|
|
52
52
|
});
|
|
53
|
+
/**
|
|
54
|
+
* Refuse an invocation the user got wrong.
|
|
55
|
+
*
|
|
56
|
+
* @remarks
|
|
57
|
+
* Every refusal in this module is a usage error — the flags asked for
|
|
58
|
+
* something invalid — so it fails with `CliError.UserError` and exits 64.
|
|
59
|
+
* `Command.runWith` prints the message once, on stderr. The messages never
|
|
60
|
+
* echo a supplied value, for the reason given in {@link createHandler}.
|
|
61
|
+
*/
|
|
62
|
+
const refuse = (message) => Effect.fail(new CliError.UserError({ cause: message }));
|
|
53
63
|
/** How a profile's `github_token` is sourced, for display. Never a value. */
|
|
54
64
|
const describeSource = (source) => "op" in source ? `op ${source.op}` : `env ${source.env}`;
|
|
55
65
|
/**
|
|
@@ -72,35 +82,14 @@ const createHandler = (input) => Effect.gen(function* () {
|
|
|
72
82
|
const { profile, op, env, username, org } = input;
|
|
73
83
|
yield* (yield* AppDirs).ensureConfig;
|
|
74
84
|
const credentialsFile = yield* ReposetsCredentialsFile;
|
|
75
|
-
if (op !== void 0 && env !== void 0)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
if (op !== void 0 && !op.startsWith("op://")) {
|
|
84
|
-
yield* Effect.logError("--op must be a 1Password reference starting with \"op://\". Value not echoed.");
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
if (looksLikeSecret(op ?? env ?? "")) {
|
|
88
|
-
yield* Effect.logError("That looks like a credential value, not a reference. This command stores references only — pass --op \"op://Vault/item/field\" or --env VAR_NAME. Value not echoed.");
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
if (username !== void 0 && org !== void 0) {
|
|
92
|
-
yield* Effect.logError("Provide exactly one of --username or --org, not both.");
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
if (username === void 0 && org === void 0) {
|
|
96
|
-
yield* Effect.logError("Provide --username <you> for a personal account or --org <name> for an organization. It is what the profile acts as, and it decides which settings are even valid.");
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
85
|
+
if (op !== void 0 && env !== void 0) return yield* refuse("Provide exactly one of --op or --env, not both.");
|
|
86
|
+
if (op === void 0 && env === void 0) return yield* refuse("Provide a reference: --op \"op://Vault/item/field\" or --env REPOSETS_GITHUB_TOKEN. A token value is never accepted.");
|
|
87
|
+
if (op !== void 0 && !op.startsWith("op://")) return yield* refuse("--op must be a 1Password reference starting with \"op://\". Value not echoed.");
|
|
88
|
+
if (looksLikeSecret(op ?? env ?? "")) return yield* refuse("That looks like a credential value, not a reference. This command stores references only — pass --op \"op://Vault/item/field\" or --env VAR_NAME. Value not echoed.");
|
|
89
|
+
if (username !== void 0 && org !== void 0) return yield* refuse("Provide exactly one of --username or --org, not both.");
|
|
90
|
+
if (username === void 0 && org === void 0) return yield* refuse("Provide --username <you> for a personal account or --org <name> for an organization. It is what the profile acts as, and it decides which settings are even valid.");
|
|
99
91
|
const existing = yield* credentialsFile.loadOrDefault(EMPTY);
|
|
100
|
-
if (existing.profiles[profile] !== void 0) {
|
|
101
|
-
yield* Effect.logError(`Profile '${profile}' already exists. Delete it first.`);
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
92
|
+
if (existing.profiles[profile] !== void 0) return yield* refuse(`Profile '${profile}' already exists. Delete it first.`);
|
|
104
93
|
const github_token = op !== void 0 ? { op } : { env };
|
|
105
94
|
const actsAs = username !== void 0 ? {
|
|
106
95
|
username,
|
|
@@ -113,7 +102,7 @@ const createHandler = (input) => Effect.gen(function* () {
|
|
|
113
102
|
...existing.profiles,
|
|
114
103
|
[profile]: actsAs
|
|
115
104
|
} });
|
|
116
|
-
yield*
|
|
105
|
+
yield* Console.log(`Created profile '${profile}' (${username !== void 0 ? `username: ${username}` : `org: ${org}`}, github_token: ${describeSource(github_token)}) in ${path}.`);
|
|
117
106
|
});
|
|
118
107
|
const createCommand = Command.make("create", {
|
|
119
108
|
profile: profileFlag,
|
|
@@ -141,14 +130,14 @@ const createCommand = Command.make("create", {
|
|
|
141
130
|
const listHandler = Effect.gen(function* () {
|
|
142
131
|
const credentials = yield* (yield* ReposetsCredentialsFile).loadOrDefault(EMPTY);
|
|
143
132
|
if (Object.keys(credentials.profiles).length === 0) {
|
|
144
|
-
yield*
|
|
133
|
+
yield* Console.log("No credential profiles configured.");
|
|
145
134
|
return;
|
|
146
135
|
}
|
|
147
136
|
for (const [name, profile] of Object.entries(credentials.profiles)) {
|
|
148
137
|
const { owner, ownerType } = profileOwner(profile);
|
|
149
|
-
yield*
|
|
150
|
-
yield*
|
|
151
|
-
yield*
|
|
138
|
+
yield* Console.log(`[${name}]`);
|
|
139
|
+
yield* Console.log(` acts as: ${owner} (${ownerType === "User" ? "user" : "organization"})`);
|
|
140
|
+
yield* Console.log(` github_token: ${describeSource(profile.github_token)}`);
|
|
152
141
|
for (const kind of [
|
|
153
142
|
"op",
|
|
154
143
|
"env",
|
|
@@ -156,9 +145,9 @@ const listHandler = Effect.gen(function* () {
|
|
|
156
145
|
]) {
|
|
157
146
|
const entries = profile.resolve?.[kind];
|
|
158
147
|
if (entries === void 0) continue;
|
|
159
|
-
for (const [label, reference] of Object.entries(entries)) yield*
|
|
148
|
+
for (const [label, reference] of Object.entries(entries)) yield* Console.log(` resolve.${kind}.${label}: ${reference}`);
|
|
160
149
|
}
|
|
161
|
-
yield*
|
|
150
|
+
yield* Console.log("");
|
|
162
151
|
}
|
|
163
152
|
});
|
|
164
153
|
const listCommand = Command.make("list", {}, () => listHandler).pipe(Command.withDescription("List credential profiles and the references they hold"));
|
|
@@ -171,13 +160,10 @@ const deleteHandler = (profile) => Effect.gen(function* () {
|
|
|
171
160
|
yield* (yield* AppDirs).ensureConfig;
|
|
172
161
|
const credentialsFile = yield* ReposetsCredentialsFile;
|
|
173
162
|
const credentials = yield* credentialsFile.loadOrDefault(EMPTY);
|
|
174
|
-
if (credentials.profiles[profile] === void 0) {
|
|
175
|
-
yield* Effect.logError(`Profile '${profile}' not found.`);
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
163
|
+
if (credentials.profiles[profile] === void 0) return yield* refuse(`Profile '${profile}' not found.`);
|
|
178
164
|
const { [profile]: _removed, ...remaining } = credentials.profiles;
|
|
179
165
|
const path = yield* saveWhereRead(credentialsFile, { profiles: remaining });
|
|
180
|
-
yield*
|
|
166
|
+
yield* Console.log(`Deleted profile '${profile}' from ${path}.`);
|
|
181
167
|
});
|
|
182
168
|
const deleteCommand = Command.make("delete", { profile: profileFlag }, ({ profile }) => deleteHandler(profile)).pipe(Command.withDescription("Remove a credential profile"));
|
|
183
169
|
/**
|
package/cli/commands/doctor.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { SettingsGroupSchema } from "../../schemas/config.js";
|
|
2
2
|
import { profileOwner } from "../../schemas/credentials.js";
|
|
3
3
|
import { CONFIG_FILENAME, CREDENTIALS_FILENAME, ReposetsConfigFile, ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
|
|
4
|
-
import {
|
|
4
|
+
import { Invocation } from "../../services/Invocation.js";
|
|
5
5
|
import { OP_SERVICE_ACCOUNT_TOKEN, OnePasswordClientLive } from "../../services/OnePasswordClient.js";
|
|
6
6
|
import { CredentialResolver, CredentialResolverLive } from "../../services/CredentialResolver.js";
|
|
7
7
|
import { ConfigFlag } from "../flags.js";
|
|
8
|
-
import { Effect, FileSystem, Layer, Option, Path, Result } from "effect";
|
|
9
|
-
import {
|
|
8
|
+
import { Config, Console, Effect, FileSystem, Layer, Option, Path, Result } from "effect";
|
|
9
|
+
import { SchemaIssueRenderer } from "@effected/cli";
|
|
10
|
+
import { Command } from "effect/cli";
|
|
10
11
|
import { AppDirs } from "@effected/xdg";
|
|
11
|
-
import { cwd, env } from "node:process";
|
|
12
12
|
import { GitHubClient } from "@effected/github";
|
|
13
13
|
import { Toml } from "@effected/toml";
|
|
14
14
|
|
|
@@ -51,7 +51,7 @@ const KNOWN_GROUP_KEYS = /* @__PURE__ */ new Set([
|
|
|
51
51
|
const REMOVED_KEYS = {
|
|
52
52
|
owner: "removed in 1.0 — the owner now belongs to the credential profile, which declares `username` or `org`",
|
|
53
53
|
"owner (in groups)": "removed in 1.0 — a group's owner comes from the profile named by its `credentials`, so a group cannot override it",
|
|
54
|
-
log_level: "removed in 1.0 — output is one level; use --debug for diagnostics, or
|
|
54
|
+
log_level: "removed in 1.0 — output is one level; use --debug for diagnostics, or redirect stdout to quieten a run"
|
|
55
55
|
};
|
|
56
56
|
/**
|
|
57
57
|
* The settings fields the schema names, derived rather than listed.
|
|
@@ -202,10 +202,10 @@ const findClosestMatch = (key, known) => {
|
|
|
202
202
|
* `appDirs.dirs.config` rather than `ensureConfig`: the latter *creates* the
|
|
203
203
|
* directory, and a read-only diagnostic must not.
|
|
204
204
|
*/
|
|
205
|
-
const locateConfig = (fs, path, appDirs, configFlag) => Effect.gen(function* () {
|
|
205
|
+
const locateConfig = (fs, path, appDirs, configFlag, cwd) => Effect.gen(function* () {
|
|
206
206
|
const candidates = [];
|
|
207
207
|
if (configFlag !== void 0) candidates.push(configFlag, path.join(configFlag, CONFIG_FILENAME));
|
|
208
|
-
let dir = cwd
|
|
208
|
+
let dir = cwd;
|
|
209
209
|
for (;;) {
|
|
210
210
|
candidates.push(path.join(dir, CONFIG_FILENAME));
|
|
211
211
|
const parent = path.dirname(dir);
|
|
@@ -233,6 +233,12 @@ const isRecord = (value) => typeof value === "object" && value !== null && !Arra
|
|
|
233
233
|
* *names* — and whether the 1Password service account variable is present. It
|
|
234
234
|
* never resolves a reference, so there is no value in scope to leak.
|
|
235
235
|
*
|
|
236
|
+
* The report — failing sections included — is the command's output and goes to
|
|
237
|
+
* stdout in one piece, so `reposets doctor > report.txt` captures all of it.
|
|
238
|
+
* Only the three early stops (no config, unreadable, unparseable) are
|
|
239
|
+
* diagnostics on stderr. `doctor` always exits 0: it describes, it does not
|
|
240
|
+
* gate — `validate` is the gate.
|
|
241
|
+
*
|
|
236
242
|
* @public
|
|
237
243
|
*/
|
|
238
244
|
/**
|
|
@@ -280,16 +286,17 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
|
|
|
280
286
|
const fs = yield* FileSystem.FileSystem;
|
|
281
287
|
const path = yield* Path.Path;
|
|
282
288
|
const appDirs = yield* AppDirs;
|
|
289
|
+
const invocation = yield* Invocation;
|
|
283
290
|
const discovered = yield* configFile.discover.pipe(Effect.result);
|
|
284
291
|
const sources = discovered._tag === "Success" ? discovered.success : [];
|
|
285
292
|
const schemaValid = discovered._tag === "Success" && sources.length > 0;
|
|
286
293
|
const validationIssue = discovered._tag === "Failure" ? discovered.failure.issue : void 0;
|
|
287
|
-
const configPath = sources[0]?.path ?? (yield* locateConfig(fs, path, appDirs, configFlag));
|
|
294
|
+
const configPath = sources[0]?.path ?? (yield* locateConfig(fs, path, appDirs, configFlag, invocation.cwd));
|
|
288
295
|
if (configPath === void 0) {
|
|
289
296
|
yield* Effect.logError("No config found. Run 'reposets init' to create one.");
|
|
290
297
|
return;
|
|
291
298
|
}
|
|
292
|
-
yield*
|
|
299
|
+
yield* Console.log(`Config: ${configPath}`);
|
|
293
300
|
const text = yield* fs.readFileString(configPath).pipe(Effect.option);
|
|
294
301
|
if (text._tag === "None") {
|
|
295
302
|
yield* Effect.logError(`Could not read ${configPath}`);
|
|
@@ -308,13 +315,13 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
|
|
|
308
315
|
if (known.has(key)) continue;
|
|
309
316
|
const removedFrom = REMOVED_KEYS[where === "" ? key : `${key} (in groups)`] ?? REMOVED_KEYS[key];
|
|
310
317
|
if (removedFrom !== void 0) {
|
|
311
|
-
yield*
|
|
318
|
+
yield* Console.log(`Warning: '${key}'${where} ${removedFrom}`);
|
|
312
319
|
warnings += 1;
|
|
313
320
|
continue;
|
|
314
321
|
}
|
|
315
322
|
const suggestion = findClosestMatch(key, known);
|
|
316
323
|
const hint = suggestion === void 0 ? "" : ` — did you mean '${suggestion}'?`;
|
|
317
|
-
yield*
|
|
324
|
+
yield* Console.log(`Warning: unknown key '${key}'${where}${hint}`);
|
|
318
325
|
warnings += 1;
|
|
319
326
|
}
|
|
320
327
|
});
|
|
@@ -326,7 +333,7 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
|
|
|
326
333
|
if (KNOWN_SETTINGS_FIELDS.has(field)) continue;
|
|
327
334
|
const suggestion = findClosestMatch(field, KNOWN_SETTINGS_FIELDS);
|
|
328
335
|
const hint = suggestion === void 0 ? " — it will be sent to GitHub as-is, which ignores fields it does not recognise" : ` — did you mean '${suggestion}'?`;
|
|
329
|
-
yield*
|
|
336
|
+
yield* Console.log(`Warning: unrecognised setting '${field}' in settings.${groupName}${hint}`);
|
|
330
337
|
warnings += 1;
|
|
331
338
|
}
|
|
332
339
|
}
|
|
@@ -341,21 +348,21 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
|
|
|
341
348
|
yield* checkKeys(cleanup.secrets, KNOWN_CLEANUP_SECRETS_KEYS, ` in ${prefix}.secrets`);
|
|
342
349
|
yield* checkKeys(cleanup.variables, KNOWN_CLEANUP_VARIABLES_KEYS, ` in ${prefix}.variables`);
|
|
343
350
|
}
|
|
344
|
-
if (schemaValid) yield*
|
|
351
|
+
if (schemaValid) yield* Console.log("Schema validation: passed");
|
|
345
352
|
else {
|
|
346
|
-
yield*
|
|
347
|
-
for (const line of
|
|
353
|
+
yield* Console.log("Schema validation: FAILED — these values are rejected, not ignored");
|
|
354
|
+
for (const line of SchemaIssueRenderer.render(validationIssue)) yield* Console.log(` ${line}`);
|
|
348
355
|
}
|
|
349
|
-
yield*
|
|
356
|
+
yield* Console.log("");
|
|
350
357
|
const loaded = yield* credentialsFile.loadOrDefault({ profiles: {} }).pipe(Effect.result);
|
|
351
358
|
const credentials = loaded._tag === "Success" ? loaded.success : { profiles: {} };
|
|
352
359
|
const profileNames = Object.keys(credentials.profiles);
|
|
353
360
|
if (loaded._tag === "Failure") {
|
|
354
|
-
yield*
|
|
355
|
-
yield*
|
|
356
|
-
} else if (profileNames.length === 0) yield*
|
|
361
|
+
yield* Console.log(`Credentials: FAILED to load — ${String(loaded.failure.message ?? loaded.failure)}`);
|
|
362
|
+
yield* Console.log(" Unknown keys are rejected. `op_service_account_token` was removed — that token now comes from OP_SERVICE_ACCOUNT_TOKEN in the environment.");
|
|
363
|
+
} else if (profileNames.length === 0) yield* Console.log("Credentials: no profiles configured");
|
|
357
364
|
else {
|
|
358
|
-
yield*
|
|
365
|
+
yield* Console.log(`Credentials: ${profileNames.length} profile(s)`);
|
|
359
366
|
let usesOnePassword = false;
|
|
360
367
|
for (const [name, profile] of Object.entries(credentials.profiles)) {
|
|
361
368
|
const token = "op" in profile.github_token ? `op ${profile.github_token.op}` : `env ${profile.github_token.env}`;
|
|
@@ -373,45 +380,45 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
|
|
|
373
380
|
}
|
|
374
381
|
}
|
|
375
382
|
const resolveSummary = sections.length === 0 ? "" : `, resolve ${sections.join(" ")}`;
|
|
376
|
-
yield*
|
|
383
|
+
yield* Console.log(` [${name}] github_token: ${token}${resolveSummary}`);
|
|
377
384
|
}
|
|
378
|
-
const hasServiceAccount =
|
|
379
|
-
if (usesOnePassword) yield*
|
|
385
|
+
const hasServiceAccount = Option.isSome(yield* Config.option(Config.String(OP_SERVICE_ACCOUNT_TOKEN)).pipe(Effect.orElseSucceed(() => Option.none())));
|
|
386
|
+
if (usesOnePassword) yield* Console.log(hasServiceAccount ? ` ${OP_SERVICE_ACCOUNT_TOKEN}: set` : ` ${OP_SERVICE_ACCOUNT_TOKEN}: NOT SET — op:// references cannot be resolved`);
|
|
380
387
|
}
|
|
381
|
-
yield*
|
|
382
|
-
yield*
|
|
388
|
+
yield* Console.log("");
|
|
389
|
+
yield* Console.log(`Version: ${invocation.version}`);
|
|
383
390
|
const credentialDiscovery = yield* credentialsFile.discover.pipe(Effect.result);
|
|
384
391
|
const credentialSources = credentialDiscovery._tag === "Success" ? credentialDiscovery.success : [];
|
|
385
392
|
const credentialsLine = credentialDiscovery._tag === "Failure" ? "(failed to load — the error above names the file)" : credentialSources.length === 0 ? `${path.join(appDirs.dirs.config, CREDENTIALS_FILENAME)} (not created yet)` : credentialSources[0]?.path ?? "";
|
|
386
|
-
yield*
|
|
387
|
-
yield*
|
|
388
|
-
yield*
|
|
393
|
+
yield* Console.log(`Credentials file: ${credentialsLine}`);
|
|
394
|
+
yield* Console.log(`State database: ${path.join(appDirs.dirs.state, "store.db")}`);
|
|
395
|
+
yield* Console.log("");
|
|
389
396
|
for (const [name, profile] of Object.entries(credentials.profiles)) {
|
|
390
397
|
const resolverLayer = Layer.provide(CredentialResolverLive, OnePasswordClientLive);
|
|
391
398
|
const resolved = yield* Effect.gen(function* () {
|
|
392
399
|
return yield* (yield* CredentialResolver).resolveGitHubToken(profile);
|
|
393
400
|
}).pipe(Effect.provide(resolverLayer), Effect.result);
|
|
394
401
|
if (resolved._tag === "Failure") {
|
|
395
|
-
yield*
|
|
402
|
+
yield* Console.log(`Token [${name}]: could not resolve — ${String(resolved.failure)}`);
|
|
396
403
|
continue;
|
|
397
404
|
}
|
|
398
405
|
const identity = yield* Effect.gen(function* () {
|
|
399
406
|
return yield* (yield* GitHubClient).request("GET /user", {});
|
|
400
407
|
}).pipe(Effect.provide(GitHubClient.layerFromToken({ token: resolved.success })), Effect.result);
|
|
401
408
|
if (identity._tag !== "Success") {
|
|
402
|
-
yield*
|
|
409
|
+
yield* Console.log(`Token [${name}]: resolved but REJECTED by GitHub — ${String(identity.failure)}`);
|
|
403
410
|
continue;
|
|
404
411
|
}
|
|
405
412
|
const line = describeIdentity(name, profile, identity.success.login);
|
|
406
|
-
yield*
|
|
413
|
+
yield* Console.log(line.text);
|
|
407
414
|
}
|
|
408
|
-
yield*
|
|
409
|
-
yield*
|
|
410
|
-
for (const [scope, permission, why] of REQUIRED_PERMISSIONS) yield*
|
|
411
|
-
yield*
|
|
412
|
-
yield*
|
|
413
|
-
yield*
|
|
414
|
-
yield*
|
|
415
|
+
yield* Console.log("");
|
|
416
|
+
yield* Console.log("Required fine-grained token permissions:");
|
|
417
|
+
for (const [scope, permission, why] of REQUIRED_PERMISSIONS) yield* Console.log(` ${scope} > ${permission} — ${why}`);
|
|
418
|
+
yield* Console.log(" (Metadata: Read is mandatory and granted automatically)");
|
|
419
|
+
yield* Console.log(" These are NOT verified — GitHub does not expose a fine-grained token's own scopes.");
|
|
420
|
+
yield* Console.log("");
|
|
421
|
+
yield* Console.log(warnings === 0 ? "No unknown keys detected." : `${warnings} warning(s) found.`);
|
|
415
422
|
});
|
|
416
423
|
/**
|
|
417
424
|
* `reposets doctor`.
|
package/cli/commands/drift.js
CHANGED
package/cli/commands/history.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SyncJournal } from "../../store/SyncJournal.js";
|
|
2
|
-
import { Effect } from "effect";
|
|
3
|
-
import { Command, Flag } from "effect/
|
|
2
|
+
import { Console, Effect } from "effect";
|
|
3
|
+
import { CliError, Command, Flag } from "effect/cli";
|
|
4
4
|
|
|
5
5
|
//#region src/cli/commands/history.ts
|
|
6
6
|
const limitFlag = Flag.Int("limit").pipe(Flag.withDefault(20), Flag.withDescription("How many runs to show, newest first"));
|
|
@@ -42,7 +42,7 @@ const pad = (value, width) => value.padEnd(width);
|
|
|
42
42
|
*
|
|
43
43
|
* @remarks
|
|
44
44
|
* Computed rather than fixed because group names and repository names are
|
|
45
|
-
* user-supplied and unbounded.
|
|
45
|
+
* user-supplied and unbounded. Each line is written verbatim to stdout, so
|
|
46
46
|
* alignment computed here survives to the terminal.
|
|
47
47
|
*/
|
|
48
48
|
const renderRows = (summaries) => {
|
|
@@ -87,14 +87,11 @@ const historyHandler = (input) => Effect.gen(function* () {
|
|
|
87
87
|
...input.repo === void 0 ? {} : { repo: input.repo }
|
|
88
88
|
});
|
|
89
89
|
if (summaries.length === 0) {
|
|
90
|
-
yield*
|
|
90
|
+
yield* Console.log(input.repo === void 0 ? "No runs recorded yet. The journal fills in as you sync." : `No recorded run has touched ${input.repo}.`);
|
|
91
91
|
return;
|
|
92
92
|
}
|
|
93
|
-
for (const line of renderRows(summaries)) yield*
|
|
94
|
-
if (summaries.length === input.limit) {
|
|
95
|
-
yield* Effect.log("");
|
|
96
|
-
yield* Effect.log(`Showing the most recent ${input.limit}. Pass --limit for more.`);
|
|
97
|
-
}
|
|
93
|
+
for (const line of renderRows(summaries)) yield* Console.log(line);
|
|
94
|
+
if (summaries.length === input.limit) yield* Effect.log(`Showing the most recent ${input.limit}. Pass --limit for more.`);
|
|
98
95
|
});
|
|
99
96
|
/**
|
|
100
97
|
* The present-tense form of a stored action, for a dry run.
|
|
@@ -118,32 +115,22 @@ const wouldForm = (action) => action === "created" ? "create" : action === "upda
|
|
|
118
115
|
const showHandler = (prefix) => Effect.gen(function* () {
|
|
119
116
|
const journal = yield* SyncJournal;
|
|
120
117
|
const matches = (yield* journal.history({ limit: 1e3 }).pipe(Effect.orElseSucceed(() => []))).filter((run) => run.id.startsWith(prefix));
|
|
121
|
-
if (matches.length === 0) {
|
|
122
|
-
yield* Effect.logError(`No run matches '${prefix}'.`);
|
|
123
|
-
yield* Effect.sync(() => {
|
|
124
|
-
process.exitCode = 1;
|
|
125
|
-
});
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
118
|
+
if (matches.length === 0) return yield* Effect.fail(new CliError.UserError({ cause: `No run matches '${prefix}'.` }));
|
|
128
119
|
if (matches.length > 1) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
yield* Effect.sync(() => {
|
|
132
|
-
process.exitCode = 1;
|
|
133
|
-
});
|
|
134
|
-
return;
|
|
120
|
+
const candidates = matches.slice(0, 5).map((run) => ` ${run.id} ${formatWhen(run.startedAt)}`);
|
|
121
|
+
return yield* Effect.fail(new CliError.UserError({ cause: [`'${prefix}' matches ${matches.length} runs. Use more of the id:`, ...candidates].join("\n") }));
|
|
135
122
|
}
|
|
136
123
|
const run = matches[0];
|
|
137
124
|
const changes = yield* journal.changesFor(run.id).pipe(Effect.orElseSucceed(() => []));
|
|
138
|
-
yield*
|
|
139
|
-
yield*
|
|
140
|
-
yield*
|
|
125
|
+
yield* Console.log(`run ${run.id}`);
|
|
126
|
+
yield* Console.log(` ${formatWhen(run.startedAt)} · ${run.dryRun === 1 ? "dry-run" : "applied"} · ${run.outcome ?? "unfinished"} · ${formatDuration(run)}`);
|
|
127
|
+
yield* Console.log("");
|
|
141
128
|
if (run.error !== null && run.error !== "") {
|
|
142
|
-
yield*
|
|
143
|
-
yield*
|
|
129
|
+
yield* Console.log(` error: ${run.error}`);
|
|
130
|
+
yield* Console.log("");
|
|
144
131
|
}
|
|
145
132
|
if (changes.length === 0) {
|
|
146
|
-
yield*
|
|
133
|
+
yield* Console.log(run.error !== null && run.error !== "" ? " No resources changed." : " No resources changed (nothing to do).");
|
|
147
134
|
return;
|
|
148
135
|
}
|
|
149
136
|
const byRepo = /* @__PURE__ */ new Map();
|
|
@@ -153,12 +140,12 @@ const showHandler = (prefix) => Effect.gen(function* () {
|
|
|
153
140
|
else existing.push(change);
|
|
154
141
|
}
|
|
155
142
|
for (const [repo, records] of byRepo) {
|
|
156
|
-
yield*
|
|
143
|
+
yield* Console.log(` ${repo}`);
|
|
157
144
|
for (const record of records) {
|
|
158
145
|
const detail = record.detail === void 0 || record.detail === null ? "" : ` — ${record.detail}`;
|
|
159
146
|
const what = record.kind === record.name ? record.kind : `${record.kind} ${record.name}`;
|
|
160
147
|
const action = run.dryRun === 1 ? `would ${wouldForm(record.action)}` : record.action;
|
|
161
|
-
yield*
|
|
148
|
+
yield* Console.log(` ${action.padEnd(18)} ${what}${detail}`);
|
|
162
149
|
}
|
|
163
150
|
}
|
|
164
151
|
});
|
|
@@ -174,13 +161,13 @@ const showHandler = (prefix) => Effect.gen(function* () {
|
|
|
174
161
|
*/
|
|
175
162
|
const pruneHandler = (keep) => Effect.gen(function* () {
|
|
176
163
|
const removed = yield* (yield* SyncJournal).prune(keep).pipe(Effect.orElseSucceed(() => 0));
|
|
177
|
-
yield*
|
|
178
|
-
yield*
|
|
164
|
+
yield* Console.log(removed === 0 ? `Nothing to prune; ${keep} or fewer runs are recorded.` : `Pruned ${removed} run${removed === 1 ? "" : "s"}, keeping the newest ${keep}.`);
|
|
165
|
+
yield* Console.log("Applied state and the cache are untouched — drift detection still works.");
|
|
179
166
|
});
|
|
180
167
|
const clearHandler = () => Effect.gen(function* () {
|
|
181
168
|
const removed = yield* (yield* SyncJournal).clear().pipe(Effect.orElseSucceed(() => 0));
|
|
182
|
-
yield*
|
|
183
|
-
yield*
|
|
169
|
+
yield* Console.log(`Cleared ${removed} run${removed === 1 ? "" : "s"} from the journal.`);
|
|
170
|
+
yield* Console.log("Applied state and the cache are untouched — drift detection still works.");
|
|
184
171
|
});
|
|
185
172
|
/**
|
|
186
173
|
* `reposets history`.
|