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 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.0.0";
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
- * Built the way `App.layer` builds its own (`AppDirs.layer` `provideMerge`
43
- * `Xdg.layer`), but without the two databases `App.layer` always opens. Every
44
- * command can resolve a path through this; resolving one creates nothing —
45
- * only an `ensure*` member does, and only the command that writes calls it.
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 = Layer.provideMerge(AppDirs.layer({ namespace: "reposets" }), Xdg.layer);
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, or anything longer than 60 characters. The length rule
46
- * catches the tokens that carry no recognisable prefix, but it never applies to
47
- * an `op://` value: that is a 1Password reference by construction, none of the
48
- * token prefixes begins that way, and a real reference crosses 60 characters
49
- * easily once a vault or item name has spaces in it
50
- * (`op://Engineering Shared Vault/GitHub Production Deploy Token/credential`).
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. While a value is being typed it
171
- * is visible — the kit's `TextInput` has no masked mode.
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
  });
@@ -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
 
@@ -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
  /**
@@ -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"));
@@ -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 module is imported dynamically on the drawing path only. The same
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* runWithView : yield* runSync(void 0);
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: noun(state.total, "repo", "repos"),
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: noun(state.changes, "change", "changes"),
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: noun(state.errors, "error", "errors"),
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 { initialSyncProgress, isSyncRunEnd, isSyncRunStart, reduceSyncProgress, syncSummaryBlock } from "./sync-progress-model.js";
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: every `CliUi.live` option but `events`.
40
+ * The sync progress view's `render`.
41
41
  *
42
42
  * @remarks
43
- * Defined once and exported so the handler and its test drive the same value —
44
- * a test that rebuilt the options would exercise a copy. `hosted` because a
45
- * run that cannot draw (a pipe, an agent, CI) has its own closing output — the
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 syncProgressView = {
56
- initial: initialSyncProgress,
57
- reduce: reduceSyncProgress,
58
- render: (state, frame) => /* @__PURE__ */ jsx(SyncProgress, {
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 { syncProgressView };
55
+ export { syncProgressRender };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reposets",
3
- "version": "3.0.0",
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.20.0",
53
- "@effected/cli": "^0.11.0",
54
- "@effected/config-file": "^0.14.0",
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.0",
56
+ "@effected/github": "^0.15.1",
57
57
  "@effected/glob": "^0.10.0",
58
- "@effected/schemastore": "^0.20.0",
59
- "@effected/store": "^0.12.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",
@@ -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, exactly as `CliMessage` does it. A failure's glyph is never painted:
51
- * it goes through the logger, which strips every escape a program logs. Headers
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 glyph for a report line on stdout: painted for a person,
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
- * Failures go through `Effect.logError`, and the kit's logger sanitises
105
- * every line a program logs — escapes included — so a painted glyph
106
- * would arrive as the bare glyph anyway. Painting it here would only be
107
- * a promise the logger does not keep. The glyph itself survives, and in
108
- * the ASCII set it is the word `[FAIL]`.
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 failureMark = Status.core.glyph("failure", theme.forStream("stderr").glyphs);
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* emitError(` ${failureMark} ${Fmt.sanitize(`error ${context}: ${message}`)}`);
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* emitError(`${failureMark} Sync complete with ${errs.length} ${errs.length === 1 ? "error" : "errors"}:`);
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({