@okfit/cli 0.1.0 → 0.2.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 +332 -3
- package/bin/okfit.js +47 -4
- package/commands/context.js +78 -0
- package/commands/init.js +177 -0
- package/commands/root.js +26 -3
- package/commands/sync.js +111 -0
- package/commands/validate.js +98 -0
- package/commands/verify.js +98 -0
- package/config/anchor.js +57 -0
- package/config/layer.js +129 -0
- package/config/resolve.js +67 -0
- package/context/run.js +35 -0
- package/errors.js +173 -0
- package/index.d.ts +891 -6
- package/index.js +15 -10
- package/init/scaffold.js +174 -0
- package/internal/exit.js +14 -0
- package/internal/tty.js +13 -0
- package/package.js +5 -0
- package/package.json +17 -5
- package/render/context.js +108 -0
- package/render/exit.js +53 -0
- package/render/human.js +68 -0
- package/render/json.js +126 -0
- package/render/sort.js +46 -0
- package/render/sync.js +101 -0
- package/render/verify.js +72 -0
- package/sync/generated.js +111 -0
- package/sync/index.js +73 -0
- package/sync/log.js +127 -0
- package/sync/run.js +65 -0
- package/sync/write.js +48 -0
- package/validate/run.js +63 -0
- package/verify/locate.js +249 -0
- package/verify/run.js +106 -0
- package/verify/splice.js +75 -0
- package/version.js +14 -0
package/commands/root.js
CHANGED
|
@@ -1,13 +1,36 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { contextCommand } from "./context.js";
|
|
2
|
+
import { initCommand } from "./init.js";
|
|
3
|
+
import { syncCommand } from "./sync.js";
|
|
4
|
+
import { validateCommand } from "./validate.js";
|
|
5
|
+
import { verifyCommand } from "./verify.js";
|
|
2
6
|
import { Command } from "effect/unstable/cli";
|
|
3
7
|
|
|
4
8
|
//#region src/commands/root.ts
|
|
5
9
|
/**
|
|
6
|
-
*
|
|
10
|
+
* K-5. No handler: the framework's own default for a command with neither a
|
|
11
|
+
* `handle` nor a matched subcommand is
|
|
12
|
+
* `Effect.fail(new CliError.ShowHelp({ commandPath, errors: [] }))`, and a
|
|
13
|
+
* `ShowHelp` with no errors carries `[Runtime.errorExitCode] = 0`. Bare
|
|
14
|
+
* `okfit` therefore prints the root help and exits `0` with no code in this
|
|
15
|
+
* package at all.
|
|
16
|
+
*
|
|
17
|
+
* `validate`, `init`, `context`, `verify`, and `sync` are the whole command tree;
|
|
18
|
+
* nothing else is registered here. Each is appended in introduction order,
|
|
19
|
+
* never reordered in, so `--help`'s subcommand list reads that way too
|
|
20
|
+
* (contract §4.2). The top-level description is left unchanged: neither
|
|
21
|
+
* `context` nor `verify` validates or scaffolds, but widening the sentence
|
|
22
|
+
* for the orientation- and attestation-only commands buys nothing
|
|
23
|
+
* (contract §4.2).
|
|
7
24
|
*
|
|
8
25
|
* @public
|
|
9
26
|
*/
|
|
10
|
-
const rootCommand = Command.make("okfit", {}
|
|
27
|
+
const rootCommand = Command.make("okfit", {}).pipe(Command.withDescription("Open Knowledge Format (OKF) v0.2 tooling: validate and scaffold bundles."), Command.withSubcommands([
|
|
28
|
+
validateCommand,
|
|
29
|
+
initCommand,
|
|
30
|
+
contextCommand,
|
|
31
|
+
verifyCommand,
|
|
32
|
+
syncCommand
|
|
33
|
+
]));
|
|
11
34
|
|
|
12
35
|
//#endregion
|
|
13
36
|
export { rootCommand };
|
package/commands/sync.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { provideConfig } from "../config/layer.js";
|
|
2
|
+
import { resolveProjectConfig } from "../config/resolve.js";
|
|
3
|
+
import { setExitCode } from "../internal/exit.js";
|
|
4
|
+
import { jsonError } from "../render/json.js";
|
|
5
|
+
import { CLI_VERSION } from "../version.js";
|
|
6
|
+
import { displayRoot } from "../render/human.js";
|
|
7
|
+
import { runSync } from "../sync/run.js";
|
|
8
|
+
import { SyncEnvelope, humanSync, syncEnvelope } from "../render/sync.js";
|
|
9
|
+
import { Argument, Command, Flag } from "effect/unstable/cli";
|
|
10
|
+
import { Console, Effect, Layer, Option, Path, Schema } from "effect";
|
|
11
|
+
import { GitHistory } from "@okfit/profiles";
|
|
12
|
+
import { Git } from "@effected/git";
|
|
13
|
+
|
|
14
|
+
//#region src/commands/sync.ts
|
|
15
|
+
/** K-2: `[path]` is the PROJECT root, byte-identical to validate/init/context/verify's. */
|
|
16
|
+
const pathArg = Argument.path("path", { pathType: "directory" }).pipe(Argument.optional, Argument.withDescription("project root to start config discovery from (default: current directory)"));
|
|
17
|
+
/** K-1: no `mustExist` — the handler stats it via `provideConfig`. */
|
|
18
|
+
const configFlag = Flag.file("config").pipe(Flag.optional, Flag.withDescription("explicit config file; skips discovery"));
|
|
19
|
+
/**
|
|
20
|
+
* S-13: a REPEATED `Flag.choice`, not a comma-separated `Flag.string`.
|
|
21
|
+
* `Flag.atLeast(0)` allows zero occurrences (all three modes run) up
|
|
22
|
+
* through any number. Each occurrence is independently validated by the
|
|
23
|
+
* underlying `Choice` primitive, so a bad token (`--only badmode`) is a
|
|
24
|
+
* genuine parse-time `CliError.InvalidValue` → `ShowHelp` → `bin.ts`'s
|
|
25
|
+
* existing 64 remap — no new error class, no hand-built `ShowHelp`
|
|
26
|
+
* (contract §11, S-13, judge note 9).
|
|
27
|
+
*
|
|
28
|
+
* Note: `Flag.choice`'s installed signature (`effect/unstable/cli/Flag.d.ts:161`)
|
|
29
|
+
* takes `(name: string, choices: ReadonlyArray<string>)`, matching
|
|
30
|
+
* `formatFlag` below — not the `[value, label]` tuple-pair form
|
|
31
|
+
* `Flag.choiceWithValue` takes. The contract's own `onlyFlag` sketch
|
|
32
|
+
* writes tuple pairs; since every pair's two elements are identical
|
|
33
|
+
* (`["generated", "generated"]`, etc.), the plain string-array form
|
|
34
|
+
* below is the same flag, verified against the installed primitive
|
|
35
|
+
* rather than copied byte-for-byte from the contract's prose.
|
|
36
|
+
*/
|
|
37
|
+
const onlyFlag = Flag.choice("only", [
|
|
38
|
+
"generated",
|
|
39
|
+
"index",
|
|
40
|
+
"log"
|
|
41
|
+
]).pipe(Flag.atLeast(0), Flag.withDescription("restrict the run to these modes (repeatable: --only generated --only index); default: all three"));
|
|
42
|
+
const dryRunFlag = Flag.boolean("dry-run").pipe(Flag.withDefault(false), Flag.withDescription("compute every result and write nothing"));
|
|
43
|
+
const formatFlag = Flag.choice("format", ["human", "json"]).pipe(Flag.withDefault("human"), Flag.withDescription("output format: human (default) or json"));
|
|
44
|
+
/**
|
|
45
|
+
* `okfit sync [path] [--config <file>] [--only <mode>]... [--dry-run]
|
|
46
|
+
* [--format human|json]`.
|
|
47
|
+
*
|
|
48
|
+
* Handler order fixed by contract §4.3. Steps 1–3 are `context`'s/
|
|
49
|
+
* `validate`'s handler in substance — stat `--config` (K-1) via
|
|
50
|
+
* `provideConfig`, resolve the project and bundle roots through
|
|
51
|
+
* `resolveProjectConfig` — then it diverges: build the `--only` mode
|
|
52
|
+
* set (default: all three, order irrelevant — `runSync`'s own fixed
|
|
53
|
+
* order wins, not `--only`'s occurrence order), run `runSync` with BOTH
|
|
54
|
+
* `Git.layer` and `GitHistory.layer` provided (S-16, mirroring
|
|
55
|
+
* `verify.ts:134`'s `Git.layer`-alone provision one layer up: here two
|
|
56
|
+
* layers are needed because `GitHistory.layer` does not re-expose `Git`
|
|
57
|
+
* even though it is built on it), render, and always exit `0`. There is
|
|
58
|
+
* no content tier: every typed failure is exit `3` through `bin.ts`'s
|
|
59
|
+
* existing `reportFailures`; an unknown `--only` token never reaches
|
|
60
|
+
* this handler at all — it fails at parse time, exit `64`.
|
|
61
|
+
*
|
|
62
|
+
* @public
|
|
63
|
+
*/
|
|
64
|
+
const syncCommand = Command.make("sync", {
|
|
65
|
+
path: pathArg,
|
|
66
|
+
config: configFlag,
|
|
67
|
+
only: onlyFlag,
|
|
68
|
+
dryRun: dryRunFlag,
|
|
69
|
+
format: formatFlag
|
|
70
|
+
}, (input) => Effect.gen(function* () {
|
|
71
|
+
const cwd = process.cwd();
|
|
72
|
+
const discoveryCwd = Option.getOrElse(input.path, () => cwd);
|
|
73
|
+
const body = Effect.gen(function* () {
|
|
74
|
+
const path = yield* Path.Path;
|
|
75
|
+
const resolved = yield* resolveProjectConfig({
|
|
76
|
+
pathArg: input.path,
|
|
77
|
+
explicitConfigPath: input.config,
|
|
78
|
+
cwd
|
|
79
|
+
});
|
|
80
|
+
const modes = input.only.length === 0 ? /* @__PURE__ */ new Set([
|
|
81
|
+
"generated",
|
|
82
|
+
"index",
|
|
83
|
+
"log"
|
|
84
|
+
]) : new Set(input.only);
|
|
85
|
+
const result = yield* runSync({
|
|
86
|
+
bundleRoot: resolved.bundleRoot,
|
|
87
|
+
config: resolved.config,
|
|
88
|
+
modes,
|
|
89
|
+
dryRun: input.dryRun
|
|
90
|
+
}).pipe(Effect.provide(Layer.mergeAll(Git.layer, GitHistory.layer)));
|
|
91
|
+
const displayPath = displayRoot(cwd, result.bundleRoot, path);
|
|
92
|
+
if (input.format === "json") {
|
|
93
|
+
const envelope = syncEnvelope({
|
|
94
|
+
okfitVersion: CLI_VERSION,
|
|
95
|
+
root: displayPath,
|
|
96
|
+
dryRun: result.dryRun,
|
|
97
|
+
result
|
|
98
|
+
});
|
|
99
|
+
yield* Console.log(JSON.stringify(Schema.encodeSync(SyncEnvelope)(envelope)));
|
|
100
|
+
} else for (const line of humanSync(result)) yield* Console.log(line);
|
|
101
|
+
setExitCode(0);
|
|
102
|
+
}).pipe(provideConfig({
|
|
103
|
+
explicitConfigPath: input.config,
|
|
104
|
+
discoveryCwd
|
|
105
|
+
}));
|
|
106
|
+
if (input.format === "json") return yield* body.pipe(Effect.tapError((error) => Console.log(JSON.stringify(jsonError(error, CLI_VERSION)))));
|
|
107
|
+
return yield* body;
|
|
108
|
+
})).pipe(Command.withDescription("Regenerate the derived-content families that no other command produces: generated.at, index.md, and log.md, all from git history and the bundle's own concepts. Mechanical and agent-runnable; never an attestation and never touches verified."));
|
|
109
|
+
|
|
110
|
+
//#endregion
|
|
111
|
+
export { syncCommand };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { provideConfig } from "../config/layer.js";
|
|
2
|
+
import { resolveProjectConfig } from "../config/resolve.js";
|
|
3
|
+
import { setExitCode } from "../internal/exit.js";
|
|
4
|
+
import { forDiagnostics } from "../render/exit.js";
|
|
5
|
+
import { collect } from "../render/sort.js";
|
|
6
|
+
import { JsonEnvelope, json, jsonError } from "../render/json.js";
|
|
7
|
+
import { CLI_VERSION } from "../version.js";
|
|
8
|
+
import { useColor } from "../internal/tty.js";
|
|
9
|
+
import { displayRoot, human, summary } from "../render/human.js";
|
|
10
|
+
import { Now, run } from "../validate/run.js";
|
|
11
|
+
import { Argument, Command, Flag } from "effect/unstable/cli";
|
|
12
|
+
import { Console, Effect, Layer, Option, Path, Schema } from "effect";
|
|
13
|
+
import { OKF_SPEC_VERSION } from "@okfit/core";
|
|
14
|
+
import { GitHistory } from "@okfit/profiles";
|
|
15
|
+
import { Git } from "@effected/git";
|
|
16
|
+
|
|
17
|
+
//#region src/commands/validate.ts
|
|
18
|
+
/** `[path]` is the PROJECT root (K-2), never the bundle root. Absolute at parse time (K-50). */
|
|
19
|
+
const pathArg = Argument.path("path", { pathType: "directory" }).pipe(Argument.optional, Argument.withDescription("project root to start config discovery from (default: current directory); never the bundle root"));
|
|
20
|
+
/** K-1: no `mustExist` — the handler stats the path itself, before building any layer. */
|
|
21
|
+
const configFlag = Flag.file("config").pipe(Flag.optional, Flag.withDescription("explicit config file; skips discovery"));
|
|
22
|
+
/** K-6: `--format` is on `validate` only. */
|
|
23
|
+
const formatFlag = Flag.choice("format", ["human", "json"]).pipe(Flag.withDefault("human"), Flag.withDescription("output format: human (default) or json"));
|
|
24
|
+
/** S-31: skips `Provenance.lint`'s git tier for this invocation, without touching `[lint]`. */
|
|
25
|
+
const skipProvenanceFlag = Flag.boolean("skip-provenance").pipe(Flag.withDefault(false), Flag.withDescription("skip the generated-at-drift lint's git tier for this run"));
|
|
26
|
+
/**
|
|
27
|
+
* `okfit validate [path] [--config <file>] [--format human|json] [--skip-provenance]`.
|
|
28
|
+
*
|
|
29
|
+
* Handler order fixed by the contract (§2 `src/commands/validate.ts`):
|
|
30
|
+
* stat `--config` (K-1) via `provideConfig`, discover (`OkfitConfigFile.discover`),
|
|
31
|
+
* resolve the profile with the K-4 warning, merge `DEFAULTS < profile < file`
|
|
32
|
+
* (D-28), warn on an `okf_version` mismatch (K-15), resolve the project and
|
|
33
|
+
* bundle roots (K-12), run (`validate/run.ts#run`), collect and sort the
|
|
34
|
+
* diagnostics, render per `--format` (K-18, K-20 to K-22), and set the exit
|
|
35
|
+
* code without failing the Effect (K-7, K-8).
|
|
36
|
+
*
|
|
37
|
+
* @public
|
|
38
|
+
*/
|
|
39
|
+
const validateCommand = Command.make("validate", {
|
|
40
|
+
path: pathArg,
|
|
41
|
+
config: configFlag,
|
|
42
|
+
format: formatFlag,
|
|
43
|
+
skipProvenance: skipProvenanceFlag
|
|
44
|
+
}, (input) => Effect.gen(function* () {
|
|
45
|
+
const cwd = process.cwd();
|
|
46
|
+
const discoveryCwd = Option.getOrElse(input.path, () => cwd);
|
|
47
|
+
const now = yield* Now;
|
|
48
|
+
const path = yield* Path.Path;
|
|
49
|
+
const body = Effect.gen(function* () {
|
|
50
|
+
const { bundleRoot, config: merged, profile } = yield* resolveProjectConfig({
|
|
51
|
+
pathArg: input.path,
|
|
52
|
+
explicitConfigPath: input.config,
|
|
53
|
+
cwd
|
|
54
|
+
});
|
|
55
|
+
const result = yield* run({
|
|
56
|
+
root: bundleRoot,
|
|
57
|
+
config: merged,
|
|
58
|
+
profile,
|
|
59
|
+
now,
|
|
60
|
+
skipProvenance: input.skipProvenance
|
|
61
|
+
}).pipe(Effect.provide(Layer.mergeAll(Git.layer, GitHistory.layer)));
|
|
62
|
+
const diagnostics = collect(result.report.conformance, result.report.lint, result.profileDiagnostics);
|
|
63
|
+
const code = forDiagnostics(diagnostics);
|
|
64
|
+
if (input.format === "json") {
|
|
65
|
+
const envelope = json({
|
|
66
|
+
okfitVersion: CLI_VERSION,
|
|
67
|
+
okfVersion: merged.okf_version ?? OKF_SPEC_VERSION,
|
|
68
|
+
root: bundleRoot,
|
|
69
|
+
profile: Option.match(profile, {
|
|
70
|
+
onNone: () => null,
|
|
71
|
+
onSome: (p) => p.name
|
|
72
|
+
}),
|
|
73
|
+
exitCode: code,
|
|
74
|
+
concepts: result.bundle.concepts.size,
|
|
75
|
+
diagnostics
|
|
76
|
+
});
|
|
77
|
+
yield* Console.log(JSON.stringify(Schema.encodeSync(JsonEnvelope)(envelope)));
|
|
78
|
+
} else {
|
|
79
|
+
for (const diagnosticLine of human(diagnostics, { color: useColor() })) yield* Console.log(diagnosticLine);
|
|
80
|
+
const counts = {
|
|
81
|
+
errors: diagnostics.filter((d) => d.severity === "error").length,
|
|
82
|
+
warnings: diagnostics.filter((d) => d.severity === "warning").length,
|
|
83
|
+
info: diagnostics.filter((d) => d.severity === "info").length,
|
|
84
|
+
concepts: result.bundle.concepts.size
|
|
85
|
+
};
|
|
86
|
+
yield* Console.error(summary(counts, displayRoot(cwd, bundleRoot, path)));
|
|
87
|
+
}
|
|
88
|
+
setExitCode(code);
|
|
89
|
+
}).pipe(provideConfig({
|
|
90
|
+
explicitConfigPath: input.config,
|
|
91
|
+
discoveryCwd
|
|
92
|
+
}));
|
|
93
|
+
if (input.format === "json") return yield* body.pipe(Effect.tapError((error) => Console.log(JSON.stringify(jsonError(error, CLI_VERSION)))));
|
|
94
|
+
return yield* body;
|
|
95
|
+
})).pipe(Command.withDescription("Load config and bundle, run conformance and lint checks, and render the diagnostics."));
|
|
96
|
+
|
|
97
|
+
//#endregion
|
|
98
|
+
export { validateCommand };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { provideConfig } from "../config/layer.js";
|
|
2
|
+
import { resolveProjectConfig } from "../config/resolve.js";
|
|
3
|
+
import { setExitCode } from "../internal/exit.js";
|
|
4
|
+
import { jsonError } from "../render/json.js";
|
|
5
|
+
import { CLI_VERSION } from "../version.js";
|
|
6
|
+
import { displayRoot } from "../render/human.js";
|
|
7
|
+
import { Now } from "../validate/run.js";
|
|
8
|
+
import { VerifyEnvelope, humanVerify, verifyEnvelope } from "../render/verify.js";
|
|
9
|
+
import { runVerify } from "../verify/run.js";
|
|
10
|
+
import { Argument, Command, Flag } from "effect/unstable/cli";
|
|
11
|
+
import { Console, DateTime, Effect, Option, Path, Schema } from "effect";
|
|
12
|
+
import { Timestamp } from "@okfit/core";
|
|
13
|
+
import { Git } from "@effected/git";
|
|
14
|
+
|
|
15
|
+
//#region src/commands/verify.ts
|
|
16
|
+
/** V-6: tolerant id, normalised through `ConceptId.normalize`; never `Argument.path`. */
|
|
17
|
+
const idArg = Argument.string("id").pipe(Argument.withDescription("concept id to verify, with or without a leading slash or trailing .md"));
|
|
18
|
+
/** K-2: `[path]` is the PROJECT root, byte-identical to validate/init/context's. */
|
|
19
|
+
const pathArg = Argument.path("path", { pathType: "directory" }).pipe(Argument.optional, Argument.withDescription("project root to start config discovery from (default: current directory); never the bundle root"));
|
|
20
|
+
/** K-1: no `mustExist`; the handler stats it via `provideConfig`. */
|
|
21
|
+
const configFlag = Flag.file("config").pipe(Flag.optional, Flag.withDescription("explicit config file; skips discovery"));
|
|
22
|
+
/** V-6: `Flag.string`, decoded through core's `Timestamp` — never `Flag.date`, which yields a bare Date. */
|
|
23
|
+
const atFlag = Flag.string("at").pipe(Flag.optional, Flag.withDescription("ISO 8601 timestamp with an explicit offset to record instead of now"));
|
|
24
|
+
/** V-6/V-8: the preview is a flag, never a prompt. */
|
|
25
|
+
const dryRunFlag = Flag.boolean("dry-run").pipe(Flag.withDefault(false), Flag.withDescription("print the exact fragment a real run would splice in; write nothing"));
|
|
26
|
+
const formatFlag = Flag.choice("format", ["human", "json"]).pipe(Flag.withDefault("human"), Flag.withDescription("output format: human (default) or json"));
|
|
27
|
+
/**
|
|
28
|
+
* `okfit verify <id> [path] [--config <file>] [--at <iso>] [--dry-run]
|
|
29
|
+
* [--format human|json]`.
|
|
30
|
+
*
|
|
31
|
+
* Handler order fixed by contract §2.3. Steps 1–3 are `context`'s handler
|
|
32
|
+
* in substance — stat `--config` (K-1) via `provideConfig`, discover,
|
|
33
|
+
* resolve the profile and roots through `resolveProjectConfig` — then it
|
|
34
|
+
* diverges: resolve `at` (the `Now` service, or `--at` through
|
|
35
|
+
* `Schema.decodeUnknownEffect(Timestamp)`, never the throwing sync
|
|
36
|
+
* decoder), provide `Git` for `Derivation.generatedBy`, run the splice and
|
|
37
|
+
* the atomic write, render, and always exit `0`. There is no content tier:
|
|
38
|
+
* every typed failure is exit `3` through `bin.ts`'s existing
|
|
39
|
+
* `reportFailures`.
|
|
40
|
+
*
|
|
41
|
+
* @public
|
|
42
|
+
*/
|
|
43
|
+
const verifyCommand = Command.make("verify", {
|
|
44
|
+
id: idArg,
|
|
45
|
+
path: pathArg,
|
|
46
|
+
config: configFlag,
|
|
47
|
+
at: atFlag,
|
|
48
|
+
dryRun: dryRunFlag,
|
|
49
|
+
format: formatFlag
|
|
50
|
+
}, (input) => Effect.gen(function* () {
|
|
51
|
+
const cwd = process.cwd();
|
|
52
|
+
const discoveryCwd = Option.getOrElse(input.path, () => cwd);
|
|
53
|
+
const body = Effect.gen(function* () {
|
|
54
|
+
const path = yield* Path.Path;
|
|
55
|
+
const resolved = yield* resolveProjectConfig({
|
|
56
|
+
pathArg: input.path,
|
|
57
|
+
explicitConfigPath: input.config,
|
|
58
|
+
cwd
|
|
59
|
+
});
|
|
60
|
+
const at = Option.isNone(input.at) ? DateTime.startOf(yield* Now, "second") : yield* Schema.decodeUnknownEffect(Timestamp)(input.at.value);
|
|
61
|
+
const result = yield* runVerify({
|
|
62
|
+
id: input.id,
|
|
63
|
+
bundleRoot: resolved.bundleRoot,
|
|
64
|
+
projectRoot: resolved.projectRoot,
|
|
65
|
+
config: resolved.config,
|
|
66
|
+
at,
|
|
67
|
+
dryRun: input.dryRun
|
|
68
|
+
});
|
|
69
|
+
const displayPath = `${displayRoot(cwd, result.bundleRoot, path)}/${result.conceptPath}`;
|
|
70
|
+
if (input.format === "json") {
|
|
71
|
+
const envelope = verifyEnvelope({
|
|
72
|
+
okfitVersion: CLI_VERSION,
|
|
73
|
+
id: result.id,
|
|
74
|
+
path: displayPath,
|
|
75
|
+
by: result.by,
|
|
76
|
+
at: result.at,
|
|
77
|
+
dryRun: result.dryRun
|
|
78
|
+
});
|
|
79
|
+
yield* Console.log(JSON.stringify(Schema.encodeSync(VerifyEnvelope)(envelope)));
|
|
80
|
+
} else for (const line of humanVerify({
|
|
81
|
+
id: result.id,
|
|
82
|
+
by: result.by,
|
|
83
|
+
at: result.at,
|
|
84
|
+
priorAt: result.priorAt,
|
|
85
|
+
dryRun: result.dryRun,
|
|
86
|
+
fragment: result.fragment
|
|
87
|
+
})) yield* Console.log(line);
|
|
88
|
+
setExitCode(0);
|
|
89
|
+
}).pipe(Effect.provide(Git.layer), provideConfig({
|
|
90
|
+
explicitConfigPath: input.config,
|
|
91
|
+
discoveryCwd
|
|
92
|
+
}));
|
|
93
|
+
if (input.format === "json") return yield* body.pipe(Effect.tapError((error) => Console.log(JSON.stringify(jsonError(error, CLI_VERSION)))));
|
|
94
|
+
return yield* body;
|
|
95
|
+
})).pipe(Command.withDescription("Record a human's attestation that a concept has been reviewed: append one verified entry, { by: human:<id>, at: <now> }, to its frontmatter. The actor is always your own git identity; there is no --by. This is a human-run command: no agent, hook, or MCP tool ever invokes it."));
|
|
96
|
+
|
|
97
|
+
//#endregion
|
|
98
|
+
export { verifyCommand };
|
package/config/anchor.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
//#region src/config/anchor.ts
|
|
2
|
+
/**
|
|
3
|
+
* The explicit `--config` anchor rule (C-8): the parent of `.config` "if the
|
|
4
|
+
* file sits in a `.config` directory, else the file's directory", with no
|
|
5
|
+
* exception for a custom name. Upstream's `ConfigResolver.explicitPath`
|
|
6
|
+
* reports no `dir` in its `match` by design, so this stays a hand-rolled,
|
|
7
|
+
* basename-based rule rather than reading `ConfigMatch.dir` — the one
|
|
8
|
+
* remaining path-tail string match in the CLI outside `init/scaffold.ts`
|
|
9
|
+
* (C1-4).
|
|
10
|
+
*/
|
|
11
|
+
const anchorForExplicit = (configPath, path) => {
|
|
12
|
+
const parent = path.dirname(configPath);
|
|
13
|
+
return path.basename(parent) === ".config" ? path.dirname(parent) : parent;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* K-12's project root, as amended by K-58 and C1-2, in order:
|
|
17
|
+
*
|
|
18
|
+
* 1. `pathArg`, if given. `Argument.path` has already resolved it absolute.
|
|
19
|
+
* 2. otherwise, if `--config` was given: `anchorForExplicit` applied to that
|
|
20
|
+
* path.
|
|
21
|
+
* 3. otherwise, if a config was discovered by the `"project"` resolver
|
|
22
|
+
* (`ConfigResolver.upwardWalk`, named `"project"` in `layer.ts`) AND it
|
|
23
|
+
* reported a `dir`: that `dir` verbatim — it is already the ancestor
|
|
24
|
+
* `upwardWalk` anchored the match against (the parent of `.config` for a
|
|
25
|
+
* `.config/okfit.toml` candidate, the candidate's own directory
|
|
26
|
+
* otherwise), so no path-tail test is needed here any more.
|
|
27
|
+
* 4. otherwise `cwd` — this covers every other resolver name (`"xdg"`,
|
|
28
|
+
* `"native"`, `"system"`) as defence in depth, a `"project"` match with no
|
|
29
|
+
* `dir` (should not happen, since `upwardWalk` always reports one), and
|
|
30
|
+
* no discovery at all.
|
|
31
|
+
*
|
|
32
|
+
* The CLI never probes for `.git` (K-12), even though
|
|
33
|
+
* `ConfigResolver.gitRoot` exists.
|
|
34
|
+
*
|
|
35
|
+
* @public
|
|
36
|
+
*/
|
|
37
|
+
const resolveProjectRoot = (input) => {
|
|
38
|
+
if (input.pathArg._tag === "Some") return input.pathArg.value;
|
|
39
|
+
if (input.explicitConfigPath._tag === "Some") return anchorForExplicit(input.explicitConfigPath.value, input.path);
|
|
40
|
+
if (input.discovered._tag === "Some") {
|
|
41
|
+
const discovered = input.discovered.value;
|
|
42
|
+
if (discovered.resolver === "project" && discovered.dir !== void 0) return discovered.dir;
|
|
43
|
+
}
|
|
44
|
+
return input.cwd;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* `<project root>/<bundle.path>`, absolute (K-2). `config` is the MERGED
|
|
48
|
+
* config, so `bundle.path` is `OkfitConfig.DEFAULTS.bundle.path` (`"okf"`)
|
|
49
|
+
* unless a file or profile overrode it. The bundle root is never what
|
|
50
|
+
* `[path]` names.
|
|
51
|
+
*
|
|
52
|
+
* @public
|
|
53
|
+
*/
|
|
54
|
+
const resolveBundleRoot = (projectRoot, config, path) => path.resolve(projectRoot, config.bundle?.path ?? "okf");
|
|
55
|
+
|
|
56
|
+
//#endregion
|
|
57
|
+
export { resolveBundleRoot, resolveProjectRoot };
|
package/config/layer.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { ConfigMalformedError, ConfigPathNotFoundError } from "../errors.js";
|
|
2
|
+
import { Cause, Effect, FileSystem, Option, Result } from "effect";
|
|
3
|
+
import { AppConfig } from "@effected/app";
|
|
4
|
+
import { ConfigCodecError, ConfigResolver, ConfigValidationError, MergeStrategy, TomlCodec } from "@effected/config-file";
|
|
5
|
+
import { OkfitConfig, OkfitConfigFile } from "@okfit/core";
|
|
6
|
+
|
|
7
|
+
//#region src/config/layer.ts
|
|
8
|
+
/**
|
|
9
|
+
* The `OkfitConfigFile` layer for one invocation (K-9 to K-11, K-57, C-6),
|
|
10
|
+
* now one `AppConfig.layer(OkfitConfigFile, ...)` call in both branches; only
|
|
11
|
+
* the chain options vary:
|
|
12
|
+
*
|
|
13
|
+
* - `explicitConfigPath` is `Some`: `resolvers: [ConfigResolver.explicitPath(path)]`
|
|
14
|
+
* and `xdg: false` — K-10's "only that path" rule, so the chain is exactly
|
|
15
|
+
* one resolver and no `systemEtc` tier is added either.
|
|
16
|
+
* - `explicitConfigPath` is `None`: `resolvers: [ConfigResolver.upwardWalk({ filenames, ... })]`
|
|
17
|
+
* carrying C-1's three per-directory candidates (`.okfit.toml`,
|
|
18
|
+
* `okfit.toml`, `.config/okfit.toml`, directory-major so a child's later
|
|
19
|
+
* candidate beats a parent's earlier one — C-2's ascent to the filesystem
|
|
20
|
+
* root is `upwardWalk`'s own default with no `stopAt`), named `"project"`
|
|
21
|
+
* so `resolve.ts`/`anchor.ts` can identify it without string-matching a
|
|
22
|
+
* path tail. `systemEtc` is C-5's `/etc` tier, present unless
|
|
23
|
+
* `systemConfigDir` overrides its root (tests only — no production call
|
|
24
|
+
* site sets it). `xdg` and `native` stay on their defaults (C-4), which is
|
|
25
|
+
* what puts personal defaults at `$XDG_CONFIG_HOME/okfit/config.toml` and
|
|
26
|
+
* the native probe behind it.
|
|
27
|
+
*
|
|
28
|
+
* `filename: "config.toml"` is the same in both branches — it is the XDG/
|
|
29
|
+
* native/system tiers' filename, unrelated to the C-1 project names above.
|
|
30
|
+
* `defaultPath` is `AppConfig.layer`'s own `XdgConfig.savePath(filename)`, so
|
|
31
|
+
* `save`/`update` do not fail with `ConfigDefaultPathMissingError`; nothing
|
|
32
|
+
* here sets it explicitly any more.
|
|
33
|
+
*
|
|
34
|
+
* `discoveryCwd` is `[path]` when given, else `process.cwd()` (K-2), passed
|
|
35
|
+
* straight through as `upwardWalk`'s own `cwd` so nothing in this module
|
|
36
|
+
* reads the process.
|
|
37
|
+
*
|
|
38
|
+
* `App.layer`, `AppStore` and `AppCache` appear nowhere, so no
|
|
39
|
+
* `store.db`/`cache.db` is ever created (K-9).
|
|
40
|
+
*
|
|
41
|
+
* @public
|
|
42
|
+
*/
|
|
43
|
+
const buildConfigLayer = (options) => Option.isSome(options.explicitConfigPath) ? AppConfig.layer(OkfitConfigFile, {
|
|
44
|
+
filename: "config.toml",
|
|
45
|
+
schema: OkfitConfig,
|
|
46
|
+
codec: TomlCodec,
|
|
47
|
+
strategy: MergeStrategy.firstMatch(),
|
|
48
|
+
resolvers: [ConfigResolver.explicitPath(options.explicitConfigPath.value)],
|
|
49
|
+
xdg: false
|
|
50
|
+
}) : AppConfig.layer(OkfitConfigFile, {
|
|
51
|
+
filename: "config.toml",
|
|
52
|
+
schema: OkfitConfig,
|
|
53
|
+
codec: TomlCodec,
|
|
54
|
+
strategy: MergeStrategy.firstMatch(),
|
|
55
|
+
resolvers: [ConfigResolver.upwardWalk({
|
|
56
|
+
filenames: [
|
|
57
|
+
".okfit.toml",
|
|
58
|
+
"okfit.toml",
|
|
59
|
+
".config/okfit.toml"
|
|
60
|
+
],
|
|
61
|
+
cwd: options.discoveryCwd,
|
|
62
|
+
name: "project"
|
|
63
|
+
})],
|
|
64
|
+
systemEtc: options.systemConfigDir === void 0 ? true : { dir: options.systemConfigDir }
|
|
65
|
+
});
|
|
66
|
+
/**
|
|
67
|
+
* The K-1 pre-flight and the provide, in that order and in one place: stat
|
|
68
|
+
* `explicitConfigPath` with `FileSystem.exists` and fail with
|
|
69
|
+
* `ConfigPathNotFoundError` BEFORE `buildConfigLayer` is called at all. This
|
|
70
|
+
* is why the CLI does not use `Command.provide(cmd, (input) => layer)`,
|
|
71
|
+
* which would construct the layer first (Judge notes 9) — here the stat
|
|
72
|
+
* runs as the first step of one `Effect.gen`, and `buildConfigLayer` is only
|
|
73
|
+
* reached, and only then actually run, on the step after it.
|
|
74
|
+
*
|
|
75
|
+
* `FileSystem.FileSystem.exists` is `(path: string) => Effect.Effect<boolean, PlatformError>`
|
|
76
|
+
* (`EF/FileSystem.ts:143-145`), not infallible, so
|
|
77
|
+
* `PlatformError.PlatformError` joins the error channel here (decision 7):
|
|
78
|
+
* an unusual stat failure (e.g. a permission error on a parent directory)
|
|
79
|
+
* renders through `renderFailure`'s catch-all rule rather than being
|
|
80
|
+
* uncatchable by the type checker.
|
|
81
|
+
*
|
|
82
|
+
* K-46/K-63 fix: a `ConfigCodecError`/`ConfigValidationError` from
|
|
83
|
+
* `buildConfigLayer`'s provided layer is wrapped into `ConfigMalformedError`
|
|
84
|
+
* whenever the failing path is known, so `renderFailure` can name it:
|
|
85
|
+
*
|
|
86
|
+
* - `ConfigCodecError` now carries its own `path: string | undefined`
|
|
87
|
+
* (config-file 0.7.0, `ConfigFile.discover` re-raises with `path` attached
|
|
88
|
+
* at every site that fed the codec a path it resolved) — used when
|
|
89
|
+
* present, so a malformed file found during DISCOVERY (no `--config`) now
|
|
90
|
+
* also wraps into `ConfigMalformedError` naming the candidate that failed,
|
|
91
|
+
* closing the K-63 gap. It falls back to `explicitConfigPath` only when
|
|
92
|
+
* the library's own `path` is `undefined` and `--config` was given; only
|
|
93
|
+
* when neither is known does the cause pass through unwrapped.
|
|
94
|
+
* - `ConfigValidationError` carries its own `path: Option<string>` — used
|
|
95
|
+
* when present (either branch), falling back to `explicitConfigPath` when
|
|
96
|
+
* the library's own `path` is `None` and `--config` was given.
|
|
97
|
+
*
|
|
98
|
+
* @public
|
|
99
|
+
*/
|
|
100
|
+
const provideConfig = (options) => (effect) => Effect.gen(function* () {
|
|
101
|
+
if (Option.isSome(options.explicitConfigPath)) {
|
|
102
|
+
const path = options.explicitConfigPath.value;
|
|
103
|
+
if (!(yield* (yield* FileSystem.FileSystem).exists(path))) return yield* Effect.fail(new ConfigPathNotFoundError({ path }));
|
|
104
|
+
}
|
|
105
|
+
return yield* effect.pipe(Effect.provide(buildConfigLayer(options)), Effect.catchCause((cause) => {
|
|
106
|
+
const found = Cause.findFail(cause);
|
|
107
|
+
if (Result.isSuccess(found)) {
|
|
108
|
+
const error = found.success.error;
|
|
109
|
+
if (error instanceof ConfigCodecError) {
|
|
110
|
+
const path = error.path !== void 0 ? error.path : Option.isSome(options.explicitConfigPath) ? options.explicitConfigPath.value : void 0;
|
|
111
|
+
if (path !== void 0) return Effect.fail(new ConfigMalformedError({
|
|
112
|
+
path,
|
|
113
|
+
cause: error
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
if (error instanceof ConfigValidationError) {
|
|
117
|
+
const path = Option.isSome(options.explicitConfigPath) ? options.explicitConfigPath : error.path;
|
|
118
|
+
if (Option.isSome(path)) return Effect.fail(new ConfigMalformedError({
|
|
119
|
+
path: path.value,
|
|
120
|
+
cause: error
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return Effect.failCause(cause);
|
|
125
|
+
}));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
//#endregion
|
|
129
|
+
export { buildConfigLayer, provideConfig };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { resolveBundleRoot, resolveProjectRoot } from "./anchor.js";
|
|
2
|
+
import { Console, Effect, Option, Path } from "effect";
|
|
3
|
+
import { OKF_SPEC_VERSION, OkfitConfig, OkfitConfigFile } from "@okfit/core";
|
|
4
|
+
import { Profiles } from "@okfit/profiles";
|
|
5
|
+
|
|
6
|
+
//#region src/config/resolve.ts
|
|
7
|
+
/**
|
|
8
|
+
* `OkfitConfig.DEFAULTS.bundle.profile` is `"software-project"` at runtime
|
|
9
|
+
* (the frozen literal always sets it), but `bundle` and `profile` are both
|
|
10
|
+
* `optionalKey` in the schema, so the type checker sees `string | undefined`
|
|
11
|
+
* two levels deep. The `?? "software-project"` fallback here is unreachable
|
|
12
|
+
* in practice; it exists only to satisfy `noUncheckedIndexedAccess`-style
|
|
13
|
+
* strictness without a non-null assertion. Formerly duplicated identically
|
|
14
|
+
* in `commands/validate.ts` and `commands/context.ts`; this is its one home
|
|
15
|
+
* now that both commands delegate to {@link resolveProjectConfig}.
|
|
16
|
+
*
|
|
17
|
+
* @public
|
|
18
|
+
*/
|
|
19
|
+
const DEFAULT_PROFILE_NAME = OkfitConfig.DEFAULTS.bundle?.profile ?? "software-project";
|
|
20
|
+
/**
|
|
21
|
+
* The config-resolution step byte-identical between `validate` and
|
|
22
|
+
* `context`'s handlers (A2 review finding): discover, pick `sources[0]`,
|
|
23
|
+
* default `{ extensions: {} }`, resolve the profile name and the K-4
|
|
24
|
+
* unknown-profile warning, merge `DEFAULTS < profile < file` (D-28), warn on
|
|
25
|
+
* an `okf_version` mismatch (K-15), then resolve the project and bundle
|
|
26
|
+
* roots (K-12). Every message string and the merge order are unchanged from
|
|
27
|
+
* the two commands' former inline copies.
|
|
28
|
+
*
|
|
29
|
+
* @public
|
|
30
|
+
*/
|
|
31
|
+
const resolveProjectConfig = (input) => Effect.gen(function* () {
|
|
32
|
+
const path = yield* Path.Path;
|
|
33
|
+
const discoveredSource = (yield* (yield* OkfitConfigFile).discover)[0];
|
|
34
|
+
const fileConfig = discoveredSource === void 0 ? { extensions: {} } : discoveredSource.value;
|
|
35
|
+
const profileName = fileConfig.bundle?.profile ?? DEFAULT_PROFILE_NAME;
|
|
36
|
+
const profile = profileName === "none" ? Option.none() : Profiles.get(profileName);
|
|
37
|
+
if (profileName !== "none" && Option.isNone(profile)) yield* Console.error(`warning: unknown profile "${profileName}"; continuing with defaults`);
|
|
38
|
+
const base = Option.match(profile, {
|
|
39
|
+
onNone: () => OkfitConfig.DEFAULTS,
|
|
40
|
+
onSome: (p) => OkfitConfig.merge(OkfitConfig.DEFAULTS, p.config)
|
|
41
|
+
});
|
|
42
|
+
const merged = OkfitConfig.merge(base, fileConfig);
|
|
43
|
+
if (merged.okf_version !== void 0 && merged.okf_version !== OKF_SPEC_VERSION) yield* Console.error(`warning: okf_version "${merged.okf_version}" does not match this okfit's spec version "${OKF_SPEC_VERSION}"; continuing`);
|
|
44
|
+
const discovered = discoveredSource === void 0 ? Option.none() : Option.some({
|
|
45
|
+
path: discoveredSource.path,
|
|
46
|
+
resolver: discoveredSource.resolver,
|
|
47
|
+
...discoveredSource.match?.dir !== void 0 ? { dir: discoveredSource.match.dir } : {}
|
|
48
|
+
});
|
|
49
|
+
const projectRoot = resolveProjectRoot({
|
|
50
|
+
pathArg: input.pathArg,
|
|
51
|
+
explicitConfigPath: input.explicitConfigPath,
|
|
52
|
+
discovered,
|
|
53
|
+
cwd: input.cwd,
|
|
54
|
+
path
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
projectRoot,
|
|
58
|
+
bundleRoot: resolveBundleRoot(projectRoot, merged, path),
|
|
59
|
+
config: merged,
|
|
60
|
+
profile,
|
|
61
|
+
profileName,
|
|
62
|
+
discovered
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
//#endregion
|
|
67
|
+
export { DEFAULT_PROFILE_NAME, resolveProjectConfig };
|
package/context/run.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Effect, FileSystem, Path } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/context/run.ts
|
|
4
|
+
/**
|
|
5
|
+
* The whole of `context`'s filesystem work: join `index.md` onto the bundle
|
|
6
|
+
* root and stat it. Loading a bundle and running conformance/lint checks
|
|
7
|
+
* over it never happen here — that is what makes `context` cheap enough
|
|
8
|
+
* for a hook to run on every session start and every in-bundle write
|
|
9
|
+
* (M-15).
|
|
10
|
+
*
|
|
11
|
+
* `"index.md"` is a literal here, not read from the profile's layout: the
|
|
12
|
+
* spec fixes the reserved filename regardless of profile, and a
|
|
13
|
+
* profile-less merge (`profile = "none"`) has no layout to read from at
|
|
14
|
+
* all — `context` behaves identically with and without a profile.
|
|
15
|
+
*
|
|
16
|
+
* `exists` is `(path) => Effect.Effect<boolean, PlatformError>`, so an
|
|
17
|
+
* unreadable parent directory would otherwise fail the command; here it is
|
|
18
|
+
* absorbed to `false`, because "the hook could not stat index.md" and
|
|
19
|
+
* "index.md is not there" are the same fact from a consumer's point of
|
|
20
|
+
* view.
|
|
21
|
+
*
|
|
22
|
+
* @public
|
|
23
|
+
*/
|
|
24
|
+
const runContext = (options) => Effect.gen(function* () {
|
|
25
|
+
const path = yield* Path.Path;
|
|
26
|
+
const fs = yield* FileSystem.FileSystem;
|
|
27
|
+
const indexPath = path.join(options.bundleRoot, "index.md");
|
|
28
|
+
return {
|
|
29
|
+
indexPath,
|
|
30
|
+
indexExists: yield* fs.exists(indexPath).pipe(Effect.orElseSucceed(() => false))
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
//#endregion
|
|
35
|
+
export { runContext };
|