reposets 2.0.4 → 3.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/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  import { ConfigSchema } from "./schemas/config.js";
2
- import { CONFIG_FILENAME, ConfigFlagNotFound, ReposetsConfigFile, makeConfigFilesLive } from "./services/ConfigFiles.js";
2
+ import { CONFIG_FILENAME, ConfigFlagMissingConfig, ConfigFlagNotFound, ReposetsConfigFile, makeConfigFilesLive } from "./services/ConfigFiles.js";
3
3
 
4
- export { CONFIG_FILENAME, ConfigFlagNotFound, ConfigSchema, ReposetsConfigFile, makeConfigFilesLive };
4
+ export { CONFIG_FILENAME, ConfigFlagMissingConfig, ConfigFlagNotFound, ConfigSchema, ReposetsConfigFile, makeConfigFilesLive };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reposets",
3
- "version": "2.0.4",
3
+ "version": "3.0.0",
4
4
  "private": false,
5
5
  "description": "CLI tool to sync GitHub repo settings, secrets and rulesets across personal repositories",
6
6
  "keywords": [
@@ -52,12 +52,18 @@
52
52
  "@effected/app": "^0.20.0",
53
53
  "@effected/cli": "^0.11.0",
54
54
  "@effected/config-file": "^0.14.0",
55
+ "@effected/env": "^0.1.0",
55
56
  "@effected/github": "^0.15.0",
57
+ "@effected/glob": "^0.10.0",
58
+ "@effected/schemastore": "^0.20.0",
56
59
  "@effected/store": "^0.12.0",
57
60
  "@effected/toml": "^0.11.0",
61
+ "@effected/walker": "^0.15.0",
58
62
  "@effected/xdg": "^0.9.0",
59
63
  "blakejs": "^1.2.1",
60
- "effect": "^4.0.0"
64
+ "effect": "^4.0.0",
65
+ "ink": "^7.1.1",
66
+ "react": "^19.3.0"
61
67
  },
