reposets 2.0.4 → 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/README.md +16 -8
- package/bin/reposets.js +103 -45
- package/cli/commands/credentials.js +249 -33
- package/cli/commands/doctor.js +63 -53
- package/cli/commands/drift.js +4 -0
- package/cli/commands/history.js +222 -68
- package/cli/commands/init.js +64 -17
- package/cli/commands/list.js +23 -10
- package/cli/commands/nuke.js +279 -43
- package/cli/commands/sync.js +175 -81
- package/cli/commands/validate.js +54 -31
- package/cli/views/sync-progress-model.js +143 -0
- package/cli/views/sync-progress-view.js +32 -0
- package/cli/views/sync-progress.js +55 -0
- package/index.d.ts +35 -1
- package/index.js +2 -2
- package/package.json +13 -7
- package/schemas/hosted.js +79 -0
- package/services/ConfigFiles.js +53 -13
- package/services/SyncLogger.js +110 -23
- package/store/SyncJournal.js +1 -0
- package/store/files.js +25 -0
package/index.d.ts
CHANGED
|
@@ -707,6 +707,40 @@ export declare class ConfigFlagNotFound extends ConfigFlagNotFound_base<{
|
|
|
707
707
|
*/
|
|
708
708
|
get message(): string;
|
|
709
709
|
}
|
|
710
|
+
declare const ConfigFlagMissingConfig_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
711
|
+
readonly _tag: "ConfigFlagMissingConfig";
|
|
712
|
+
} & Readonly<A>;
|
|
713
|
+
/**
|
|
714
|
+
* Raised when `--config` names a directory that holds no `reposets.config.toml`.
|
|
715
|
+
*
|
|
716
|
+
* @remarks
|
|
717
|
+
* The sibling of {@link ConfigFlagNotFound}, for the case it cannot see: the
|
|
718
|
+
* path exists, so the existence check passes, but the directory resolver then
|
|
719
|
+
* finds nothing and — being a probe with `never` in its error channel — falls
|
|
720
|
+
* through to `AppConfig`'s XDG tier. `reposets doctor --config ./empty-dir`
|
|
721
|
+
* would report on the user's XDG config, a different file from the one asked
|
|
722
|
+
* about, with nothing to say so. An explicit request fails loudly here for the
|
|
723
|
+
* same reason a missing path does.
|
|
724
|
+
*
|
|
725
|
+
* A separate tag rather than a widened `ConfigFlagNotFound`, because the two
|
|
726
|
+
* call for different fixes — a mistyped path, versus a right directory with
|
|
727
|
+
* the file missing or misnamed — and the message names both the directory and
|
|
728
|
+
* the filename it looked for. Both are paths the user typed or a fixed name;
|
|
729
|
+
* nothing secret reaches the message.
|
|
730
|
+
*
|
|
731
|
+
* @public
|
|
732
|
+
*/
|
|
733
|
+
export declare class ConfigFlagMissingConfig extends ConfigFlagMissingConfig_base<{
|
|
734
|
+
readonly dir: string;
|
|
735
|
+
readonly filename: string;
|
|
736
|
+
}> {
|
|
737
|
+
/**
|
|
738
|
+
* @remarks
|
|
739
|
+
* As on {@link ConfigFlagNotFound}: without it the error renders as a bare
|
|
740
|
+
* tag and the directory never reaches the log line.
|
|
741
|
+
*/
|
|
742
|
+
get message(): string;
|
|
743
|
+
}
|
|
710
744
|
/**
|
|
711
745
|
* Builds the config-file layer for one invocation's `--config` flag.
|
|
712
746
|
*
|
|
@@ -716,7 +750,7 @@ export declare class ConfigFlagNotFound extends ConfigFlagNotFound_base<{
|
|
|
716
750
|
*
|
|
717
751
|
* @public
|
|
718
752
|
*/
|
|
719
|
-
export declare const makeConfigFilesLive: (configFlag: string | undefined) => Layer.Layer<ReposetsConfigFile, ConfigFlagNotFound, FileSystem.FileSystem | Path.Path | AppDirs | Xdg>;
|
|
753
|
+
export declare const makeConfigFilesLive: (configFlag: string | undefined) => Layer.Layer<ReposetsConfigFile, ConfigFlagNotFound | ConfigFlagMissingConfig, FileSystem.FileSystem | Path.Path | AppDirs | Xdg>;
|
|
720
754
|
//#endregion
|
|
721
755
|
export type { Config };
|
|
722
756
|
//# sourceMappingURL=index.d.ts.map
|
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": "
|
|
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,15 +49,21 @@
|
|
|
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.
|
|
55
|
-
"@effected/
|
|
56
|
-
"@effected/
|
|
52
|
+
"@effected/app": "^0.21.1",
|
|
53
|
+
"@effected/cli": "^0.12.0",
|
|
54
|
+
"@effected/config-file": "^0.14.2",
|
|
55
|
+
"@effected/env": "^0.1.0",
|
|
56
|
+
"@effected/github": "^0.15.1",
|
|
57
|
+
"@effected/glob": "^0.10.0",
|
|
58
|
+
"@effected/schemastore": "^0.21.0",
|
|
59
|
+
"@effected/store": "^0.13.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 };
|
package/services/ConfigFiles.js
CHANGED
|
@@ -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
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* the
|
|
55
|
-
* `discover`
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
})]
|
|
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 };
|
package/services/SyncLogger.js
CHANGED
|
@@ -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 { CliLog, 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,31 @@ 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, 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
|
|
55
|
+
* (`group:`, `repo:`) take none — they are structure, not outcomes. The glyph
|
|
56
|
+
* sits after the indent and before the padded verb, so the verbs still line
|
|
57
|
+
* up with each other.
|
|
58
|
+
*
|
|
44
59
|
* **The report is the product; failures are diagnostics.** What a run did —
|
|
45
60
|
* group and repository headers, every operation, every drift line, the closing
|
|
46
61
|
* "Sync complete!" — is the output of `sync` and `drift`, so it is written with
|
|
47
62
|
* `Console.log` to stdout, where `reposets drift > report.txt` captures it.
|
|
48
|
-
* Failures are emitted with `Effect.logError`, which the CLI logger
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
63
|
+
* Failures are emitted with `Effect.logError`, which the CLI logger routes to
|
|
64
|
+
* stderr, so they stay on the terminal when stdout is redirected. A consequence
|
|
65
|
+
* worth knowing: `--log-level` filters diagnostics only — it does not silence
|
|
66
|
+
* the report.
|
|
67
|
+
*
|
|
68
|
+
* Both go through the fiber's `Console`, which is the seam a live progress view
|
|
69
|
+
* uses: providing its `logConsole` around the run puts every one of these lines
|
|
70
|
+
* above the redrawing frame without this service knowing a view exists.
|
|
52
71
|
*
|
|
53
72
|
* @public
|
|
54
73
|
*/
|
|
@@ -57,18 +76,42 @@ var SyncLogger = class extends Context.Service()("reposets/SyncLogger") {};
|
|
|
57
76
|
* Build a live logger for one run.
|
|
58
77
|
*
|
|
59
78
|
* @remarks
|
|
60
|
-
* A factory rather than a bare layer because
|
|
61
|
-
* they come from the `--dry-run` and `--debug` flags,
|
|
62
|
-
* the command has parsed.
|
|
79
|
+
* A factory rather than a bare layer because its settings are per-invocation:
|
|
80
|
+
* they come from the `--dry-run` and `--debug` flags, and the event sink from
|
|
81
|
+
* whether this run draws a view — all known only once the command has parsed.
|
|
82
|
+
*
|
|
83
|
+
* Requires `CliTheme` and `Audience`, read once when the layer builds, to paint
|
|
84
|
+
* the status glyphs; under the bin both come from the environment
|
|
85
|
+
* `CliRuntime.main` builds.
|
|
63
86
|
*
|
|
64
87
|
* @public
|
|
65
88
|
*/
|
|
66
89
|
function SyncLoggerLive(config) {
|
|
67
|
-
const { dryRun, debug } = config;
|
|
90
|
+
const { dryRun, debug, events } = config;
|
|
68
91
|
return Layer.effect(SyncLogger, Effect.gen(function* () {
|
|
92
|
+
const theme = yield* CliTheme;
|
|
93
|
+
const audience = yield* Audience;
|
|
69
94
|
const errors = yield* Ref.make([]);
|
|
70
95
|
const currentRepo = yield* Ref.make("");
|
|
71
|
-
const
|
|
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);
|
|
101
|
+
/**
|
|
102
|
+
* A failure status line, glyph painted for a person, `indent` spaces in.
|
|
103
|
+
*
|
|
104
|
+
* @remarks
|
|
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]`.
|
|
110
|
+
*/
|
|
111
|
+
const emitFailure = (text, indent = 0) => CliLog.status(Status.core, "failure", text, { indent }).pipe(Effect.provideService(CliTheme, theme), Effect.provideService(Audience, audience));
|
|
112
|
+
const emit = (line) => Console.log(Fmt.sanitize(line));
|
|
113
|
+
/** An action line: indent, glyph, then the padded verb and its content. */
|
|
114
|
+
const action = (status, body) => Console.log(` ${mark(status)} ${Fmt.sanitize(body)}`);
|
|
72
115
|
/**
|
|
73
116
|
* Failures, on the error channel.
|
|
74
117
|
*
|
|
@@ -87,34 +130,69 @@ function SyncLoggerLive(config) {
|
|
|
87
130
|
* concatenated directly, with no separating space.
|
|
88
131
|
*/
|
|
89
132
|
const formatVerb = (pastTense, presentTense) => dryRun ? `would ${presentTense}`.padEnd(14) : pastTense.padEnd(8);
|
|
133
|
+
/** A change made reads as done; the same change on a dry run, as information. */
|
|
134
|
+
const changeStatus = dryRun ? "info" : "success";
|
|
90
135
|
return {
|
|
136
|
+
runStart: (total) => publish({
|
|
137
|
+
_tag: "RunStarted",
|
|
138
|
+
total,
|
|
139
|
+
dryRun
|
|
140
|
+
}),
|
|
91
141
|
groupStart: (name, selected, declared) => {
|
|
92
142
|
const scope = selected === declared ? `${selected} ${selected === 1 ? "repo" : "repos"}` : `${selected} of ${declared} ${declared === 1 ? "repo" : "repos"}`;
|
|
93
|
-
return emit(`group: ${name} (${scope})`)
|
|
143
|
+
return emit(`group: ${name} (${scope})`).pipe(Effect.andThen(publish({
|
|
144
|
+
_tag: "GroupStarted",
|
|
145
|
+
name,
|
|
146
|
+
selected,
|
|
147
|
+
declared
|
|
148
|
+
})));
|
|
94
149
|
},
|
|
95
150
|
repoStart: (owner, repo) => {
|
|
96
151
|
const repoSlug = `${owner}/${repo}`;
|
|
97
|
-
return Ref.set(currentRepo, repoSlug).pipe(Effect.andThen(emit(` repo: ${repoSlug}`))
|
|
152
|
+
return Ref.set(currentRepo, repoSlug).pipe(Effect.andThen(emit(` repo: ${repoSlug}`)), Effect.andThen(publish({
|
|
153
|
+
_tag: "RepoStarted",
|
|
154
|
+
slug: repoSlug
|
|
155
|
+
})));
|
|
98
156
|
},
|
|
99
157
|
settingsApplied: (fields) => {
|
|
100
158
|
const named = [...fields].sort();
|
|
101
159
|
const detail = named.length === 0 ? "" : named.length <= 6 ? ` (${named.join(", ")})` : ` (${named.length} fields: ${named.slice(0, 5).join(", ")}, …)`;
|
|
102
|
-
return
|
|
160
|
+
return action(changeStatus, `${formatVerb("applied", "apply")}settings${detail}`).pipe(Effect.andThen(publish({
|
|
161
|
+
_tag: "Operation",
|
|
162
|
+
verb: "apply",
|
|
163
|
+
resource: "settings",
|
|
164
|
+
count: 1
|
|
165
|
+
})));
|
|
103
166
|
},
|
|
104
167
|
cleanupSummary: (resource, count, names) => {
|
|
105
168
|
const suffix = names.length > 0 ? ` (${names.join(", ")})` : "";
|
|
106
|
-
return
|
|
169
|
+
return action("warning", `${formatVerb("deleted", "delete")}${count} ${pluralize(resource, count)}${suffix}`).pipe(Effect.andThen(publish({
|
|
170
|
+
_tag: "Operation",
|
|
171
|
+
verb: "delete",
|
|
172
|
+
resource,
|
|
173
|
+
count
|
|
174
|
+
})));
|
|
107
175
|
},
|
|
108
176
|
syncOperation: (verb, resource, name, detail, source) => {
|
|
109
177
|
const nameStr = name ? ` ${name}` : "";
|
|
110
178
|
const suffix = detail ? ` ${detail}` : "";
|
|
111
179
|
const sourceSuffix = source && debug ? ` <- ${source}` : "";
|
|
112
|
-
return
|
|
180
|
+
return action(verb === "skip" ? "skip" : verb === "delete" ? "warning" : changeStatus, `${formatVerb(verb, verb)}${resource}${nameStr}${suffix}${sourceSuffix}`).pipe(Effect.andThen(publish({
|
|
181
|
+
_tag: "Operation",
|
|
182
|
+
verb,
|
|
183
|
+
resource,
|
|
184
|
+
count: 1
|
|
185
|
+
})));
|
|
113
186
|
},
|
|
114
187
|
driftDetected: (resource, name, drift) => {
|
|
115
188
|
const consequence = !drift.needsApply ? "already matches config, nothing written" : dryRun ? "would overwrite" : "overwritten";
|
|
116
189
|
const fingerprints = debug ? ` <- applied ${drift.applied} live ${drift.live}` : "";
|
|
117
|
-
return
|
|
190
|
+
return action("warning", `${"drift".padEnd(8)}${resource} ${name} changed outside reposets — ${consequence}${fingerprints}`).pipe(Effect.andThen(publish({
|
|
191
|
+
_tag: "Drift",
|
|
192
|
+
resource,
|
|
193
|
+
name,
|
|
194
|
+
needsApply: drift.needsApply
|
|
195
|
+
})));
|
|
118
196
|
},
|
|
119
197
|
syncError: (context, message) => Effect.gen(function* () {
|
|
120
198
|
const repo = yield* Ref.get(currentRepo);
|
|
@@ -123,16 +201,25 @@ function SyncLoggerLive(config) {
|
|
|
123
201
|
context,
|
|
124
202
|
message
|
|
125
203
|
}]);
|
|
126
|
-
yield*
|
|
204
|
+
yield* emitFailure(`error ${context}: ${message}`, 4);
|
|
205
|
+
yield* publish({
|
|
206
|
+
_tag: "Error",
|
|
207
|
+
repo,
|
|
208
|
+
context,
|
|
209
|
+
message
|
|
210
|
+
});
|
|
127
211
|
}),
|
|
128
|
-
finish: () => Effect.gen(function* () {
|
|
212
|
+
finish: (summary) => Effect.gen(function* () {
|
|
129
213
|
const errs = yield* Ref.get(errors);
|
|
130
|
-
if (errs.length === 0) {
|
|
131
|
-
|
|
132
|
-
|
|
214
|
+
if (errs.length === 0) yield* Console.log(`${mark("success")} Sync complete!`);
|
|
215
|
+
else {
|
|
216
|
+
yield* emitFailure(`Sync complete with ${errs.length} ${errs.length === 1 ? "error" : "errors"}:`);
|
|
217
|
+
for (const err of errs) yield* emitError(Fmt.sanitize(err.repo === "" ? ` ${err.context} — ${err.message}` : ` ${err.repo}: ${err.context} — ${err.message}`));
|
|
133
218
|
}
|
|
134
|
-
yield*
|
|
135
|
-
|
|
219
|
+
yield* publish({
|
|
220
|
+
_tag: "RunEnded",
|
|
221
|
+
...summary
|
|
222
|
+
});
|
|
136
223
|
})
|
|
137
224
|
};
|
|
138
225
|
}));
|
package/store/SyncJournal.js
CHANGED
|
@@ -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 };
|