@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.
- package/ConfigLoader.js +160 -0
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/Report.js +140 -0
- package/Runner.js +125 -0
- package/StepSummary.js +31 -0
- package/bin/schemastore.js +8 -0
- package/cli/commands/build.js +15 -0
- package/cli/commands/check.js +16 -0
- package/cli/execute.js +102 -0
- package/cli/flags.js +53 -0
- package/cli/program.js +42 -0
- package/cli/root.js +23 -0
- package/main.js +27 -0
- package/package.json +49 -0
package/ConfigLoader.js
ADDED
|
@@ -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
|
+
[](https://www.npmjs.com/package/@effected/schemastore-cli)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](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,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
|
+
}
|