62
68
  "engines": {
63
69
  "node": ">=24.11.0"
@@ -0,0 +1,79 @@
1
+ import { HostedSchema } from "@effected/schemastore";
2
+
3
+ //#region src/schemas/hosted.ts
4
+ /**
5
+ * The version label both published JSON Schema documents carry.
6
+ *
7
+ * @remarks
8
+ * `3.0` tracks the reposets 3.0.0 release that introduced versioned schemas.
9
+ * The label names the TOML contract, not the package version: a later
10
+ * reposets release that leaves both file shapes alone keeps `3.0`. A contract
11
+ * change (a new required key, a removed or retyped key, a new `default`) is
12
+ * answered by appending a new label to `versions` and leaving the `3.0` file
13
+ * frozen on disk — `init` stamps the `3.0` URL into every config it scaffolds,
14
+ * so editing that document in place would move a URL people pin.
15
+ *
16
+ * @public
17
+ */
18
+ const SCHEMA_VERSION = "3.0";
19
+ /**
20
+ * Where the `reposets.config.toml` JSON Schema is hosted and which version is
21
+ * current.
22
+ *
23
+ * @remarks
24
+ * The one identity both sides derive from: `lib/configs/schemastore.config.ts`
25
+ * builds the document at this `$id` (keyed by `name`), and `reposets init`
26
+ * stamps `#:schema <$id>` into the config it scaffolds. Neither spells the URL
27
+ * by hand, so the file the build writes and the URL a fresh config points at
28
+ * cannot disagree. Served raw from `main` under the repository root's
29
+ * `schemas/` directory — independent of the package layout — as
30
+ * `schemas/<version>/config.json` (`appendVersion: false`, the version
31
+ * directory alone names the version).
32
+ *
33
+ * @public
34
+ */
35
+ const configSchemaHost = HostedSchema.github({
36
+ repo: "spencerbeggs/reposets",
37
+ path: "schemas",
38
+ name: "config",
39
+ versions: ["3.0"],
40
+ appendVersion: false
41
+ });
42
+ /**
43
+ * Where the `reposets.credentials.toml` JSON Schema is hosted and which
44
+ * version is current.
45
+ *
46
+ * @remarks
47
+ * The credentials-file twin of {@link configSchemaHost}: same repository,
48
+ * directory, layout and version label, so the two documents always move
49
+ * together.
50
+ *
51
+ * @public
52
+ */
53
+ const credentialsSchemaHost = HostedSchema.github({
54
+ repo: "spencerbeggs/reposets",
55
+ path: "schemas",
56
+ name: "credentials",
57
+ versions: ["3.0"],
58
+ appendVersion: false
59
+ });
60
+ /**
61
+ * The `#:schema` directive `init` writes as the first line of a scaffolded
62
+ * TOML file, followed by the blank line Tombi requires before the document.
63
+ *
64
+ * @remarks
65
+ * `#:schema <url>` is the spelling both Taplo and Tombi read, and to every
66
+ * TOML parser it is an ordinary comment — so the config and credentials
67
+ * decoders never see it. The directive binds the file to one schema version
68
+ * without depending on SchemaStore's catalog, which matches by file name and
69
+ * may lag a release.
70
+ *
71
+ * @param host - the hosted schema the file is written against
72
+ * @returns the directive line and its trailing blank line
73
+ *
74
+ * @public
75
+ */
76
+ const schemaDirective = (host) => `#:schema ${host.$id}\n\n`;
77
+
78
+ //#endregion
79
+ export { configSchemaHost, credentialsSchemaHost, schemaDirective };
@@ -1,6 +1,6 @@
1
1
  import { ConfigSchema } from "../schemas/config.js";
2
2
  import { CredentialsSchema } from "../schemas/credentials.js";
3
- import { Data, Effect, FileSystem, Layer } from "effect";
3
+ import { Data, Effect, FileSystem, Layer, Path } from "effect";
4
4
  import { AppConfig } from "@effected/app";
5
5
  import { ConfigFile, ConfigResolver, TomlCodec } from "@effected/config-file";
6
6
 
@@ -48,14 +48,13 @@ var ReposetsCredentialsFile = class extends ConfigFile.Service()("reposets/Crede
48
48
  * are covered by a `StructWithRest` rest schema, so they are not excess;
49
49
  * verified here and pinned by a test upstream.
50
50
  *
51
- * **Applied to credentials only, for now.** The config file keeps lenient
52
- * decoding because `doctor` diagnoses unknown keys with nearest-match
53
- * suggestions — strictly better output than a decode failure — and it locates
54
- * the file through `discover`, which decodes. Making the load strict means
55
- * `discover` fails and `doctor` reports "no config found" for a file that is
56
- * present and one character wrong, losing the diagnosis exactly when it is
57
- * wanted. Turning it on for config needs `doctor` to locate the file without
58
- * decoding first.
51
+ * **Applied to both files.** Strict config decoding was once held back
52
+ * because `doctor` found the config through `discover`, which decodes: a
53
+ * strict load would have made a file that is present and one key wrong look
54
+ * absent, losing the diagnosis exactly when it is wanted. `doctor` now keeps
55
+ * `discover`'s failure as the structured issue it reports, and locates the
56
+ * file on its own (`locateConfig`) without decoding, so its nearest-match
57
+ * suggestions survive a strict load.
59
58
  */
60
59
  const STRICT_KEYS = {
61
60
  onExcessProperty: "error",
@@ -105,20 +104,61 @@ var ConfigFlagNotFound = class extends Data.TaggedError("ConfigFlagNotFound") {
105
104
  }
106
105
  };
107
106
  /**
107
+ * Raised when `--config` names a directory that holds no `reposets.config.toml`.
108
+ *
109
+ * @remarks
110
+ * The sibling of {@link ConfigFlagNotFound}, for the case it cannot see: the
111
+ * path exists, so the existence check passes, but the directory resolver then
112
+ * finds nothing and — being a probe with `never` in its error channel — falls
113
+ * through to `AppConfig`'s XDG tier. `reposets doctor --config ./empty-dir`
114
+ * would report on the user's XDG config, a different file from the one asked
115
+ * about, with nothing to say so. An explicit request fails loudly here for the
116
+ * same reason a missing path does.
117
+ *
118
+ * A separate tag rather than a widened `ConfigFlagNotFound`, because the two
119
+ * call for different fixes — a mistyped path, versus a right directory with
120
+ * the file missing or misnamed — and the message names both the directory and
121
+ * the filename it looked for. Both are paths the user typed or a fixed name;
122
+ * nothing secret reaches the message.
123
+ *
124
+ * @public
125
+ */
126
+ var ConfigFlagMissingConfig = class extends Data.TaggedError("ConfigFlagMissingConfig") {
127
+ /**
128
+ * @remarks
129
+ * As on {@link ConfigFlagNotFound}: without it the error renders as a bare
130
+ * tag and the directory never reaches the log line.
131
+ */
132
+ get message() {
133
+ return `--config directory has no ${this.filename}: ${this.dir}`;
134
+ }
135
+ };
136
+ /**
108
137
  * Builds the resolver tiers that must win over `AppConfig`'s own XDG chain.
109
138
  *
110
139
  * @remarks
111
140
  * `AppConfig.layer` prepends these, in order, ahead of `XdgConfig.resolver` and
112
141
  * the native-directory probe, so only the higher-priority tiers appear here.
142
+ *
143
+ * A directory is checked for the config file by existence only, not decoded:
144
+ * a file that is present but invalid must still resolve, so `doctor` can
145
+ * diagnose it and a command can report its decode error against the right path.
113
146
  */
114
147
  const resolversFor = (configFlag) => Effect.gen(function* () {
115
148
  if (configFlag === void 0) return [ConfigResolver.upwardWalk({ filename: CONFIG_FILENAME })];
116
- const info = yield* (yield* FileSystem.FileSystem).stat(configFlag).pipe(Effect.option);
149
+ const fs = yield* FileSystem.FileSystem;
150
+ const info = yield* fs.stat(configFlag).pipe(Effect.option);
117
151
  if (info._tag === "None") return yield* new ConfigFlagNotFound({ path: configFlag });
118
- return info.value.type === "Directory" ? [ConfigResolver.staticDir({
152
+ if (info.value.type !== "Directory") return [ConfigResolver.explicitPath(configFlag)];
153
+ const path = yield* Path.Path;
154
+ if (!(yield* fs.exists(path.join(configFlag, "reposets.config.toml")).pipe(Effect.orElseSucceed(() => false)))) return yield* new ConfigFlagMissingConfig({
155
+ dir: configFlag,
156
+ filename: CONFIG_FILENAME
157
+ });
158
+ return [ConfigResolver.staticDir({
119
159
  dir: configFlag,
120
160
  filename: CONFIG_FILENAME
121
- })] : [ConfigResolver.explicitPath(configFlag)];
161
+ })];
122
162
  });
123
163
  /**
124
164
  * Builds the config-file layer for one invocation's `--config` flag.
@@ -138,4 +178,4 @@ const makeConfigFilesLive = (configFlag) => Layer.unwrap(Effect.map(resolversFor
138
178
  })));
139
179
 
140
180
  //#endregion
141
- export { CONFIG_FILENAME, CREDENTIALS_FILENAME, ConfigFlagNotFound, CredentialsFilesLive, ReposetsConfigFile, ReposetsCredentialsFile, makeConfigFilesLive };
181
+ export { CONFIG_FILENAME, CREDENTIALS_FILENAME, ConfigFlagMissingConfig, ConfigFlagNotFound, CredentialsFilesLive, ReposetsConfigFile, ReposetsCredentialsFile, makeConfigFilesLive };
@@ -1,4 +1,6 @@
1
- import { Console, Context, Effect, Layer, Ref } from "effect";
1
+ import { Console, Context, Effect, Layer, PubSub, Ref } from "effect";
2
+ import { CliTheme, Fmt, Status } from "@effected/cli";
3
+ import { Audience } from "@effected/env";
2
4
 
3
5
  //#region src/services/SyncLogger.ts
4
6
  /** Plural forms the naive `+ "s"` gets wrong. */
@@ -41,14 +43,28 @@ function pluralize(resource, count) {
41
43
  * out-of-band edit happens to match the config. The tool has no work to do; the
42
44
  * human still changed something, and staying quiet would hide it.
43
45
  *
46
+ * **Every action line leads with a status glyph**, from the kit's core
47
+ * vocabulary: a change made is `success`, a dry run's `would …` is `info`, a
48
+ * deletion and a drift are `warning`, a failure is `failure`, a skip is `skip`.
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
52
+ * (`group:`, `repo:`) take none — they are structure, not outcomes. The glyph
53
+ * sits after the indent and before the padded verb, so the verbs still line
54
+ * up with each other.
55
+ *
44
56
  * **The report is the product; failures are diagnostics.** What a run did —
45
57
  * group and repository headers, every operation, every drift line, the closing
46
58
  * "Sync complete!" — is the output of `sync` and `drift`, so it is written with
47
59
  * `Console.log` to stdout, where `reposets drift > report.txt` captures it.
48
- * Failures are emitted with `Effect.logError`, which the CLI logger
49
- * (`CliLogger.layer()`) routes to stderr, so they stay on the terminal when
50
- * stdout is redirected. A consequence worth knowing: `--log-level` filters
51
- * diagnostics only — it no longer silences the report.
60
+ * Failures are emitted with `Effect.logError`, which the CLI logger routes to
61
+ * stderr, so they stay on the terminal when stdout is redirected. A consequence
62
+ * worth knowing: `--log-level` filters diagnostics only — it does not silence
63
+ * the report.
64
+ *
65
+ * Both go through the fiber's `Console`, which is the seam a live progress view
66
+ * uses: providing its `logConsole` around the run puts every one of these lines
67
+ * above the redrawing frame without this service knowing a view exists.
52
68
  *
53
69
  * @public
54
70
  */
@@ -57,18 +73,44 @@ var SyncLogger = class extends Context.Service()("reposets/SyncLogger") {};
57
73
  * Build a live logger for one run.
58
74
  *
59
75
  * @remarks
60
- * A factory rather than a bare layer because both settings are per-invocation:
61
- * they come from the `--dry-run` and `--debug` flags, which are only known once
62
- * the command has parsed.
76
+ * A factory rather than a bare layer because its settings are per-invocation:
77
+ * they come from the `--dry-run` and `--debug` flags, and the event sink from
78
+ * whether this run draws a view — all known only once the command has parsed.
79
+ *
80
+ * Requires `CliTheme` and `Audience`, read once when the layer builds, to paint
81
+ * the status glyphs; under the bin both come from the environment
82
+ * `CliRuntime.main` builds.
63
83
  *
64
84
  * @public
65
85
  */
66
86
  function SyncLoggerLive(config) {
67
- const { dryRun, debug } = config;
87
+ const { dryRun, debug, events } = config;
68
88
  return Layer.effect(SyncLogger, Effect.gen(function* () {
89
+ const theme = yield* CliTheme;
90
+ const audience = yield* Audience;
69
91
  const errors = yield* Ref.make([]);
70
92
  const currentRepo = yield* Ref.make("");
71
- const emit = (line) => Console.log(line);
93
+ const publish = (event) => events === void 0 ? Effect.void : PubSub.publish(events, event);
94
+ /**
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
+ *
103
+ * @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]`.
109
+ */
110
+ const failureMark = Status.core.glyph("failure", theme.forStream("stderr").glyphs);
111
+ const emit = (line) => Console.log(Fmt.sanitize(line));
112
+ /** An action line: indent, glyph, then the padded verb and its content. */
113
+ const action = (status, body) => Console.log(` ${mark(status)} ${Fmt.sanitize(body)}`);
72
114
  /**
73
115
  * Failures, on the error channel.
74
116
  *
@@ -87,34 +129,69 @@ function SyncLoggerLive(config) {
87
129
  * concatenated directly, with no separating space.
88
130
  */
89
131
  const formatVerb = (pastTense, presentTense) => dryRun ? `would ${presentTense}`.padEnd(14) : pastTense.padEnd(8);
132
+ /** A change made reads as done; the same change on a dry run, as information. */
133
+ const changeStatus = dryRun ? "info" : "success";
90
134
  return {
135
+ runStart: (total) => publish({
136
+ _tag: "RunStarted",
137
+ total,
138
+ dryRun
139
+ }),
91
140
  groupStart: (name, selected, declared) => {
92
141
  const scope = selected === declared ? `${selected} ${selected === 1 ? "repo" : "repos"}` : `${selected} of ${declared} ${declared === 1 ? "repo" : "repos"}`;
93
- return emit(`group: ${name} (${scope})`);
142
+ return emit(`group: ${name} (${scope})`).pipe(Effect.andThen(publish({
143
+ _tag: "GroupStarted",
144
+ name,
145
+ selected,
146
+ declared
147
+ })));
94
148
  },
95
149
  repoStart: (owner, repo) => {
96
150
  const repoSlug = `${owner}/${repo}`;
97
- return Ref.set(currentRepo, repoSlug).pipe(Effect.andThen(emit(` repo: ${repoSlug}`)));
151
+ return Ref.set(currentRepo, repoSlug).pipe(Effect.andThen(emit(` repo: ${repoSlug}`)), Effect.andThen(publish({
152
+ _tag: "RepoStarted",
153
+ slug: repoSlug
154
+ })));
98
155
  },
99
156
  settingsApplied: (fields) => {
100
157
  const named = [...fields].sort();
101
158
  const detail = named.length === 0 ? "" : named.length <= 6 ? ` (${named.join(", ")})` : ` (${named.length} fields: ${named.slice(0, 5).join(", ")}, …)`;
102
- return emit(` ${formatVerb("applied", "apply")}settings${detail}`);
159
+ return action(changeStatus, `${formatVerb("applied", "apply")}settings${detail}`).pipe(Effect.andThen(publish({
160
+ _tag: "Operation",
161
+ verb: "apply",
162
+ resource: "settings",
163
+ count: 1
164
+ })));
103
165
  },
104
166
  cleanupSummary: (resource, count, names) => {
105
167
  const suffix = names.length > 0 ? ` (${names.join(", ")})` : "";
106
- return emit(` ${formatVerb("deleted", "delete")}${count} ${pluralize(resource, count)}${suffix}`);
168
+ return action("warning", `${formatVerb("deleted", "delete")}${count} ${pluralize(resource, count)}${suffix}`).pipe(Effect.andThen(publish({
169
+ _tag: "Operation",
170
+ verb: "delete",
171
+ resource,
172
+ count
173
+ })));
107
174
  },
108
175
  syncOperation: (verb, resource, name, detail, source) => {
109
176
  const nameStr = name ? ` ${name}` : "";
110
177
  const suffix = detail ? ` ${detail}` : "";
111
178
  const sourceSuffix = source && debug ? ` <- ${source}` : "";
112
- return emit(` ${formatVerb(verb, verb)}${resource}${nameStr}${suffix}${sourceSuffix}`);
179
+ return action(verb === "skip" ? "skip" : verb === "delete" ? "warning" : changeStatus, `${formatVerb(verb, verb)}${resource}${nameStr}${suffix}${sourceSuffix}`).pipe(Effect.andThen(publish({
180
+ _tag: "Operation",
181
+ verb,
182
+ resource,
183
+ count: 1
184
+ })));
113
185
  },
114
186
  driftDetected: (resource, name, drift) => {
115
187
  const consequence = !drift.needsApply ? "already matches config, nothing written" : dryRun ? "would overwrite" : "overwritten";
116
188
  const fingerprints = debug ? ` <- applied ${drift.applied} live ${drift.live}` : "";
117
- return emit(` ${"drift".padEnd(8)}${resource} ${name} changed outside reposets — ${consequence}${fingerprints}`);
189
+ return action("warning", `${"drift".padEnd(8)}${resource} ${name} changed outside reposets — ${consequence}${fingerprints}`).pipe(Effect.andThen(publish({
190
+ _tag: "Drift",
191
+ resource,
192
+ name,
193
+ needsApply: drift.needsApply
194
+ })));
118
195
  },
119
196
  syncError: (context, message) => Effect.gen(function* () {
120
197
  const repo = yield* Ref.get(currentRepo);
@@ -123,16 +200,25 @@ function SyncLoggerLive(config) {
123
200
  context,
124
201
  message
125
202
  }]);
126
- yield* emitError(` error ${context}: ${message}`);
203
+ yield* emitError(` ${failureMark} ${Fmt.sanitize(`error ${context}: ${message}`)}`);
204
+ yield* publish({
205
+ _tag: "Error",
206
+ repo,
207
+ context,
208
+ message
209
+ });
127
210
  }),
128
- finish: () => Effect.gen(function* () {
211
+ finish: (summary) => Effect.gen(function* () {
129
212
  const errs = yield* Ref.get(errors);
130
- if (errs.length === 0) {
131
- yield* emit("Sync complete!");
132
- return;
213
+ if (errs.length === 0) yield* Console.log(`${mark("success")} Sync complete!`);
214
+ else {
215
+ yield* emitError(`${failureMark} Sync complete with ${errs.length} ${errs.length === 1 ? "error" : "errors"}:`);
216
+ for (const err of errs) yield* emitError(Fmt.sanitize(err.repo === "" ? ` ${err.context} — ${err.message}` : ` ${err.repo}: ${err.context} — ${err.message}`));
133
217
  }
134
- yield* emitError(`Sync complete with ${errs.length} ${errs.length === 1 ? "error" : "errors"}:`);
135
- for (const err of errs) yield* emitError(err.repo === "" ? ` ${err.context} — ${err.message}` : ` ${err.repo}: ${err.context} — ${err.message}`);
218
+ yield* publish({
219
+ _tag: "RunEnded",
220
+ ...summary
221
+ });
136
222
  })
137
223
  };
138
224
  }));
@@ -59,6 +59,7 @@ var SyncJournal = class extends Context.Service()("reposets/SyncJournal", { make
59
59
  ORDER BY r.rowid DESC
60
60
  LIMIT ${options.limit}
61
61
  `,
62
+ runIds: () => sql`SELECT id FROM sync_run`.pipe(Effect.map((rows) => rows.map((row) => row.id))),
62
63
  changesFor: (runId) => sql`
63
64
  SELECT repo, kind, name, action, detail
64
65
  FROM sync_change
package/store/files.js ADDED
@@ -0,0 +1,25 @@
1
+ //#region src/store/files.ts
2
+ /**
3
+ * The state database's file name, under the XDG state directory.
4
+ *
5
+ * @remarks
6
+ * Passed explicitly to `AppStore.layer` rather than left to its default, so
7
+ * the name the database is opened under and the name `nuke` and `doctor` look
8
+ * for are one constant and cannot drift apart.
9
+ *
10
+ * @public
11
+ */
12
+ const STATE_DB_FILENAME = "store.db";
13
+ /**
14
+ * The cache database's file name, under the XDG cache directory.
15
+ *
16
+ * @remarks
17
+ * Passed explicitly to `AppCache.layer` for the same reason as
18
+ * {@link STATE_DB_FILENAME}.
19
+ *
20
+ * @public
21
+ */
22
+ const CACHE_DB_FILENAME = "cache.db";
23
+
24
+ //#endregion
25
+ export { CACHE_DB_FILENAME, STATE_DB_FILENAME };