@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/commands/root.js CHANGED
@@ -1,13 +1,36 @@
1
- import { Console } from "effect";
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
- * The `okfit` root command. Subcommands are attached in later releases.
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", {}, () => Console.log("okfit: no subcommands are available yet. Run `okfit --help`."));
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 };
@@ -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 };
@@ -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 };
@@ -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 };