reposets 3.0.0 → 3.1.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/bin/reposets.js +8 -8
- package/cli/commands/credentials.js +47 -11
- package/cli/commands/doctor.js +1 -1
- package/cli/commands/init.js +1 -1
- package/cli/commands/nuke.js +1 -1
- package/cli/commands/sync.js +3 -3
- package/cli/views/sync-progress-model.js +16 -4
- package/cli/views/sync-progress-view.js +32 -0
- package/cli/views/sync-progress.js +10 -23
- package/package.json +7 -7
- package/services/SyncLogger.js +19 -18
package/bin/reposets.js
CHANGED
|
@@ -15,10 +15,9 @@ import { listCommand } from "../cli/commands/list.js";
|
|
|
15
15
|
import { nukeCommand } from "../cli/commands/nuke.js";
|
|
16
16
|
import { validateCommand } from "../cli/commands/validate.js";
|
|
17
17
|
import { Effect, Layer } from "effect";
|
|
18
|
-
import { AppCache, AppStore } from "@effected/app";
|
|
18
|
+
import { App, AppCache, AppStore } from "@effected/app";
|
|
19
19
|
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
20
20
|
import { CliAudience, CliRuntime, ConfigIssueRenderer, Fmt } from "@effected/cli";
|
|
21
|
-
import { AppDirs, Xdg } from "@effected/xdg";
|
|
22
21
|
import { Command } from "effect/cli";
|
|
23
22
|
|
|
24
23
|
//#region src/cli/index.ts
|
|
@@ -33,18 +32,18 @@ import { Command } from "effect/cli";
|
|
|
33
32
|
* `0.0.0 (dev build)` from every build until the version was threaded down
|
|
34
33
|
* from here through {@link Invocation}.
|
|
35
34
|
*/
|
|
36
|
-
const VERSION = "3.
|
|
35
|
+
const VERSION = "3.1.0";
|
|
37
36
|
/**
|
|
38
37
|
* The application's directories: XDG resolution and the `reposets` namespace
|
|
39
38
|
* under it.
|
|
40
39
|
*
|
|
41
40
|
* @remarks
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
41
|
+
* `App.layerDirs` is the directories half of `App.layer`, without the two
|
|
42
|
+
* databases `App.layer` always opens. Every command can resolve a path through
|
|
43
|
+
* this; resolving one creates nothing — only an `ensure*` member does, and only
|
|
44
|
+
* the command that writes calls it.
|
|
46
45
|
*/
|
|
47
|
-
const DirsLive =
|
|
46
|
+
const DirsLive = App.layerDirs({ namespace: "reposets" });
|
|
48
47
|
/**
|
|
49
48
|
* The state database, `store.db` under the XDG state directory.
|
|
50
49
|
*
|
|
@@ -182,6 +181,7 @@ NodeRuntime.runMain(CliRuntime.main(CliAudience.run(cli, { version: VERSION }),
|
|
|
182
181
|
env: {
|
|
183
182
|
audienceEnvVar: "REPOSETS_AUDIENCE",
|
|
184
183
|
log: { envVar: "REPOSETS_LOG_LEVEL" },
|
|
184
|
+
spansEnvVar: "REPOSETS_SPANS",
|
|
185
185
|
stderrIsTerminal: Effect.sync(() => process.stderr.isTTY === true)
|
|
186
186
|
},
|
|
187
187
|
render,
|
|
@@ -2,9 +2,9 @@ import { profileOwner } from "../../schemas/credentials.js";
|
|
|
2
2
|
import { ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
|
|
3
3
|
import { Effect, Option } from "effect";
|
|
4
4
|
import { CliInteractive, CliMessage, Doc } from "@effected/cli";
|
|
5
|
-
import { AppDirs } from "@effected/xdg";
|
|
6
5
|
import { CliError, Command, Flag } from "effect/cli";
|
|
7
6
|
import { CliUi, Select, TextInput } from "@effected/cli/ui";
|
|
7
|
+
import { AppDirs } from "@effected/xdg";
|
|
8
8
|
|
|
9
9
|
//#region src/cli/commands/credentials.ts
|
|
10
10
|
const EMPTY = { profiles: {} };
|
|
@@ -39,17 +39,42 @@ const SECRET_PREFIXES = [
|
|
|
39
39
|
"xoxb-"
|
|
40
40
|
];
|
|
41
41
|
/**
|
|
42
|
+
* A token embedded anywhere in a value — `op://v/ghp_…` — rather than leading it.
|
|
43
|
+
*
|
|
44
|
+
* @remarks
|
|
45
|
+
* A prefix only counts at a word boundary and followed by a token-like body:
|
|
46
|
+
* a run of at least 20 letters and digits for the GitHub and 1Password
|
|
47
|
+
* prefixes (a `ghp_` token's body is 36), digits for a Slack bot token. Both
|
|
48
|
+
* conditions keep names out: `devops_team` has no boundary before `ops_`, and
|
|
49
|
+
* `op://Vault/ops_deploy_credentials/field` has no unbroken 20-character run
|
|
50
|
+
* after it. `sk-` is deliberately absent — too common inside ordinary words to
|
|
51
|
+
* mean anything away from the start of a value.
|
|
52
|
+
*/
|
|
53
|
+
const EMBEDDED_SECRET = /(?:^|[^A-Za-z0-9])(?:(?:gh[pousr]_|github_pat_|ops_)[A-Za-z0-9]{20}|xoxb-[0-9]{6})/;
|
|
54
|
+
/**
|
|
55
|
+
* A GitHub classic token from before prefixes: exactly 40 hex digits.
|
|
56
|
+
*
|
|
57
|
+
* @remarks
|
|
58
|
+
* It carries no prefix and is under the length rule, and one that begins with
|
|
59
|
+
* a letter is also a valid environment-variable name, so without this it is
|
|
60
|
+
* accepted by `--env` and printed back in the confirmation line.
|
|
61
|
+
*/
|
|
62
|
+
const LEGACY_GITHUB_TOKEN = /^[0-9a-f]{40}$/i;
|
|
63
|
+
/**
|
|
42
64
|
* Whether a value looks like a pasted token rather than a reference or a name.
|
|
43
65
|
*
|
|
44
66
|
* @remarks
|
|
45
|
-
* A known token prefix
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
67
|
+
* A known token prefix at the start, a token embedded anywhere
|
|
68
|
+
* ({@link EMBEDDED_SECRET}), a pre-prefix GitHub token
|
|
69
|
+
* ({@link LEGACY_GITHUB_TOKEN}), or anything longer than 60 characters. The
|
|
70
|
+
* length rule catches the tokens that carry no recognisable prefix, but it
|
|
71
|
+
* never applies to an `op://` value: that is a 1Password reference by
|
|
72
|
+
* construction, none of the token prefixes begins that way, and a real
|
|
73
|
+
* reference crosses 60 characters easily once a vault or item name has spaces
|
|
74
|
+
* in it (`op://Engineering Shared Vault/GitHub Production Deploy Token/credential`).
|
|
75
|
+
* A token pasted *into* a reference is still caught, by the embedded rule.
|
|
51
76
|
*/
|
|
52
|
-
const looksLikeSecret = (value) => SECRET_PREFIXES.some((prefix) => value.startsWith(prefix)) || !value.startsWith("op://") && value.length > 60;
|
|
77
|
+
const looksLikeSecret = (value) => SECRET_PREFIXES.some((prefix) => value.startsWith(prefix)) || EMBEDDED_SECRET.test(value) || LEGACY_GITHUB_TOKEN.test(value) || !value.startsWith("op://") && value.length > 60;
|
|
53
78
|
/**
|
|
54
79
|
* The explanation for a flag value {@link looksLikeSecret} catches — in any
|
|
55
80
|
* flag, since a token pasted into `--profile` or `--username` would otherwise
|
|
@@ -167,8 +192,18 @@ const validName = (what) => (value) => value.trim() === "" ? `${what} is require
|
|
|
167
192
|
* profile key, an owner or a reference, or reaches the confirmation line. The
|
|
168
193
|
* text prompts' frames are cleared when they close, so a token typed and then
|
|
169
194
|
* abandoned is not left in the terminal's scrollback; the accepted answers are
|
|
170
|
-
* recorded in the confirmation line instead.
|
|
171
|
-
* is
|
|
195
|
+
* recorded in the confirmation line instead. The reference prompt — the one
|
|
196
|
+
* field a token is most likely to be pasted into — is masked by the same rule
|
|
197
|
+
* `validate` refuses on (`mask: looksLikeSecret`), so what is hidden is exactly
|
|
198
|
+
* what is refused. The kit latches the mask once the rule answers true, until
|
|
199
|
+
* the field is emptied, so editing a pasted token's prefix away never unmasks
|
|
200
|
+
* the rest; a paste is masked from its first frame, and a token typed by hand
|
|
201
|
+
* shows at most its public prefix (`ghp`) before the mask trips. A real
|
|
202
|
+
* `op://` reference or variable name stays readable while it is typed.
|
|
203
|
+
* `validate` sees the real text and refuses a token with a message that never
|
|
204
|
+
* quotes it. The reference itself is echoed in the confirmation line, which is
|
|
205
|
+
* where a typo in it shows. The name prompts are not masked: a name is read back as
|
|
206
|
+
* it is typed.
|
|
172
207
|
*
|
|
173
208
|
* `op_service_account_token` is likewise not accepted: it comes from the
|
|
174
209
|
* environment, so there is nothing to write here.
|
|
@@ -240,7 +275,8 @@ const createHandler = (input) => Effect.gen(function* () {
|
|
|
240
275
|
const reference = yield* ask(TextInput.screen({
|
|
241
276
|
message: kind === "op" ? "1Password reference" : "Environment variable name",
|
|
242
277
|
placeholder: kind === "op" ? "op://Vault/item/field" : "REPOSETS_GITHUB_TOKEN",
|
|
243
|
-
validate: validateReference(kind)
|
|
278
|
+
validate: validateReference(kind),
|
|
279
|
+
mask: looksLikeSecret
|
|
244
280
|
}), MISSING_REFERENCE, { clear: true });
|
|
245
281
|
return kind === "op" ? { op: reference } : { env: reference };
|
|
246
282
|
});
|
package/cli/commands/doctor.js
CHANGED
|
@@ -8,8 +8,8 @@ import { CredentialResolver, CredentialResolverLive } from "../../services/Crede
|
|
|
8
8
|
import { ConfigFlag } from "../flags.js";
|
|
9
9
|
import { Config, Console, Effect, FileSystem, Layer, Option, Path, Result } from "effect";
|
|
10
10
|
import { CliMessage, Doc, SchemaIssueRenderer, Status } from "@effected/cli";
|
|
11
|
-
import { AppDirs } from "@effected/xdg";
|
|
12
11
|
import { Command } from "effect/cli";
|
|
12
|
+
import { AppDirs } from "@effected/xdg";
|
|
13
13
|
import { GitHubClient } from "@effected/github";
|
|
14
14
|
import { Toml } from "@effected/toml";
|
|
15
15
|
|
package/cli/commands/init.js
CHANGED
|
@@ -3,9 +3,9 @@ import { Invocation } from "../../services/Invocation.js";
|
|
|
3
3
|
import { configSchemaHost, credentialsSchemaHost, schemaDirective } from "../../schemas/hosted.js";
|
|
4
4
|
import { Effect, FileSystem, Option, Path } from "effect";
|
|
5
5
|
import { CliExit, CliMessage } from "@effected/cli";
|
|
6
|
-
import { AppDirs } from "@effected/xdg";
|
|
7
6
|
import { Command, Flag } from "effect/cli";
|
|
8
7
|
import { CliUi, Select } from "@effected/cli/ui";
|
|
8
|
+
import { AppDirs } from "@effected/xdg";
|
|
9
9
|
|
|
10
10
|
//#region src/cli/commands/init.ts
|
|
11
11
|
/**
|
package/cli/commands/nuke.js
CHANGED
|
@@ -3,9 +3,9 @@ import { Invocation } from "../../services/Invocation.js";
|
|
|
3
3
|
import { CACHE_DB_FILENAME, STATE_DB_FILENAME } from "../../store/files.js";
|
|
4
4
|
import { Effect, FileSystem, Path } from "effect";
|
|
5
5
|
import { CliExit, CliInteractive, CliMessage, Doc } from "@effected/cli";
|
|
6
|
-
import { AppDirs, Xdg } from "@effected/xdg";
|
|
7
6
|
import { CliError, Command, Flag } from "effect/cli";
|
|
8
7
|
import { CliUi, Confirm, MultiSelect } from "@effected/cli/ui";
|
|
8
|
+
import { AppDirs, Xdg } from "@effected/xdg";
|
|
9
9
|
|
|
10
10
|
//#region src/cli/commands/nuke.ts
|
|
11
11
|
const forceFlag = Flag.Boolean("force").pipe(Flag.withDefault(false), Flag.withDescription("Delete without asking. Intended for scripts; there is no undo"));
|
package/cli/commands/sync.js
CHANGED
|
@@ -10,6 +10,7 @@ import { PHASE_NAMES } from "../../sync/phase.js";
|
|
|
10
10
|
import { allPhases } from "../../sync/phases/index.js";
|
|
11
11
|
import { SyncEngine, SyncEngineLive } from "../../sync/SyncEngine.js";
|
|
12
12
|
import { syncSummaryBlock } from "../views/sync-progress-model.js";
|
|
13
|
+
import { syncProgressView } from "../views/sync-progress-view.js";
|
|
13
14
|
import { Console, Effect, Exit, Layer, Option, PubSub } from "effect";
|
|
14
15
|
import { CliExit, CliInteractive, CliMessage, Doc } from "@effected/cli";
|
|
15
16
|
import { CliError, Command, Flag } from "effect/cli";
|
|
@@ -136,7 +137,7 @@ const runUnderSyncView = (view, work) => Effect.scoped(Effect.gen(function* () {
|
|
|
136
137
|
* nothing tears the frame. The view's committed final frame is the summary.
|
|
137
138
|
*
|
|
138
139
|
* Anyone else — a pipe, an agent, CI — gets no view and no React load: the
|
|
139
|
-
* view
|
|
140
|
+
* view's `render` is a `CliUi.lazyView`, and this path never mounts it. The same
|
|
140
141
|
* summary block is printed statically with `Doc.print` instead.
|
|
141
142
|
*
|
|
142
143
|
* **Findings are failures, not diagnostics.** "No config found", a dangling
|
|
@@ -257,9 +258,8 @@ const syncHandler = (input) => Effect.gen(function* () {
|
|
|
257
258
|
return summary;
|
|
258
259
|
}).pipe(Effect.provide(sharedLayer));
|
|
259
260
|
};
|
|
260
|
-
const runWithView = Effect.promise(() => import("../views/sync-progress.js")).pipe(Effect.flatMap(({ syncProgressView }) => runUnderSyncView(syncProgressView, runSync)));
|
|
261
261
|
const interactive = yield* CliInteractive;
|
|
262
|
-
const report = interactive ? yield*
|
|
262
|
+
const report = interactive ? yield* runUnderSyncView(syncProgressView, runSync) : yield* runSync(void 0);
|
|
263
263
|
if (!interactive) yield* Doc.print([syncSummaryBlock({
|
|
264
264
|
dryRun: input.dryRun,
|
|
265
265
|
total,
|
|
@@ -81,7 +81,6 @@ const isSyncRunStart = (event) => event._tag === "RunStarted";
|
|
|
81
81
|
* @public
|
|
82
82
|
*/
|
|
83
83
|
const isSyncRunEnd = (event) => event._tag === "RunEnded";
|
|
84
|
-
const noun = (n, one, many) => n === 1 ? one : many;
|
|
85
84
|
/**
|
|
86
85
|
* The run's counts as one document block: `Dry run: 2/3 repos, 4 changes, 1
|
|
87
86
|
* drifted, 0 errors`.
|
|
@@ -93,6 +92,10 @@ const noun = (n, one, many) => n === 1 ? one : many;
|
|
|
93
92
|
* reader looks for, and a counter that vanishes at zero makes the line change
|
|
94
93
|
* shape between runs.
|
|
95
94
|
*
|
|
95
|
+
* Plurals are the kit's `{ one, other }` labels. A counter shown as a share
|
|
96
|
+
* of the headline's total — `repos` — agrees with that total (`0/1 repo`,
|
|
97
|
+
* `1/3 repos`); every other counter agrees with its own count.
|
|
98
|
+
*
|
|
96
99
|
* @public
|
|
97
100
|
*/
|
|
98
101
|
const syncSummaryBlock = (state) => Doc.counts({
|
|
@@ -102,13 +105,19 @@ const syncSummaryBlock = (state) => Doc.counts({
|
|
|
102
105
|
counters: [
|
|
103
106
|
Doc.counter(Status.core, "success", {
|
|
104
107
|
key: "repos",
|
|
105
|
-
label:
|
|
108
|
+
label: {
|
|
109
|
+
one: "repo",
|
|
110
|
+
other: "repos"
|
|
111
|
+
},
|
|
106
112
|
n: state.repos,
|
|
107
113
|
showZero: true
|
|
108
114
|
}),
|
|
109
115
|
Doc.counter(Status.core, "info", {
|
|
110
116
|
key: "changes",
|
|
111
|
-
label:
|
|
117
|
+
label: {
|
|
118
|
+
one: "change",
|
|
119
|
+
other: "changes"
|
|
120
|
+
},
|
|
112
121
|
n: state.changes,
|
|
113
122
|
showZero: true
|
|
114
123
|
}),
|
|
@@ -120,7 +129,10 @@ const syncSummaryBlock = (state) => Doc.counts({
|
|
|
120
129
|
}),
|
|
121
130
|
Doc.counter(Status.core, "failure", {
|
|
122
131
|
key: "errors",
|
|
123
|
-
label:
|
|
132
|
+
label: {
|
|
133
|
+
one: "error",
|
|
134
|
+
other: "errors"
|
|
135
|
+
},
|
|
124
136
|
n: state.errors,
|
|
125
137
|
showZero: true
|
|
126
138
|
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { initialSyncProgress, isSyncRunEnd, isSyncRunStart, reduceSyncProgress } from "./sync-progress-model.js";
|
|
2
|
+
import { CliUi } from "@effected/cli/ui";
|
|
3
|
+
|
|
4
|
+
//#region src/cli/views/sync-progress-view.ts
|
|
5
|
+
/**
|
|
6
|
+
* The sync progress view: every `CliUi.live` option but `events`.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* Defined once and exported so the handler and its tests drive the same value —
|
|
10
|
+
* a test that rebuilt the options would exercise a copy. `hosted` because a
|
|
11
|
+
* run that cannot draw (a pipe, an agent, CI) has its own closing output — the
|
|
12
|
+
* static summary `sync` prints — so the view must print nothing there; the
|
|
13
|
+
* handler does not mount it on such a run in the first place.
|
|
14
|
+
*
|
|
15
|
+
* `render` is `CliUi.lazyView`: the JSX module is imported only when a run
|
|
16
|
+
* first draws, so importing this module — and `sync` — loads neither React
|
|
17
|
+
* nor Ink. It lives apart from the model because the JSX module imports the
|
|
18
|
+
* model, and a lazy import back into it is still an import cycle.
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
const syncProgressView = {
|
|
23
|
+
initial: initialSyncProgress,
|
|
24
|
+
reduce: reduceSyncProgress,
|
|
25
|
+
render: CliUi.lazyView(() => import("./sync-progress.js").then((module) => module.syncProgressRender)),
|
|
26
|
+
isStart: isSyncRunStart,
|
|
27
|
+
isTerminal: isSyncRunEnd,
|
|
28
|
+
mode: "hosted"
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
//#endregion
|
|
32
|
+
export { syncProgressView };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { syncSummaryBlock } from "./sync-progress-model.js";
|
|
2
2
|
import { Fmt } from "@effected/cli";
|
|
3
3
|
import { DocView, Styled, useGlyphs } from "@effected/cli/ui";
|
|
4
4
|
import { Box, Text } from "ink";
|
|
@@ -37,32 +37,19 @@ const SyncProgress = (props) => {
|
|
|
37
37
|
});
|
|
38
38
|
};
|
|
39
39
|
/**
|
|
40
|
-
* The sync progress view
|
|
40
|
+
* The sync progress view's `render`.
|
|
41
41
|
*
|
|
42
42
|
* @remarks
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* run that
|
|
46
|
-
* static summary `sync` prints — so the view must print nothing there; the
|
|
47
|
-
* handler does not mount it on such a run in the first place.
|
|
48
|
-
*
|
|
49
|
-
* This module is the only one that holds JSX, and `sync` imports it
|
|
50
|
-
* dynamically on the drawing path alone, so a non-interactive run never loads
|
|
51
|
-
* React or Ink.
|
|
43
|
+
* This module is the only one that holds JSX. It is loaded through
|
|
44
|
+
* `CliUi.lazyView` by `syncProgressView` in `sync-progress-view.ts`, so only
|
|
45
|
+
* a run that draws it ever loads React or Ink.
|
|
52
46
|
*
|
|
53
47
|
* @public
|
|
54
48
|
*/
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
state,
|
|
60
|
-
frame
|
|
61
|
-
}),
|
|
62
|
-
isStart: isSyncRunStart,
|
|
63
|
-
isTerminal: isSyncRunEnd,
|
|
64
|
-
mode: "hosted"
|
|
65
|
-
};
|
|
49
|
+
const syncProgressRender = (state, frame) => /* @__PURE__ */ jsx(SyncProgress, {
|
|
50
|
+
state,
|
|
51
|
+
frame
|
|
52
|
+
});
|
|
66
53
|
|
|
67
54
|
//#endregion
|
|
68
|
-
export {
|
|
55
|
+
export { syncProgressRender };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reposets",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "CLI tool to sync GitHub repo settings, secrets and rulesets across personal repositories",
|
|
6
6
|
"keywords": [
|
|
@@ -49,14 +49,14 @@
|
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@1password/sdk": "^0.5.0",
|
|
51
51
|
"@effect/platform-node": "^4.0.0",
|
|
52
|
-
"@effected/app": "^0.
|
|
53
|
-
"@effected/cli": "^0.
|
|
54
|
-
"@effected/config-file": "^0.14.
|
|
52
|
+
"@effected/app": "^0.21.1",
|
|
53
|
+
"@effected/cli": "^0.12.0",
|
|
54
|
+
"@effected/config-file": "^0.14.2",
|
|
55
55
|
"@effected/env": "^0.1.0",
|
|
56
|
-
"@effected/github": "^0.15.
|
|
56
|
+
"@effected/github": "^0.15.1",
|
|
57
57
|
"@effected/glob": "^0.10.0",
|
|
58
|
-
"@effected/schemastore": "^0.
|
|
59
|
-
"@effected/store": "^0.
|
|
58
|
+
"@effected/schemastore": "^0.21.0",
|
|
59
|
+
"@effected/store": "^0.13.0",
|
|
60
60
|
"@effected/toml": "^0.11.0",
|
|
61
61
|
"@effected/walker": "^0.15.0",
|
|
62
62
|
"@effected/xdg": "^0.9.0",
|
package/services/SyncLogger.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Console, Context, Effect, Layer, PubSub, Ref } from "effect";
|
|
2
|
-
import { CliTheme, Fmt, Status } from "@effected/cli";
|
|
2
|
+
import { CliLog, CliTheme, Fmt, Status } from "@effected/cli";
|
|
3
3
|
import { Audience } from "@effected/env";
|
|
4
4
|
|
|
5
5
|
//#region src/services/SyncLogger.ts
|
|
@@ -47,8 +47,11 @@ function pluralize(resource, count) {
|
|
|
47
47
|
* vocabulary: a change made is `success`, a dry run's `would …` is `info`, a
|
|
48
48
|
* deletion and a drift are `warning`, a failure is `failure`, a skip is `skip`.
|
|
49
49
|
* The glyph is painted for a person; an agent gets it unpainted and the text
|
|
50
|
-
* plain,
|
|
51
|
-
*
|
|
50
|
+
* plain, through `CliTheme.forAudience` — the rule `CliMessage` paints by. The
|
|
51
|
+
* failure lines with a glyph — the per-repository `error` line, at the action
|
|
52
|
+
* lines' indent, and the closing `Sync complete with N errors:` header — go
|
|
53
|
+
* through `CliLog.status`, which paints its glyph the same way on the log
|
|
54
|
+
* channel (the logger strips every escape a program logs itself). Headers
|
|
52
55
|
* (`group:`, `repo:`) take none — they are structure, not outcomes. The glyph
|
|
53
56
|
* sits after the indent and before the padded verb, so the verbs still line
|
|
54
57
|
* up with each other.
|
|
@@ -91,23 +94,21 @@ function SyncLoggerLive(config) {
|
|
|
91
94
|
const errors = yield* Ref.make([]);
|
|
92
95
|
const currentRepo = yield* Ref.make("");
|
|
93
96
|
const publish = (event) => events === void 0 ? Effect.void : PubSub.publish(events, event);
|
|
97
|
+
/** stdout's theme as this run's audience sees it: unpainted for an agent. */
|
|
98
|
+
const stdoutTheme = CliTheme.forAudience(theme.forStream("stdout"), audience.kind);
|
|
99
|
+
/** A status glyph for a report line on stdout. */
|
|
100
|
+
const mark = (status) => stdoutTheme.status(Status.core, status);
|
|
94
101
|
/**
|
|
95
|
-
* A status
|
|
96
|
-
* plain for an agent — who never gets an escape, whatever the terminal
|
|
97
|
-
* could do.
|
|
98
|
-
*/
|
|
99
|
-
const mark = (status) => audience.kind === "agent" ? Status.core.glyph(status, theme.glyphs) : theme.status(Status.core, status);
|
|
100
|
-
/**
|
|
101
|
-
* A status glyph for a failure line, never painted.
|
|
102
|
+
* A failure status line, glyph painted for a person, `indent` spaces in.
|
|
102
103
|
*
|
|
103
104
|
* @remarks
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* the
|
|
105
|
+
* `CliLog.status` logs at `Error` (the status's rank), so the line goes
|
|
106
|
+
* where `Effect.logError` goes — stderr — and its text is sanitised. The
|
|
107
|
+
* theme and audience this layer was built with are provided, so the
|
|
108
|
+
* glyph follows the same audience the stdout lines do. In the ASCII set
|
|
109
|
+
* the glyph is the word `[FAIL]`.
|
|
109
110
|
*/
|
|
110
|
-
const
|
|
111
|
+
const emitFailure = (text, indent = 0) => CliLog.status(Status.core, "failure", text, { indent }).pipe(Effect.provideService(CliTheme, theme), Effect.provideService(Audience, audience));
|
|
111
112
|
const emit = (line) => Console.log(Fmt.sanitize(line));
|
|
112
113
|
/** An action line: indent, glyph, then the padded verb and its content. */
|
|
113
114
|
const action = (status, body) => Console.log(` ${mark(status)} ${Fmt.sanitize(body)}`);
|
|
@@ -200,7 +201,7 @@ function SyncLoggerLive(config) {
|
|
|
200
201
|
context,
|
|
201
202
|
message
|
|
202
203
|
}]);
|
|
203
|
-
yield*
|
|
204
|
+
yield* emitFailure(`error ${context}: ${message}`, 4);
|
|
204
205
|
yield* publish({
|
|
205
206
|
_tag: "Error",
|
|
206
207
|
repo,
|
|
@@ -212,7 +213,7 @@ function SyncLoggerLive(config) {
|
|
|
212
213
|
const errs = yield* Ref.get(errors);
|
|
213
214
|
if (errs.length === 0) yield* Console.log(`${mark("success")} Sync complete!`);
|
|
214
215
|
else {
|
|
215
|
-
yield*
|
|
216
|
+
yield* emitFailure(`Sync complete with ${errs.length} ${errs.length === 1 ? "error" : "errors"}:`);
|
|
216
217
|
for (const err of errs) yield* emitError(Fmt.sanitize(err.repo === "" ? ` ${err.context} — ${err.message}` : ` ${err.repo}: ${err.context} — ${err.message}`));
|
|
217
218
|
}
|
|
218
219
|
yield* publish({
|