@effected/schemastore-cli 0.9.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.
@@ -0,0 +1,160 @@
1
+ import { Effect, FileSystem, Path, Schema } from "effect";
2
+ import { isSchemastoreConfig } from "@effected/schemastore";
3
+ import { createJiti } from "jiti";
4
+
5
+ //#region src/ConfigLoader.ts
6
+ /**
7
+ * No `schemastore.config.*` was found — by upward discovery from the working
8
+ * directory, or at the explicit path handed to `--config`.
9
+ *
10
+ * @public
11
+ */
12
+ var ConfigNotFoundError = class extends Schema.TaggedError()("ConfigNotFoundError", {
13
+ /** The directories walked (discovery) or the one explicit path checked. */
14
+ searched: Schema.Array(Schema.String) }) {
15
+ get message() {
16
+ return `No schemastore config found. Searched for ${ConfigLoader.CONFIG_NAMES.join(", ")} in: ${this.searched.join(", ")}`;
17
+ }
18
+ };
19
+ /**
20
+ * The config file exists but could not be turned into a `SchemastoreConfig`:
21
+ * the module threw on import, its default export is not a `defineConfig(...)`
22
+ * value, a `schemas` element is not `SchemaTarget`-shaped, or two outputs
23
+ * resolve to one absolute path.
24
+ *
25
+ * @public
26
+ */
27
+ var ConfigLoadError = class extends Schema.TaggedError()("ConfigLoadError", {
28
+ path: Schema.String,
29
+ reason: Schema.String
30
+ }) {
31
+ get message() {
32
+ return `Failed to load schemastore config ${this.path}: ${this.reason}`;
33
+ }
34
+ };
35
+ const jitiImport = (path) => createJiti(path, { interopDefault: true }).import(path);
36
+ const describeCause = (cause) => cause instanceof Error ? cause.stack ?? cause.message : String(cause);
37
+ const describeMalformedTarget = (schemas) => {
38
+ if (!Array.isArray(schemas)) return "schemas is not an array";
39
+ for (const [index, target] of schemas.entries()) {
40
+ const record = typeof target === "object" && target !== null ? target : void 0;
41
+ if (!(record !== void 0 && Schema.isSchema(record.schema) && typeof record.$id === "string" && typeof record.path === "string" && typeof record.published === "boolean")) return `schemas[${index}] is not a SchemaTarget (missing schema/$id/path/published)`;
42
+ }
43
+ };
44
+ const describeDuplicatePath = (config) => {
45
+ const seen = /* @__PURE__ */ new Set();
46
+ const paths = [...config.schemas.map((target) => target.path), ...config.catalog.map((c) => c.config.path)];
47
+ for (const p of paths) {
48
+ if (seen.has(p)) return `output path "${p}" is declared twice after resolution`;
49
+ seen.add(p);
50
+ }
51
+ };
52
+ /**
53
+ * Finds and loads a `schemastore.config.*` file.
54
+ *
55
+ * @remarks
56
+ * `discover` walks upward from a start directory checking
57
+ * {@link ConfigLoader.CONFIG_NAMES} in order at each level, so the nearest
58
+ * directory wins and, within one directory, `.ts` beats `.mjs`. `load` either
59
+ * takes the explicit path or discovers one, imports it, checks the default
60
+ * export is a `defineConfig(...)` value, and resolves relative paths against
61
+ * the config's directory.
62
+ *
63
+ * @public
64
+ */
65
+ var ConfigLoader = class ConfigLoader {
66
+ constructor() {}
67
+ /** The file names discovery looks for, in preference order. */
68
+ static CONFIG_NAMES = [
69
+ "schemastore.config.ts",
70
+ "schemastore.config.mts",
71
+ "schemastore.config.js",
72
+ "schemastore.config.mjs"
73
+ ];
74
+ /**
75
+ * Walk upward from `start` until a config file is found; fails with the
76
+ * list of directories searched when none is.
77
+ */
78
+ static discover = Effect.fn("ConfigLoader.discover")(function* (start) {
79
+ const fs = yield* FileSystem.FileSystem;
80
+ const path = yield* Path.Path;
81
+ const searched = [];
82
+ let dir = path.resolve(start);
83
+ for (;;) {
84
+ searched.push(dir);
85
+ for (const name of ConfigLoader.CONFIG_NAMES) {
86
+ const candidate = path.join(dir, name);
87
+ if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) return candidate;
88
+ }
89
+ const parent = path.dirname(dir);
90
+ if (parent === dir) return yield* Effect.fail(new ConfigNotFoundError({ searched }));
91
+ dir = parent;
92
+ }
93
+ });
94
+ /**
95
+ * Resolve every relative `path` in the config (schema targets and catalog
96
+ * entries) against `directory`; absolute paths are left alone. The result
97
+ * keeps the `defineConfig` brand.
98
+ */
99
+ static resolvePaths = Effect.fn("ConfigLoader.resolvePaths")(function* (config, directory) {
100
+ const path = yield* Path.Path;
101
+ const absolute = (p) => path.isAbsolute(p) ? p : path.resolve(directory, p);
102
+ return {
103
+ ...config,
104
+ schemas: config.schemas.map((target) => ({
105
+ ...target,
106
+ path: absolute(target.path)
107
+ })),
108
+ catalog: config.catalog.map((c) => ({
109
+ ...c,
110
+ config: {
111
+ ...c.config,
112
+ path: absolute(c.config.path)
113
+ }
114
+ }))
115
+ };
116
+ });
117
+ /** Locate, import and validate the config; see {@link ConfigLoadOptions}. */
118
+ static load = Effect.fn("ConfigLoader.load")(function* (options) {
119
+ const fs = yield* FileSystem.FileSystem;
120
+ const path = yield* Path.Path;
121
+ const importModule = options.importModule ?? jitiImport;
122
+ let configPath;
123
+ if (options.explicit !== void 0) {
124
+ configPath = path.resolve(options.cwd, options.explicit);
125
+ if (!(yield* fs.exists(configPath).pipe(Effect.orElseSucceed(() => false)))) return yield* Effect.fail(new ConfigNotFoundError({ searched: [configPath] }));
126
+ } else configPath = yield* ConfigLoader.discover(options.cwd);
127
+ const module = yield* Effect.tryPromise({
128
+ try: () => importModule(configPath),
129
+ catch: (cause) => new ConfigLoadError({
130
+ path: configPath,
131
+ reason: describeCause(cause)
132
+ })
133
+ });
134
+ const exported = module?.default ?? module;
135
+ if (!isSchemastoreConfig(exported)) return yield* Effect.fail(new ConfigLoadError({
136
+ path: configPath,
137
+ reason: "default export is not a defineConfig(...) value from @effected/schemastore"
138
+ }));
139
+ const malformed = describeMalformedTarget(exported.schemas);
140
+ if (malformed !== void 0) return yield* Effect.fail(new ConfigLoadError({
141
+ path: configPath,
142
+ reason: malformed
143
+ }));
144
+ const directory = path.dirname(configPath);
145
+ const config = yield* ConfigLoader.resolvePaths(exported, directory);
146
+ const duplicate = describeDuplicatePath(config);
147
+ if (duplicate !== void 0) return yield* Effect.fail(new ConfigLoadError({
148
+ path: configPath,
149
+ reason: duplicate
150
+ }));
151
+ return {
152
+ path: configPath,
153
+ directory,
154
+ config
155
+ };
156
+ });
157
+ };
158
+
159
+ //#endregion
160
+ export { ConfigLoadError, ConfigLoader, ConfigNotFoundError };
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C. Spencer Beggs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # @effected/schemastore-cli
2
+
3
+ [![npm](https://img.shields.io/npm/v/@effected%2Fschemastore-cli?label=npm&color=cb3837)](https://www.npmjs.com/package/@effected/schemastore-cli)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
5
+ [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
+ [![TypeScript 7.0](https://img.shields.io/badge/TypeScript-7.0-3178c6.svg)](https://www.typescriptlang.org/)
7
+
8
+ The `schemastore` command: build and check SchemaStore-shaped JSON Schema documents from a `schemastore.config.ts`. It is the command-line companion to [`@effected/schemastore`](https://www.npmjs.com/package/@effected/schemastore), which owns the pipeline; this package ships the plumbing every consumer used to write by hand — flag parsing, the contract gate, the drift test — once, as a `bin`.
9
+
10
+ It is not a library: nothing is importable from it. Every type a config file needs comes from `@effected/schemastore`, which the CLI declares as a peer so your config and the pipeline share one `effect` and one `@effected/schemastore` instance.
11
+
12
+ > **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
13
+ > development against a single pinned Effect v4 prerelease. Packages graduate to
14
+ > `1.0.0` once Effect `4.0.0` ships. To hold your own `effect` versions at
15
+ > exactly the ones the kit is built and tested against, install
16
+ > [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
17
+ >
18
+ > **Stability: unstable.** This package's API surface is not yet considered
19
+ > complete and may change across `0.x` releases. Pin an exact version — even a
20
+ > package marked *stable* before `1.0.0` can introduce a breaking change by
21
+ > accident, and an exact pin turns that into a type-check error rather than a
22
+ > runtime surprise. Full policy: [release strategy](https://github.com/spencerbeggs/effected#release-strategy).
23
+
24
+ ## Install
25
+
26
+ `@effected/schemastore` and `@effected/schemastore-cli` release together at one version; install them at the same version.
27
+
28
+ ```bash
29
+ npm install --save-dev @effected/schemastore-cli @effected/schemastore effect
30
+ ```
31
+
32
+ ```bash
33
+ pnpm add -D @effected/schemastore-cli @effected/schemastore effect
34
+ ```
35
+
36
+ ## Configure
37
+
38
+ Create `schemastore.config.ts` (also `.mts`, `.js`, `.mjs`). The CLI finds it by walking upward from the working directory, or takes its path as a positional argument. Relative `path` values resolve against the config file's directory.
39
+
40
+ ```ts
41
+ import { defineConfig, SchemaTarget } from "@effected/schemastore";
42
+ import { ReleaseOutput, SCHEMA_URL } from "./src/schema/release-output.js";
43
+
44
+ export default defineConfig({
45
+ schemas: [
46
+ SchemaTarget.make({
47
+ schema: ReleaseOutput,
48
+ $id: SCHEMA_URL,
49
+ name: "silk-release-action",
50
+ version: "5.0.0",
51
+ path: "schemas/silk-release-action-5.0.0.json",
52
+ published: true,
53
+ jsonSchema: { onExcessProperty: "error" },
54
+ }),
55
+ ],
56
+ catalog: [
57
+ {
58
+ name: "silk-release-action",
59
+ description: "Structured output of the silk-release GitHub Action",
60
+ fileMatch: ["silk-release-output.json"],
61
+ baseUrl: "https://raw.githubusercontent.com/savvy-web/silk-release-action/main/schemas",
62
+ path: "schemas/catalog-entry.json",
63
+ },
64
+ ],
65
+ drift: { policy: "semantic", onDrift: "error" },
66
+ });
67
+ ```
68
+
69
+ - `schemas` — at least one `SchemaTarget`; `published` (default `false`) marks a version other people depend on.
70
+ - `catalog` — zero or more SchemaStore catalog entries; `versions` is derived from every versioned schema of that name. The entry's `url` and each `versions` value are derived as `<baseUrl>/<name>-<version>.json`, so every schema's `path` must sit directly under the directory `baseUrl` names and its `$id` must be that exact URL — the CLI does not yet cross-check them.
71
+ - `drift` — the default policy for published schemas (`strict`, `semantic` or `allow`) and what drift means (`error` or `warn`). Defaults to `{ policy: "semantic", onDrift: "error" }`.
72
+
73
+ Then add two scripts:
74
+
75
+ ```json
76
+ {
77
+ "scripts": {
78
+ "schema:build": "schemastore build",
79
+ "schema:check": "schemastore check"
80
+ }
81
+ }
82
+ ```
83
+
84
+ ## Commands
85
+
86
+ ```text
87
+ schemastore build [config] [--drift=strict|semantic|allow] [--on-drift=error|warn] [--force] [--format=human|json]
88
+ schemastore check [config] [--drift=strict|semantic|allow] [--on-drift=error|warn] [--force] [--format=human|json]
89
+ ```
90
+
91
+ - `build` generates every schema, runs the gates (structural lints and ajv strict mode), applies the drift policy, and writes what passes — content-compared, so unchanged files are untouched — along with each catalog entry.
92
+ - `check` is the identical walk with no writes: it reports what `build` would do under the same flags and exits under the same conditions. It is the CI gate, so it also fails (exit `1`) whenever a build would write anything — a committed schema or catalog entry that differs from what the config generates, or is missing, is stale; run `schemastore build` and commit the result.
93
+ - `--drift` and `--on-drift` override the config's `drift` block for one run; `--force` is sugar for `--drift=allow`.
94
+ - `--format=json` emits one JSON document on stdout (config path, per-schema outcome, per-catalog-entry outcome, effective drift policy and its source); human text moves to stderr.
95
+ - When `GITHUB_STEP_SUMMARY` is set, both commands append a markdown summary table.
96
+
97
+ An unpublished schema is never drift: a contract change at a pinned but unpublished version rewrites the file in place.
98
+
99
+ ## Exit codes
100
+
101
+ | code | meaning |
102
+ | ---- | -------------------------------------------------------------------------- |
103
+ | 0 | success, including drift under `onDrift: warn` |
104
+ | 1 | drift under `onDrift: error`, a gate failure, or — for `check` — any document `build` would write |
105
+ | 2 | config not found, failed to load, or failed `SchemastoreConfig` validation |
106
+ | 3 | infrastructure failure |
107
+ | 64 | usage error |
108
+
109
+ ## License
110
+
111
+ MIT
package/Report.js ADDED
@@ -0,0 +1,140 @@
1
+ //#region src/Report.ts
2
+ const findingLine = (finding) => ` ${finding.label} at "${finding.path}": ${finding.message}`;
3
+ const blockingCount = (findings) => findings.filter((finding) => finding.severity === "warning").length;
4
+ const suggestionClause = (schema) => schema.nextVersion !== void 0 && schema.nextVersion !== schema.version ? ` → suggest ${schema.nextVersion}` : "";
5
+ const publishedClause = (schema) => schema.version !== void 0 ? ` at published ${schema.version}` : "";
6
+ const schemaLines = (schema, report) => {
7
+ switch (schema.outcome) {
8
+ case "written": return [`written (${schema.change}) ${schema.path}`];
9
+ case "unchanged": return [`unchanged ${schema.path}`];
10
+ case "would-write": return [`would write (${schema.change}) ${schema.path}`];
11
+ case "drift": return [`DRIFT ${schema.change}${publishedClause(schema)}${suggestionClause(schema)} — ${schema.path}`];
12
+ case "held": return [`held (${report.gateFailed ? "gate failed elsewhere" : "drift elsewhere"}) ${schema.path}`];
13
+ case "gate-failed": return [`GATE FAILED ${schema.path} (${blockingCount(schema.findings)} blocking finding(s))`, ...schema.findings.map(findingLine)];
14
+ default: return schema.outcome;
15
+ }
16
+ };
17
+ const catalogLine = (entry) => {
18
+ switch (entry.outcome) {
19
+ case "written": return `written catalog ${entry.path}`;
20
+ case "unchanged": return `unchanged catalog ${entry.path}`;
21
+ case "would-write": return `would write catalog ${entry.path}`;
22
+ case "held": return `held catalog ${entry.path}`;
23
+ default: return entry.outcome;
24
+ }
25
+ };
26
+ const summaryLine = (report) => {
27
+ const written = report.schemas.filter((schema) => schema.outcome === "written").length;
28
+ const unchanged = report.schemas.filter((schema) => schema.outcome === "unchanged").length;
29
+ const drift = report.schemas.filter((schema) => schema.verdict === "drift").length;
30
+ const gateFailed = report.schemas.filter((schema) => schema.outcome === "gate-failed").length;
31
+ return `${report.schemas.length} schema(s): ${written} written, ${unchanged} unchanged, ${drift} drift, ${gateFailed} gate failed — drift policy ${report.drift.policy}/${report.drift.onDrift} (${report.drift.source})`;
32
+ };
33
+ const warningLine = (schema, report) => `warning: DRIFT ${schema.change}${publishedClause(schema)} ${report.mode === "build" ? "written" : "would write"} under --on-drift=warn — ${schema.path}`;
34
+ const tableRow = (columns) => `| ${columns.join(" | ")} |`;
35
+ /**
36
+ * Renders a {@link RunReport} for a terminal, a JSON consumer or a GitHub
37
+ * step summary.
38
+ *
39
+ * @public
40
+ */
41
+ var Report = class {
42
+ constructor() {}
43
+ /** stdout lines. */
44
+ static human(report) {
45
+ const lines = [];
46
+ for (const schema of report.schemas) lines.push(...schemaLines(schema, report));
47
+ for (const entry of report.catalog) lines.push(catalogLine(entry));
48
+ lines.push(summaryLine(report));
49
+ return lines;
50
+ }
51
+ /**
52
+ * stderr lines: one per drift written (or, under `check`, would be written)
53
+ * under `onDrift: "warn"`. Empty when the gate failed — nothing was (or
54
+ * would be) written, so there is no drift to warn about.
55
+ */
56
+ static warnings(report) {
57
+ if (report.drift.onDrift !== "warn" || report.gateFailed) return [];
58
+ return report.schemas.filter((schema) => schema.verdict === "drift").map((schema) => warningLine(schema, report));
59
+ }
60
+ /** One JSON document, stable key order. */
61
+ static json(report) {
62
+ const doc = {
63
+ mode: report.mode,
64
+ configPath: report.configPath,
65
+ drift: {
66
+ policy: report.drift.policy,
67
+ onDrift: report.drift.onDrift,
68
+ source: report.drift.source
69
+ },
70
+ schemas: report.schemas.map((schema) => ({
71
+ $id: schema.$id,
72
+ path: schema.path,
73
+ ...schema.name !== void 0 ? { name: schema.name } : {},
74
+ ...schema.version !== void 0 ? { version: schema.version } : {},
75
+ published: schema.published,
76
+ change: schema.change,
77
+ verdict: schema.verdict,
78
+ outcome: schema.outcome,
79
+ ...schema.nextVersion !== void 0 ? { nextVersion: schema.nextVersion } : {},
80
+ findings: schema.findings.map((finding) => ({
81
+ source: finding.source,
82
+ severity: finding.severity,
83
+ ...finding.check !== void 0 ? { check: finding.check } : {},
84
+ path: finding.path,
85
+ message: finding.message
86
+ }))
87
+ })),
88
+ catalog: report.catalog.map((entry) => ({
89
+ name: entry.name,
90
+ path: entry.path,
91
+ outcome: entry.outcome
92
+ })),
93
+ drifted: report.drifted,
94
+ gateFailed: report.gateFailed,
95
+ wrote: report.wrote
96
+ };
97
+ return JSON.stringify(doc, null, " ");
98
+ }
99
+ /** The step-summary table; ends with a newline so a later append starts on its own line. */
100
+ static markdown(report) {
101
+ const lines = [`### schemastore ${report.mode}`, ""];
102
+ lines.push(tableRow([
103
+ "schema",
104
+ "version",
105
+ "published",
106
+ "change",
107
+ "outcome"
108
+ ]), tableRow([
109
+ "---",
110
+ "---",
111
+ "---",
112
+ "---",
113
+ "---"
114
+ ]));
115
+ for (const schema of report.schemas) lines.push(tableRow([
116
+ schema.name ?? schema.$id,
117
+ schema.version ?? "",
118
+ schema.published ? "yes" : "no",
119
+ schema.change,
120
+ schema.outcome
121
+ ]));
122
+ if (report.catalog.length > 0) {
123
+ lines.push("", tableRow(["catalog", "outcome"]), tableRow(["---", "---"]));
124
+ for (const entry of report.catalog) lines.push(tableRow([entry.name, entry.outcome]));
125
+ }
126
+ lines.push("");
127
+ if (report.gateFailed) {
128
+ const failed = report.schemas.filter((schema) => schema.outcome === "gate-failed").length;
129
+ lines.push(`**Gate:** ${failed} schema(s) failed`);
130
+ } else if (report.drifted) {
131
+ const drifted = report.schemas.filter((schema) => schema.verdict === "drift").length;
132
+ lines.push(`**Drift:** ${drifted} schema(s) drifted under ${report.drift.policy}/${report.drift.onDrift}`);
133
+ } else lines.push("**Drift:** none");
134
+ lines.push("");
135
+ return lines.join("\n");
136
+ }
137
+ };
138
+
139
+ //#endregion
140
+ export { Report };
package/Runner.js ADDED
@@ -0,0 +1,125 @@
1
+ import { Effect, FileSystem, Path, Schema } from "effect";
2
+ import { CanonicalJson, CatalogEntry, DriftPolicy, SchemaPipeline, SchemaVersioning } from "@effected/schemastore";
3
+
4
+ //#region src/Runner.ts
5
+ const pipelineOptions = { contractChanges: "allow" };
6
+ const catalogText = (target) => CanonicalJson.serialize(Schema.encodeSync(CatalogEntry)(target.entry));
7
+ const jsonEqual = (a, b) => {
8
+ if (a === b) return true;
9
+ if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => jsonEqual(v, b[i]));
10
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
11
+ const left = a;
12
+ const right = b;
13
+ const keys = Object.keys(left);
14
+ return keys.length === Object.keys(right).length && keys.every((k) => Object.hasOwn(right, k) && jsonEqual(left[k], right[k]));
15
+ };
16
+ const sameJson = (existing, text) => {
17
+ try {
18
+ return jsonEqual(JSON.parse(existing), JSON.parse(text));
19
+ } catch {
20
+ return false;
21
+ }
22
+ };
23
+ /**
24
+ * The shared `build` / `check` walk: classify every schema through
25
+ * {@link DriftPolicy} over `SchemaPipeline.check`, then write through
26
+ * `SchemaPipeline.run` only when nothing is refused.
27
+ *
28
+ * @remarks
29
+ * A build writes NOTHING when any schema fails its gate, or when any schema
30
+ * drifts under `onDrift: "error"` — a partial write would leave a
31
+ * repository half-bumped. Every otherwise-writable schema then reports
32
+ * `held`, so a reader sees why a clean schema was not written — in both
33
+ * modes, since `check` reports what `build` would do under the same
34
+ * flags. Both modes share one `SchemaFile`; the single `writing`
35
+ * predicate (`mode === "build" && !refused`) gates every write, schemas
36
+ * and catalog entries alike. Under `onDrift: "warn"` drifting schemas are
37
+ * written and keep their `"drift"` verdict for the renderer to shout
38
+ * about.
39
+ *
40
+ * Catalog entries follow the schemas: serialized canonically, compared by
41
+ * parsed content against the file on disk, written only when different and
42
+ * only when the run is writing.
43
+ *
44
+ * @public
45
+ */
46
+ var Runner = class {
47
+ constructor() {}
48
+ static run = Effect.fn("Runner.run")(function* (config, options) {
49
+ const fs = yield* FileSystem.FileSystem;
50
+ const path = yield* Path.Path;
51
+ const checks = yield* SchemaPipeline.check(config.schemas, pipelineOptions);
52
+ const gateFailed = checks.some((check) => check.blocked);
53
+ const classified = checks.map((check, i) => {
54
+ const target = config.schemas[i];
55
+ return {
56
+ target,
57
+ check,
58
+ verdict: DriftPolicy.classify({
59
+ published: target.published,
60
+ change: check.change
61
+ }, options.drift.policy),
62
+ nextVersion: target.version !== void 0 && check.change === "contract" && SchemaVersioning.isPinned(target.version) ? SchemaVersioning.next(target.version, "contract") : void 0
63
+ };
64
+ });
65
+ const drifted = classified.some((entry) => entry.verdict === "drift");
66
+ const refused = gateFailed || drifted && options.drift.onDrift === "error";
67
+ const writing = options.mode === "build" && !refused;
68
+ const written = writing ? yield* SchemaPipeline.run(config.schemas, pipelineOptions).pipe(Effect.catchTags({
69
+ SchemaGateError: (error) => Effect.die(error),
70
+ SchemaContractChangeError: (error) => Effect.die(error)
71
+ })) : void 0;
72
+ const schemas = classified.map(({ target, check, verdict, nextVersion }, i) => {
73
+ const outcome = check.blocked ? "gate-failed" : written !== void 0 ? written[i].outcome : verdict === "drift" ? "drift" : refused && check.wouldWrite ? "held" : check.wouldWrite ? "would-write" : "unchanged";
74
+ return {
75
+ $id: target.$id,
76
+ path: target.path,
77
+ published: target.published,
78
+ change: check.change,
79
+ verdict,
80
+ outcome,
81
+ findings: check.findings,
82
+ ...target.name !== void 0 ? { name: target.name } : {},
83
+ ...target.version !== void 0 ? { version: target.version } : {},
84
+ ...nextVersion !== void 0 ? { nextVersion } : {}
85
+ };
86
+ });
87
+ const catalog = [];
88
+ for (const entry of config.catalog) {
89
+ const { name, path: file } = entry.config;
90
+ const text = yield* catalogText(entry);
91
+ if ((yield* fs.exists(file)) && sameJson(yield* fs.readFileString(file), text)) catalog.push({
92
+ name,
93
+ path: file,
94
+ outcome: "unchanged"
95
+ });
96
+ else if (!writing) catalog.push({
97
+ name,
98
+ path: file,
99
+ outcome: refused ? "held" : "would-write"
100
+ });
101
+ else {
102
+ yield* fs.makeDirectory(path.dirname(file), { recursive: true });
103
+ yield* fs.writeFileString(file, text);
104
+ catalog.push({
105
+ name,
106
+ path: file,
107
+ outcome: "written"
108
+ });
109
+ }
110
+ }
111
+ return {
112
+ mode: options.mode,
113
+ configPath: options.configPath,
114
+ drift: options.drift,
115
+ schemas,
116
+ catalog,
117
+ drifted,
118
+ gateFailed,
119
+ wrote: schemas.some((s) => s.outcome === "written") || catalog.some((c) => c.outcome === "written")
120
+ };
121
+ });
122
+ };
123
+
124
+ //#endregion
125
+ export { Runner };
package/StepSummary.js ADDED
@@ -0,0 +1,31 @@
1
+ import { Config, Effect, FileSystem, Option } from "effect";
2
+
3
+ //#region src/StepSummary.ts
4
+ const summaryTarget = Config.String("GITHUB_STEP_SUMMARY").pipe(Config.option);
5
+ /**
6
+ * Appends a rendered {@link Report.markdown} document to `GITHUB_STEP_SUMMARY`.
7
+ *
8
+ * @public
9
+ */
10
+ var StepSummary = class {
11
+ constructor() {}
12
+ /**
13
+ * `true` when appended; `false` when `GITHUB_STEP_SUMMARY` is unset or
14
+ * empty, or when the read/write failed — a failure is logged at warning
15
+ * and never fails the run it is reporting on.
16
+ */
17
+ static append = Effect.fn("StepSummary.append")(function* (markdown) {
18
+ const fs = yield* FileSystem.FileSystem;
19
+ return yield* Effect.gen(function* () {
20
+ const target = yield* summaryTarget;
21
+ if (Option.isNone(target)) return false;
22
+ const existing = (yield* fs.exists(target.value)) ? yield* fs.readFileString(target.value) : "";
23
+ const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
24
+ yield* fs.writeFileString(target.value, existing + separator + markdown);
25
+ return true;
26
+ }).pipe(Effect.catch((error) => Effect.logWarning("StepSummary.append: could not update GITHUB_STEP_SUMMARY", error).pipe(Effect.as(false))));
27
+ });
28
+ };
29
+
30
+ //#endregion
31
+ export { StepSummary };
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../main.js";
3
+
4
+ //#region src/bin.ts
5
+ main();
6
+
7
+ //#endregion
8
+ export { };
@@ -0,0 +1,15 @@
1
+ import { execute } from "../execute.js";
2
+ import { commandFlags } from "../flags.js";
3
+ import { Command } from "effect/unstable/cli";
4
+
5
+ //#region src/cli/commands/build.ts
6
+ /**
7
+ * `schemastore build`: generate, gate and write every schema and catalog
8
+ * entry the config declares.
9
+ *
10
+ * @public
11
+ */
12
+ const makeBuildCommand = (deps) => Command.make("build", commandFlags, (input) => execute("build", input, deps)).pipe(Command.withDescription("Generate, gate and write every schema and catalog entry the config declares"));
13
+
14
+ //#endregion
15
+ export { makeBuildCommand };
@@ -0,0 +1,16 @@
1
+ import { execute } from "../execute.js";
2
+ import { commandFlags } from "../flags.js";
3
+ import { Command } from "effect/unstable/cli";
4
+
5
+ //#region src/cli/commands/check.ts
6
+ /**
7
+ * `schemastore check`: the same walk as `build`, reported and never written.
8
+ * The CI drift gate: it also fails when the committed documents are stale,
9
+ * i.e. whenever `build` would write anything.
10
+ *
11
+ * @public
12
+ */
13
+ const makeCheckCommand = (deps) => Command.make("check", commandFlags, (input) => execute("check", input, deps)).pipe(Command.withDescription("Report what build would do, fail when it would write anything or refuse to, and write nothing"));
14
+
15
+ //#endregion
16
+ export { makeCheckCommand };
package/cli/execute.js ADDED
@@ -0,0 +1,102 @@
1
+ import { ConfigLoader } from "../ConfigLoader.js";
2
+ import { Report } from "../Report.js";
3
+ import { Runner } from "../Runner.js";
4
+ import { StepSummary } from "../StepSummary.js";
5
+ import { CliRuntime } from "@effected/cli";
6
+ import { Console, Effect, Option, Schema } from "effect";
7
+ import { SchemaFile, SchemaValidator } from "@effected/schemastore";
8
+
9
+ //#region src/cli/execute.ts
10
+ /**
11
+ * A published schema drifted under `onDrift: "error"`, so nothing was
12
+ * written. Exit `1`.
13
+ *
14
+ * @public
15
+ */
16
+ var DriftError = class extends Schema.TaggedError()("DriftError", { count: Schema.Number }) {
17
+ get message() {
18
+ return `${this.count} published schema(s) drifted; nothing was written. Bump the drifting versions in the config, or re-run with --force to write anyway.`;
19
+ }
20
+ };
21
+ /**
22
+ * A schema failed the lint/validation gate, so nothing was written. Exit `1`.
23
+ *
24
+ * @public
25
+ */
26
+ var GateError = class extends Schema.TaggedError()("GateError", { count: Schema.Number }) {
27
+ get message() {
28
+ return `${this.count} schema(s) failed the lint/validation gate; nothing was written.`;
29
+ }
30
+ };
31
+ /**
32
+ * `check` found committed documents that differ from what the config
33
+ * generates (or are missing), so a `build` would write. `check` is the CI
34
+ * drift gate, so a stale tree fails it. Exit `1`.
35
+ *
36
+ * @public
37
+ */
38
+ var StaleError = class extends Schema.TaggedError()("StaleError", { count: Schema.Number }) {
39
+ get message() {
40
+ return `${this.count} document(s) are stale; run \`schemastore build\` and commit the result.`;
41
+ }
42
+ };
43
+ const reported = (error, exitCode) => CliRuntime.reported(error, exitCode);
44
+ const effectiveDrift = (configured, input) => {
45
+ return {
46
+ policy: input.force ? "allow" : Option.getOrElse(input.drift, () => configured.policy),
47
+ onDrift: Option.getOrElse(input.onDrift, () => configured.onDrift),
48
+ source: input.force || Option.isSome(input.drift) || Option.isSome(input.onDrift) ? "flag" : "config"
49
+ };
50
+ };
51
+ const emit = Effect.fn("schemastore.emit")(function* (report, format) {
52
+ if (format === "json") {
53
+ yield* Console.log(Report.json(report));
54
+ for (const line of Report.human(report)) yield* Effect.logInfo(line);
55
+ } else for (const line of Report.human(report)) yield* Console.log(line);
56
+ for (const line of Report.warnings(report)) yield* Effect.logWarning(line);
57
+ });
58
+ /**
59
+ * Run one `build` or `check`.
60
+ *
61
+ * @remarks
62
+ * Loads the config, applies the flag overrides, runs the shared walk,
63
+ * emits the report in the requested format, appends the step summary, and
64
+ * fails typed — `GateError`, then `DriftError`, then (for `check` only)
65
+ * `StaleError`, each carrying exit `1` — when the report says the run
66
+ * refused to write or, under `check`, that a build would write. `SchemaFile` is built
67
+ * here over the environment's `FileSystem`; the validator is
68
+ * `deps.validator` or the real engine.
69
+ *
70
+ * @public
71
+ */
72
+ const execute = Effect.fn("schemastore.execute")(function* (mode, input, deps) {
73
+ const loaded = yield* ConfigLoader.load({
74
+ cwd: deps.cwd,
75
+ ...Option.isSome(input.config) ? { explicit: input.config.value } : {},
76
+ ...deps.importModule !== void 0 ? { importModule: deps.importModule } : {}
77
+ });
78
+ const drift = effectiveDrift(loaded.config.drift, input);
79
+ if (input.force) yield* Effect.logWarning(`--force: drift policy is allow for this run; a published document ${mode === "check" ? "would be" : "may be"} rewritten in place, which breaks every consumer pinned to its URL.`);
80
+ const report = yield* Runner.run(loaded.config, {
81
+ mode,
82
+ configPath: loaded.path,
83
+ drift
84
+ }).pipe(Effect.provide(SchemaFile.layer), Effect.provide(deps.validator ?? SchemaValidator.layer));
85
+ yield* emit(report, input.format);
86
+ yield* StepSummary.append(Report.markdown(report));
87
+ if (report.gateFailed) {
88
+ const count = report.schemas.filter((schema) => schema.outcome === "gate-failed").length;
89
+ return yield* Effect.fail(reported(new GateError({ count }), 1));
90
+ }
91
+ if (report.drifted && drift.onDrift === "error") {
92
+ const count = report.schemas.filter((schema) => schema.verdict === "drift").length;
93
+ return yield* Effect.fail(reported(new DriftError({ count }), 1));
94
+ }
95
+ if (mode === "check") {
96
+ const count = report.schemas.filter((schema) => schema.outcome === "would-write").length + report.catalog.filter((entry) => entry.outcome === "would-write").length;
97
+ if (count > 0) return yield* Effect.fail(reported(new StaleError({ count }), 1));
98
+ }
99
+ });
100
+
101
+ //#endregion
102
+ export { DriftError, GateError, StaleError, execute };
package/cli/flags.js ADDED
@@ -0,0 +1,53 @@
1
+ import { Argument, Flag } from "effect/unstable/cli";
2
+
3
+ //#region src/cli/flags.ts
4
+ /**
5
+ * The optional positional config path; omitted, the config is discovered
6
+ * upward from the working directory.
7
+ *
8
+ * @public
9
+ */
10
+ const configArgument = Argument.String("config").pipe(Argument.withDescription("Path to a schemastore config module; omitted, schemastore.config.{ts,mts,js,mjs} is searched upward from the working directory"), Argument.optional);
11
+ /**
12
+ * `--drift`: the tolerance for published schemas, overriding the config.
13
+ *
14
+ * @public
15
+ */
16
+ const driftFlag = Flag.Literals("drift", [
17
+ "strict",
18
+ "semantic",
19
+ "allow"
20
+ ]).pipe(Flag.withDescription("Drift tolerance for published schemas; overrides the config's drift.policy"), Flag.optional);
21
+ /**
22
+ * `--on-drift`: what drift does, overriding the config.
23
+ *
24
+ * @public
25
+ */
26
+ const onDriftFlag = Flag.Literals("on-drift", ["error", "warn"]).pipe(Flag.withDescription("What drift does: refuse every write (error) or write and warn; overrides drift.onDrift"), Flag.optional);
27
+ /**
28
+ * `--force`: shorthand for `--drift=allow`.
29
+ *
30
+ * @public
31
+ */
32
+ const forceFlag = Flag.Boolean("force").pipe(Flag.withDefault(false), Flag.withDescription("Shorthand for --drift=allow"));
33
+ /**
34
+ * `--format`: `human` (default) or `json`.
35
+ *
36
+ * @public
37
+ */
38
+ const formatFlag = Flag.Literals("format", ["human", "json"]).pipe(Flag.withDefault("human"), Flag.withDescription("Output format; json writes one document to stdout and moves human text to stderr"));
39
+ /**
40
+ * The config both commands take.
41
+ *
42
+ * @public
43
+ */
44
+ const commandFlags = {
45
+ config: configArgument,
46
+ drift: driftFlag,
47
+ onDrift: onDriftFlag,
48
+ force: forceFlag,
49
+ format: formatFlag
50
+ };
51
+
52
+ //#endregion
53
+ export { commandFlags, configArgument, driftFlag, forceFlag, formatFlag, onDriftFlag };
package/cli/program.js ADDED
@@ -0,0 +1,42 @@
1
+ import { ConfigLoadError } from "../ConfigLoader.js";
2
+ import { makeCommands } from "./root.js";
3
+ import { CliLogger, CliRuntime } from "@effected/cli";
4
+ import { Effect } from "effect";
5
+ import { Command } from "effect/unstable/cli";
6
+
7
+ //#region src/cli/program.ts
8
+ /**
9
+ * The logger the program writes through: stdout is `Console.log` only,
10
+ * so EVERY log level is routed to stderr — warnings, the human lines under
11
+ * `--format=json`, and the rendered failure.
12
+ *
13
+ * @public
14
+ */
15
+ const loggerLayer = CliLogger.layer({ stderrFrom: "All" });
16
+ const trimLoadError = Effect.fn("schemastore.trimLoadError")(function* (error) {
17
+ const [first = "", ...rest] = error.reason.split("\n");
18
+ if (rest.length > 0) yield* Effect.logDebug(rest.join("\n"));
19
+ return new ConfigLoadError({
20
+ path: error.path,
21
+ reason: first
22
+ });
23
+ });
24
+ /**
25
+ * Parse `args` and run the selected command.
26
+ *
27
+ * @remarks
28
+ * Fails with the marked error the runtime maps to the exit code:
29
+ * `ShowHelp` is `64` with parse errors and `0` without (help itself was
30
+ * already rendered); `ConfigNotFoundError` / `ConfigLoadError` are `2`;
31
+ * `DriftError` / `GateError` / `StaleError` arrive already marked `1`.
32
+ *
33
+ * @public
34
+ */
35
+ const program = (args, deps) => Command.runWith(makeCommands(deps).root, { version: deps.version })(args).pipe(Effect.catchTags({
36
+ ShowHelp: (help) => Effect.fail(CliRuntime.reported(help, help.errors.length > 0 ? 64 : 0)),
37
+ ConfigNotFoundError: (error) => Effect.fail(CliRuntime.reported(error, 2)),
38
+ ConfigLoadError: (error) => trimLoadError(error).pipe(Effect.flatMap((trimmed) => Effect.fail(CliRuntime.reported(trimmed, 2))))
39
+ }));
40
+
41
+ //#endregion
42
+ export { loggerLayer, program };
package/cli/root.js ADDED
@@ -0,0 +1,23 @@
1
+ import { makeBuildCommand } from "./commands/build.js";
2
+ import { makeCheckCommand } from "./commands/check.js";
3
+ import { Command } from "effect/unstable/cli";
4
+
5
+ //#region src/cli/root.ts
6
+ /**
7
+ * The command tree, closed over the process boundary it runs against:
8
+ * `main.ts` hands in the process values, tests hand in their own.
9
+ *
10
+ * @public
11
+ */
12
+ const makeCommands = (deps) => {
13
+ const build = makeBuildCommand(deps);
14
+ const check = makeCheckCommand(deps);
15
+ return {
16
+ root: Command.make("schemastore", {}).pipe(Command.withDescription("Build and check SchemaStore-shaped JSON Schema documents from a schemastore.config.ts"), Command.withSubcommands([build, check])),
17
+ build,
18
+ check
19
+ };
20
+ };
21
+
22
+ //#endregion
23
+ export { makeCommands };
package/main.js ADDED
@@ -0,0 +1,27 @@
1
+ import { loggerLayer, program } from "./cli/program.js";
2
+ import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
3
+ import * as NodeServices from "@effect/platform-node/NodeServices";
4
+ import { CliRuntime } from "@effected/cli";
5
+ import { Effect } from "effect";
6
+ import { CliError } from "effect/unstable/cli";
7
+
8
+ //#region src/main.ts
9
+ /**
10
+ * The assembled schemastore CLI program.
11
+ *
12
+ * @packageDocumentation
13
+ */
14
+ const render = (error) => CliError.isCliError(error) && error._tag === "ShowHelp" ? [] : [error instanceof Error ? error.message : String(error)];
15
+ const main = () => {
16
+ const run = program(process.argv.slice(2), {
17
+ cwd: process.cwd(),
18
+ version: "0.9.0"
19
+ }).pipe(Effect.provide(NodeServices.layer), CliRuntime.reportFailures({
20
+ exitCode: 3,
21
+ render
22
+ }), Effect.provide(loggerLayer));
23
+ NodeRuntime.runMain(run);
24
+ };
25
+
26
+ //#endregion
27
+ export { main };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@effected/schemastore-cli",
3
+ "version": "0.9.0",
4
+ "private": false,
5
+ "description": "The schemastore command: build and check SchemaStore-shaped JSON Schema documents from a schemastore.config.ts, with a per-schema published flag and a drift policy.",
6
+ "keywords": [
7
+ "schemastore",
8
+ "json-schema",
9
+ "draft-07",
10
+ "cli",
11
+ "effect",
12
+ "effected"
13
+ ],
14
+ "homepage": "https://github.com/spencerbeggs/effected/tree/main/packages/schemastore-cli#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/spencerbeggs/effected/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/spencerbeggs/effected.git",
21
+ "directory": "packages/schemastore-cli"
22
+ },
23
+ "license": "MIT",
24
+ "author": {
25
+ "name": "C. Spencer Beggs",
26
+ "email": "spencer@beggs.codes",
27
+ "url": "https://spencerbeg.gs"
28
+ },
29
+ "sideEffects": false,
30
+ "type": "module",
31
+ "exports": {
32
+ "./package.json": "./package.json"
33
+ },
34
+ "bin": {
35
+ "schemastore": "bin/schemastore.js"
36
+ },
37
+ "dependencies": {
38
+ "@effect/platform-node": "4.0.0-rc.115",
39
+ "@effected/cli": "^0.4.1",
40
+ "jiti": "^2.6.0"
41
+ },
42
+ "peerDependencies": {
43
+ "@effected/schemastore": "0.9.0",
44
+ "effect": "4.0.0-rc.115"
45
+ },
46
+ "engines": {
47
+ "node": ">=24.11.0"
48
+ }
49
+ }