@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/README.md
CHANGED
|
@@ -1,12 +1,341 @@
|
|
|
1
1
|
# @okfit/cli
|
|
2
2
|
|
|
3
|
-
The `okfit` command line for [Open Knowledge Format (OKF)](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) bundles.
|
|
3
|
+
The `okfit` command line for [Open Knowledge Format (OKF)](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) v0.2 bundles: `okfit validate`, `okfit init`, `okfit context`, `okfit verify`, and `okfit sync`. The full subcommand list is `okf/interfaces/cli-commands.md`'s to keep, not this sentence's to count.
|
|
4
4
|
|
|
5
5
|
> **Part of the okfit kit.** Most users want **[@okfit/plugin](https://www.npmjs.com/package/@okfit/plugin)**, which pulls this package in automatically.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Usage
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
```text
|
|
10
|
+
okfit [--help] [--version]
|
|
11
|
+
okfit validate [path] [--config <file>] [--format human|json] [--skip-provenance] [--help]
|
|
12
|
+
okfit init [path] [--profile <name>] [--config <file>] [--help]
|
|
13
|
+
okfit context [path] [--config <file>] [--format human|json] [--help]
|
|
14
|
+
okfit verify <id> [path] [--config <file>] [--at <iso>] [--dry-run] [--format human|json] [--help]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`[path]` is the **project root** on every subcommand — the directory discovery
|
|
18
|
+
starts from, and, for `init`, where `.config/okfit.toml` is written.
|
|
19
|
+
It is never the bundle root; the bundle root is `<project root>/<bundle.path>`
|
|
20
|
+
(`okf` by default). Default `[path]` is the current directory.
|
|
21
|
+
|
|
22
|
+
### `okfit validate`
|
|
23
|
+
|
|
24
|
+
Loads the config, loads the bundle, runs conformance and lint checks against
|
|
25
|
+
it, runs the resolved profile's own checks, and renders every diagnostic.
|
|
26
|
+
|
|
27
|
+
```console
|
|
28
|
+
$ cd my-repo && okfit validate
|
|
29
|
+
0 errors, 0 warnings, 0 info in 1 concepts (okf)
|
|
30
|
+
$ echo $?
|
|
31
|
+
0
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
A run that finds diagnostics prints one line per diagnostic to stdout, then
|
|
35
|
+
the summary to stderr:
|
|
36
|
+
|
|
37
|
+
```console
|
|
38
|
+
$ okfit validate --config ./ci-config.toml
|
|
39
|
+
warning: unknown profile "legacy-project"; continuing with defaults
|
|
40
|
+
(bundle) warning config-unknown-key unknown top-level key "extra_section"
|
|
41
|
+
modules/router.md:12:1 error required-key-missing missing required key "description"
|
|
42
|
+
1 errors, 1 warnings, 0 info in 4 concepts (okf)
|
|
43
|
+
$ echo $?
|
|
44
|
+
1
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Each diagnostic line is `<file>:<line>:<col> <severity> <code> <message>`
|
|
48
|
+
(one-based line and column) when the diagnostic carries a range, or
|
|
49
|
+
`<file> <severity> <code> <message>` when it does not; a bundle-level
|
|
50
|
+
diagnostic (no file at all) prints `(bundle)` in place of `<file>`.
|
|
51
|
+
Diagnostics sort by file (so `(bundle)` leads), then range-less before
|
|
52
|
+
ranged, then by offset, then by code.
|
|
53
|
+
|
|
54
|
+
`--skip-provenance` skips the `generated-at-drift` lint's git tier
|
|
55
|
+
(`Provenance.lint`) for this one invocation, without touching the
|
|
56
|
+
project's `[lint]` table — the same effect as `generated_at_drift = "off"`
|
|
57
|
+
in config, scoped to a single run. The Claude Code plugin's PostToolUse
|
|
58
|
+
hook passes it on every edit-time `validate` call so a git spawn per
|
|
59
|
+
concept never runs on every keystroke-level edit; CI and the MCP
|
|
60
|
+
`validate_bundle` tool omit the flag and keep the lint.
|
|
61
|
+
|
|
62
|
+
### `okfit init`
|
|
63
|
+
|
|
64
|
+
Scaffolds a fresh OKF bundle: a thin `.config/okfit.toml`, the
|
|
65
|
+
bundle's root and per-directory `index.md` files, a `project.md` stub, and an
|
|
66
|
+
initial `log.md` entry — then self-validates the result and exits with
|
|
67
|
+
`validate`'s own exit code, so a scaffold that does not validate clean is a
|
|
68
|
+
defect.
|
|
69
|
+
|
|
70
|
+
```console
|
|
71
|
+
$ mkdir my-repo && cd my-repo && okfit init
|
|
72
|
+
Initialized okf with the software-project profile
|
|
73
|
+
0 errors, 0 warnings, 0 info in 1 concepts (okf)
|
|
74
|
+
$ echo $?
|
|
75
|
+
0
|
|
76
|
+
$ find . -type f | sort
|
|
77
|
+
./.config/okfit.toml
|
|
78
|
+
./okf/conventions/index.md
|
|
79
|
+
./okf/decisions/index.md
|
|
80
|
+
./okf/index.md
|
|
81
|
+
./okf/interfaces/index.md
|
|
82
|
+
./okf/log.md
|
|
83
|
+
./okf/modules/index.md
|
|
84
|
+
./okf/project.md
|
|
85
|
+
./okf/references/index.md
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`init` never overwrites. If any target path already exists, nothing is
|
|
89
|
+
written:
|
|
90
|
+
|
|
91
|
+
```console
|
|
92
|
+
$ okfit init
|
|
93
|
+
error: refusing to overwrite existing files:
|
|
94
|
+
.config/okfit.toml
|
|
95
|
+
okf/index.md
|
|
96
|
+
okf/log.md
|
|
97
|
+
okf/project.md
|
|
98
|
+
okf/modules/index.md
|
|
99
|
+
okf/decisions/index.md
|
|
100
|
+
okf/conventions/index.md
|
|
101
|
+
okf/interfaces/index.md
|
|
102
|
+
okf/references/index.md
|
|
103
|
+
Nothing was written.
|
|
104
|
+
$ echo $?
|
|
105
|
+
3
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`init` also refuses when any of the other two project-level names --
|
|
109
|
+
`.okfit.toml` or `okfit.toml` -- already exists in the target directory,
|
|
110
|
+
and names every colliding file in one error. An ancestor directory's config
|
|
111
|
+
is a legitimate discovery hit, not a collision, and is never probed.
|
|
112
|
+
|
|
113
|
+
`--profile <name>` picks the profile `init` scaffolds for (default: the
|
|
114
|
+
config's `bundle.profile`, itself defaulting to `software-project`); an
|
|
115
|
+
unrecognised name is a warning, not a failure — `init` continues with the
|
|
116
|
+
default profile.
|
|
117
|
+
|
|
118
|
+
### `okfit context`
|
|
119
|
+
|
|
120
|
+
Prints the resolved project root, bundle root, config path, profile, and
|
|
121
|
+
vocabulary (`types` and `tags` from the merged config) without loading the
|
|
122
|
+
bundle. Useful for a script or a Claude Code hook that needs to know where
|
|
123
|
+
the bundle lives before deciding whether to run `okfit validate`.
|
|
124
|
+
|
|
125
|
+
```console
|
|
126
|
+
$ okfit context
|
|
127
|
+
project root: /abs/path/to/my-repo
|
|
128
|
+
bundle root: /abs/path/to/my-repo/okf
|
|
129
|
+
config: (none)
|
|
130
|
+
profile: software-project
|
|
131
|
+
index.md: /abs/path/to/my-repo/okf/index.md (exists)
|
|
132
|
+
agent: (unset)
|
|
133
|
+
|
|
134
|
+
types:
|
|
135
|
+
Convention A rule contributors and agents must follow.
|
|
136
|
+
Decision A choice made, the alternatives rejected, and why.
|
|
137
|
+
Interface A contract others depend on.
|
|
138
|
+
Module A unit of code with an owner and a boundary.
|
|
139
|
+
Project The repository's root concept: its purpose, boundaries, and non-goals.
|
|
140
|
+
Reference Mirrored external material kept under the references directory.
|
|
141
|
+
|
|
142
|
+
tags:
|
|
143
|
+
architecture Concerns the shape of the system rather than one module.
|
|
144
|
+
performance Concerns speed, memory, or resource cost and the trade-offs made for them.
|
|
145
|
+
release Concerns how changes ship: versioning, changelogs, publishing, and tagging.
|
|
146
|
+
security Concerns trust boundaries, secrets, permissions, or attack surface.
|
|
147
|
+
testing Concerns how the system is verified: strategy, fixtures, and coverage policy.
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
All paths `humanContext` prints are absolute, exactly as the resolved envelope carries them
|
|
151
|
+
(never relativized to the current directory the way `validate`'s summary line is) — the
|
|
152
|
+
example above reflects that, not a shortened path for readability.
|
|
153
|
+
|
|
154
|
+
`--format json` uses its own envelope, `ContextEnvelope` (schema 1),
|
|
155
|
+
documented in `## --format json` below — never `validate`'s `JsonEnvelope`.
|
|
156
|
+
There is no `--profile` flag on `context`; that one belongs to `init`
|
|
157
|
+
alone. Config discovery, the `--config` pre-flight, and the K-4/K-15
|
|
158
|
+
warnings all behave exactly as `## Config discovery` describes below.
|
|
159
|
+
|
|
160
|
+
### `okfit verify`
|
|
161
|
+
|
|
162
|
+
Appends one attestation, `{ by: human:<id>, at: <now> }`, to a concept's
|
|
163
|
+
`verified` list and writes the file back. `<id>` is a concept id with or
|
|
164
|
+
without a leading slash or trailing `.md`. The actor is always your own git
|
|
165
|
+
identity, resolved from `user.name`/`user.email` and `[actors].humans`;
|
|
166
|
+
there is no `--by`. Existing entries are never touched or replaced — every
|
|
167
|
+
run appends, including a repeat by the same person. `--at <iso>` records a
|
|
168
|
+
different instant; `--dry-run` runs the same splice and prints the exact
|
|
169
|
+
fragment it would write, under a `would write:` line, without touching the
|
|
170
|
+
file. Exit `0` on success (a dry run included), `3` on any failure.
|
|
171
|
+
|
|
172
|
+
A read-only concept is overwritten anyway — the write goes through a temp
|
|
173
|
+
file and an atomic rename, and the target's mode is preserved on the
|
|
174
|
+
replacement, but the file is not skipped just because it is `chmod`-ed
|
|
175
|
+
read-only.
|
|
176
|
+
|
|
177
|
+
This is a human-run command: it records **your** attestation that you
|
|
178
|
+
reviewed the concept, so no agent, hook, or MCP tool ever invokes it.
|
|
179
|
+
|
|
180
|
+
## Config discovery
|
|
181
|
+
|
|
182
|
+
With no `--config` flag, `okfit` walks upward from `[path]` (default:
|
|
183
|
+
the current directory). In each directory it checks `<dir>/.okfit.toml`,
|
|
184
|
+
then `<dir>/okfit.toml`, then `<dir>/.config/okfit.toml` before moving
|
|
185
|
+
up one level, so a child directory's `okfit.toml` always beats a
|
|
186
|
+
parent's `.okfit.toml`. Past the project it falls back to
|
|
187
|
+
`$XDG_CONFIG_HOME/okfit/config.toml` (and `$XDG_CONFIG_DIRS`), then the
|
|
188
|
+
OS-native config directory
|
|
189
|
+
(`~/Library/Application Support/okfit/config.toml` on macOS,
|
|
190
|
+
`%APPDATA%\okfit\config.toml` on Windows), then `/etc/okfit/config.toml`
|
|
191
|
+
on Linux and macOS. First match wins; nothing merges across levels, and
|
|
192
|
+
`okfit` never probes for a `.git` directory.
|
|
193
|
+
|
|
194
|
+
The project root anchors on the matched file's own directory -- except
|
|
195
|
+
for `.config/okfit.toml`, which anchors on the parent of `.config`. An
|
|
196
|
+
XDG, native, system-tier or absent config anchors on the current
|
|
197
|
+
directory instead.
|
|
198
|
+
|
|
199
|
+
Because the upward walk never stops at `$HOME`, it reaches `$HOME` itself
|
|
200
|
+
before falling through to the XDG tier. `~/.config/okfit.toml` and
|
|
201
|
+
`~/okfit.toml` are therefore **project-tier** files, not the personal
|
|
202
|
+
defaults they look like: the walk finds them like any other project
|
|
203
|
+
config, wins over the XDG tier, and anchors the project root at `$HOME`
|
|
204
|
+
-- so every project under `$HOME` with no config of its own resolves
|
|
205
|
+
against a stray `~/.config/okfit.toml`. Personal defaults belong at
|
|
206
|
+
`$XDG_CONFIG_HOME/okfit/config.toml` (note the extra `okfit` directory),
|
|
207
|
+
not directly under `~/.config/`.
|
|
208
|
+
|
|
209
|
+
`--config <file>` bypasses discovery entirely — no upward walk, no XDG probe
|
|
210
|
+
— and anchors the project root the same way. An explicit path inside a
|
|
211
|
+
`.config` directory anchors on that directory's parent, exactly as a
|
|
212
|
+
discovered one does. A path that does not exist is a hard failure:
|
|
213
|
+
|
|
214
|
+
```console
|
|
215
|
+
$ okfit validate --config ./missing.toml
|
|
216
|
+
error: config path not found: /abs/missing.toml
|
|
217
|
+
$ echo $?
|
|
218
|
+
3
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
A config whose `okf_version` disagrees with the version this `okfit` speaks
|
|
222
|
+
is a warning, not a failure; the run continues.
|
|
223
|
+
|
|
224
|
+
## Exit codes
|
|
225
|
+
|
|
226
|
+
| Code | Meaning |
|
|
227
|
+
| --- | --- |
|
|
228
|
+
| `130` | Interrupted (Ctrl-C) |
|
|
229
|
+
| `64` | Usage error: an unknown flag or subcommand |
|
|
230
|
+
| `3` | Infrastructure failure: bad `--config` path, malformed or unreadable config, unset `HOME`, unreadable bundle root, or `init`'s overwrite refusal |
|
|
231
|
+
| `2` | One or more conformance diagnostics of severity error |
|
|
232
|
+
| `1` | One or more lint or profile diagnostics of severity error |
|
|
233
|
+
| `0` | Otherwise |
|
|
234
|
+
|
|
235
|
+
Higher wins when several apply. Warnings and info never change the exit
|
|
236
|
+
code.
|
|
237
|
+
|
|
238
|
+
`okfit context` never produces `1` or `2`: it prints orientation data and
|
|
239
|
+
never runs conformance or lint checks.
|
|
240
|
+
|
|
241
|
+
## `--format json`
|
|
242
|
+
|
|
243
|
+
`okfit validate --format json` prints exactly one JSON document to stdout and
|
|
244
|
+
nothing else (no summary line, no warnings — those still go to stderr):
|
|
245
|
+
|
|
246
|
+
```json
|
|
247
|
+
{
|
|
248
|
+
"schema": 1,
|
|
249
|
+
"okfit_version": "0.1.0",
|
|
250
|
+
"okf_version": "0.2",
|
|
251
|
+
"root": "/abs/path/to/my-repo/okf",
|
|
252
|
+
"profile": "software-project",
|
|
253
|
+
"exit_code": 1,
|
|
254
|
+
"summary": {
|
|
255
|
+
"conformance_errors": 0,
|
|
256
|
+
"lint_errors": 1,
|
|
257
|
+
"lint_warnings": 1,
|
|
258
|
+
"lint_info": 0,
|
|
259
|
+
"profile_errors": 0,
|
|
260
|
+
"concepts": 4
|
|
261
|
+
},
|
|
262
|
+
"diagnostics": [
|
|
263
|
+
{
|
|
264
|
+
"source": "core.lint",
|
|
265
|
+
"file": "",
|
|
266
|
+
"code": "config-unknown-key",
|
|
267
|
+
"severity": "warning",
|
|
268
|
+
"message": "unknown top-level key \"extra_section\""
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
"source": "core.lint",
|
|
272
|
+
"file": "modules/router.md",
|
|
273
|
+
"code": "required-key-missing",
|
|
274
|
+
"severity": "error",
|
|
275
|
+
"message": "missing required key \"description\"",
|
|
276
|
+
"range": { "offset": 87, "length": 9, "line": 11, "character": 0 }
|
|
277
|
+
}
|
|
278
|
+
]
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
`profile` is `null` when the config sets `bundle.profile = "none"` or names a
|
|
283
|
+
profile `okfit` does not recognise. `range` is zero-based, exactly as
|
|
284
|
+
`@okfit/core` computed it, and omitted for a range-less diagnostic.
|
|
285
|
+
|
|
286
|
+
An infrastructure failure under `--format json` prints a different, smaller
|
|
287
|
+
envelope to stdout and exits `3`:
|
|
288
|
+
|
|
289
|
+
```json
|
|
290
|
+
{ "schema": 1, "okfit_version": "0.1.0", "exit_code": 3, "error": { "tag": "ConfigPathNotFoundError", "message": "config path not found: /abs/ci-config.toml" } }
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
`init` has no `--format`; it is human output only.
|
|
294
|
+
|
|
295
|
+
`okfit context --format json` prints its own envelope, distinct from the
|
|
296
|
+
one above:
|
|
297
|
+
|
|
298
|
+
```json
|
|
299
|
+
{
|
|
300
|
+
"schema": 1,
|
|
301
|
+
"project_root": "/abs/path/to/my-repo",
|
|
302
|
+
"bundle_root": "/abs/path/to/my-repo/okf",
|
|
303
|
+
"config_path": null,
|
|
304
|
+
"profile": "software-project",
|
|
305
|
+
"profile_requested": null,
|
|
306
|
+
"index_path": "/abs/path/to/my-repo/okf/index.md",
|
|
307
|
+
"index_exists": true,
|
|
308
|
+
"actors": { "agent": null },
|
|
309
|
+
"types": [
|
|
310
|
+
{ "name": "Project", "description": "The repository's root concept: its purpose, boundaries, and non-goals.", "guidance": "..." }
|
|
311
|
+
],
|
|
312
|
+
"tags": [
|
|
313
|
+
{ "name": "architecture", "description": "Concerns the shape of the system rather than one module." }
|
|
314
|
+
]
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Every field is present, even when unset (`config_path`, `profile`,
|
|
319
|
+
`profile_requested`, and `actors.agent` are `null`, never an omitted key) --
|
|
320
|
+
unlike `JsonDiagnostic`'s `range`, this envelope has no optional keys at all.
|
|
321
|
+
|
|
322
|
+
`profile_requested` is the profile name the config asked for after the
|
|
323
|
+
default rule, and is `null` only when no config file was found at all.
|
|
324
|
+
`profile` keeps its original meaning: the resolved profile's name, or
|
|
325
|
+
`null` when the requested name is unknown (or the config sets
|
|
326
|
+
`bundle.profile = "none"`). The two differ only when a config names a
|
|
327
|
+
profile `okfit` does not recognise — `profile` is `null` but
|
|
328
|
+
`profile_requested` still names what was asked for, so `okfit context
|
|
329
|
+
--format human` prints `profile: (none) (requested <name>, unknown)` in
|
|
330
|
+
that one case.
|
|
331
|
+
|
|
332
|
+
## Message conventions
|
|
333
|
+
|
|
334
|
+
Every message `okfit` prints is lowercase, starts `error:` or `warning:`,
|
|
335
|
+
never ends with a trailing period, and renders a path relative to the
|
|
336
|
+
current directory when the path is under it, absolute otherwise. Colour, when
|
|
337
|
+
stdout is a TTY and `NO_COLOR` is not `1`, wraps only the severity word —
|
|
338
|
+
never the code, the path, or the message text.
|
|
10
339
|
|
|
11
340
|
## License
|
|
12
341
|
|
package/bin/okfit.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { renderFailure } from "../errors.js";
|
|
3
|
+
import { CLI_VERSION } from "../version.js";
|
|
4
|
+
import { Now } from "../validate/run.js";
|
|
2
5
|
import { rootCommand } from "../commands/root.js";
|
|
3
|
-
import { CLI_VERSION } from "../index.js";
|
|
4
|
-
import { Effect } from "effect";
|
|
5
6
|
import { Command } from "effect/unstable/cli";
|
|
7
|
+
import { DateTime, Effect, Layer, Option } from "effect";
|
|
8
|
+
import { CliLogger, CliRuntime } from "@effected/cli";
|
|
6
9
|
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
|
|
7
10
|
import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
11
|
+
import { AppDirs, Xdg } from "@effected/xdg";
|
|
8
12
|
|
|
9
13
|
//#region src/bin.ts
|
|
10
14
|
/**
|
|
@@ -12,8 +16,47 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
|
12
16
|
*
|
|
13
17
|
* @packageDocumentation
|
|
14
18
|
*/
|
|
15
|
-
|
|
16
|
-
|
|
19
|
+
/**
|
|
20
|
+
* K-9: `AppConfig.layer` only, built by Group B's `config/layer.ts` —
|
|
21
|
+
* `Xdg` and `AppDirs` are provided once, here, for both commands; no
|
|
22
|
+
* `Store`, no `Cache`, so no `store.db`/`cache.db` is ever created.
|
|
23
|
+
*
|
|
24
|
+
* `AppDirs.layer(options)` requires `Xdg | FileSystem | Path`
|
|
25
|
+
* (`XDG/index.d.ts:320`), so `Layer.provide(Xdg.layer)` alone does not close
|
|
26
|
+
* it: `Layer.provideMerge(NodeServices.layer)` supplies `FileSystem`/`Path`
|
|
27
|
+
* to both members and keeps every service in the output. `NodeServices.layer`
|
|
28
|
+
* provides `ChildProcessSpawner | Crypto | FileSystem | Path | Stdio |
|
|
29
|
+
* Terminal`, a superset of `Command.Environment`.
|
|
30
|
+
*
|
|
31
|
+
* `Xdg.layer` fails with `XdgEnvError` when `HOME` is unset (K-13). That
|
|
32
|
+
* error reaches `reportFailures` and takes its `exitCode: 3` fallback, with
|
|
33
|
+
* no special case anywhere — but only because `Effect.provide(PlatformLayer)`
|
|
34
|
+
* is applied INSIDE the region `CliRuntime.reportFailures` wraps, below.
|
|
35
|
+
* `@effected/cli`'s own doc example provides its layer after
|
|
36
|
+
* `reportFailures`; doing that here would let a failure while building this
|
|
37
|
+
* layer (an unset `HOME`, say) escape reportFailures entirely and fall to
|
|
38
|
+
* `NodeRuntime.runMain`'s own fatal-error path — a stack trace on stdout and
|
|
39
|
+
* exit `1`, not the rendered `exitCode: 3` this module promises.
|
|
40
|
+
*/
|
|
41
|
+
const PlatformLayer = Layer.mergeAll(Xdg.layer, AppDirs.layer({ namespace: "okfit" }).pipe(Layer.provide(Xdg.layer))).pipe(Layer.provideMerge(NodeServices.layer));
|
|
42
|
+
/**
|
|
43
|
+
* K-47: an ISO-8601 `OKFIT_NOW` when set, else the wall clock. A documented
|
|
44
|
+
* test hook, not user-facing. Resolved exactly once, here, and provided to
|
|
45
|
+
* the whole command tree through the `Now` tag so no command handler ever
|
|
46
|
+
* reads `process.env["OKFIT_NOW"]` itself.
|
|
47
|
+
*/
|
|
48
|
+
const nowEffect = Option.fromNullishOr(process.env.OKFIT_NOW).pipe(Option.flatMap((iso) => DateTime.make(iso)), Option.match({
|
|
49
|
+
onNone: () => DateTime.now,
|
|
50
|
+
onSome: Effect.succeed
|
|
51
|
+
}));
|
|
52
|
+
const program = Effect.gen(function* () {
|
|
53
|
+
const now = yield* nowEffect;
|
|
54
|
+
return yield* Command.run(rootCommand, { version: CLI_VERSION }).pipe(Effect.provideService(Now, now), Effect.catchTag("ShowHelp", (help) => Effect.fail(CliRuntime.reported(help, help.errors.length > 0 ? 64 : 0))));
|
|
55
|
+
}).pipe(Effect.provide(PlatformLayer), CliRuntime.reportFailures({
|
|
56
|
+
exitCode: 3,
|
|
57
|
+
render: renderFailure
|
|
58
|
+
}));
|
|
59
|
+
NodeRuntime.runMain(program.pipe(Effect.provide(CliLogger.layer())));
|
|
17
60
|
|
|
18
61
|
//#endregion
|
|
19
62
|
export { };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { provideConfig } from "../config/layer.js";
|
|
2
|
+
import { resolveProjectConfig } from "../config/resolve.js";
|
|
3
|
+
import { runContext } from "../context/run.js";
|
|
4
|
+
import { setExitCode } from "../internal/exit.js";
|
|
5
|
+
import { ContextEnvelope, contextEnvelope, humanContext } from "../render/context.js";
|
|
6
|
+
import { jsonError } from "../render/json.js";
|
|
7
|
+
import { CLI_VERSION } from "../version.js";
|
|
8
|
+
import { Argument, Command, Flag } from "effect/unstable/cli";
|
|
9
|
+
import { Console, Effect, Option, Schema } from "effect";
|
|
10
|
+
|
|
11
|
+
//#region src/commands/context.ts
|
|
12
|
+
/** `[path]` is the PROJECT root (K-2), never the bundle root. Absolute at parse time (K-50). */
|
|
13
|
+
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"));
|
|
14
|
+
/** K-1: no `mustExist` — the handler stats the path itself, before building any layer. */
|
|
15
|
+
const configFlag = Flag.file("config").pipe(Flag.optional, Flag.withDescription("explicit config file; skips discovery"));
|
|
16
|
+
/** No `--profile` flag: M-15's exact ruling; that flag belongs to `init` alone. */
|
|
17
|
+
const formatFlag = Flag.choice("format", ["human", "json"]).pipe(Flag.withDefault("human"), Flag.withDescription("output format: human (default) or json"));
|
|
18
|
+
/**
|
|
19
|
+
* `okfit context [path] [--config <file>] [--format human|json]`.
|
|
20
|
+
*
|
|
21
|
+
* Handler order fixed by the contract (§8.3): steps 1–7 are
|
|
22
|
+
* `validateCommand`'s handler in substance (both now share
|
|
23
|
+
* `config/resolve.ts#resolveProjectConfig` — A4) — stat `--config` (K-1) via
|
|
24
|
+
* `provideConfig`, discover (`OkfitConfigFile.discover`), resolve the
|
|
25
|
+
* profile with the K-4 warning, merge `DEFAULTS < profile < file` (D-28),
|
|
26
|
+
* warn on an `okf_version` mismatch (K-15), resolve the project and bundle
|
|
27
|
+
* roots (K-12) — then it diverges: `runContext` (index.md stat only,
|
|
28
|
+
* never `Bundle.load`), build the envelope, render, and always exit `0`
|
|
29
|
+
* (there is no content tier: `context` never runs conformance or lint
|
|
30
|
+
* checks, so C-3.5's `1`/`2` codes have no analogue here).
|
|
31
|
+
*
|
|
32
|
+
* @public
|
|
33
|
+
*/
|
|
34
|
+
const contextCommand = Command.make("context", {
|
|
35
|
+
path: pathArg,
|
|
36
|
+
config: configFlag,
|
|
37
|
+
format: formatFlag
|
|
38
|
+
}, (input) => Effect.gen(function* () {
|
|
39
|
+
const cwd = process.cwd();
|
|
40
|
+
const discoveryCwd = Option.getOrElse(input.path, () => cwd);
|
|
41
|
+
const body = Effect.gen(function* () {
|
|
42
|
+
const { projectRoot, bundleRoot, config: merged, profile, profileName, discovered } = yield* resolveProjectConfig({
|
|
43
|
+
pathArg: input.path,
|
|
44
|
+
explicitConfigPath: input.config,
|
|
45
|
+
cwd
|
|
46
|
+
});
|
|
47
|
+
const { indexPath, indexExists } = yield* runContext({ bundleRoot });
|
|
48
|
+
const configPath = Option.match(discovered, {
|
|
49
|
+
onNone: () => Option.isSome(input.config) ? input.config.value : null,
|
|
50
|
+
onSome: (source) => source.path
|
|
51
|
+
});
|
|
52
|
+
const profileRequested = configPath === null ? null : profileName;
|
|
53
|
+
const envelope = contextEnvelope({
|
|
54
|
+
projectRoot,
|
|
55
|
+
bundleRoot,
|
|
56
|
+
configPath,
|
|
57
|
+
profile: Option.match(profile, {
|
|
58
|
+
onNone: () => null,
|
|
59
|
+
onSome: (p) => p.name
|
|
60
|
+
}),
|
|
61
|
+
profileRequested,
|
|
62
|
+
indexPath,
|
|
63
|
+
indexExists,
|
|
64
|
+
config: merged
|
|
65
|
+
});
|
|
66
|
+
if (input.format === "json") yield* Console.log(JSON.stringify(Schema.encodeSync(ContextEnvelope)(envelope)));
|
|
67
|
+
else for (const contextLine of humanContext(envelope)) yield* Console.log(contextLine);
|
|
68
|
+
setExitCode(0);
|
|
69
|
+
}).pipe(provideConfig({
|
|
70
|
+
explicitConfigPath: input.config,
|
|
71
|
+
discoveryCwd
|
|
72
|
+
}));
|
|
73
|
+
if (input.format === "json") return yield* body.pipe(Effect.tapError((error) => Console.log(JSON.stringify(jsonError(error, CLI_VERSION)))));
|
|
74
|
+
return yield* body;
|
|
75
|
+
})).pipe(Command.withDescription("Print the resolved project root, bundle root, config path, profile, and type/tag vocabulary without loading the bundle."));
|
|
76
|
+
|
|
77
|
+
//#endregion
|
|
78
|
+
export { contextCommand };
|
package/commands/init.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { InitOverwriteError } from "../errors.js";
|
|
2
|
+
import { provideConfig } from "../config/layer.js";
|
|
3
|
+
import { resolveBundleRoot, resolveProjectRoot } from "../config/anchor.js";
|
|
4
|
+
import { setExitCode } from "../internal/exit.js";
|
|
5
|
+
import { forDiagnostics } from "../render/exit.js";
|
|
6
|
+
import { collect } from "../render/sort.js";
|
|
7
|
+
import { CONFIG_RELATIVE_PATH, SCHEMA_DIRECTIVE, configValue, files, targetPaths } from "../init/scaffold.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, DateTime, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
|
|
13
|
+
import { TomlCodec } from "@effected/config-file";
|
|
14
|
+
import { OKF_SPEC_VERSION, OkfitConfig, OkfitConfigFile } from "@okfit/core";
|
|
15
|
+
import { GitHistory, Profiles } from "@okfit/profiles";
|
|
16
|
+
import { Git } from "@effected/git";
|
|
17
|
+
|
|
18
|
+
//#region src/commands/init.ts
|
|
19
|
+
/**
|
|
20
|
+
* `[path]` is the PROJECT root (K-2), never the bundle root — identical to
|
|
21
|
+
* `validate`'s own argument (`Argument.path` resolves it absolute at the
|
|
22
|
+
* parse boundary, satisfying K-50).
|
|
23
|
+
*/
|
|
24
|
+
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"));
|
|
25
|
+
/** K-1: no `mustExist` — existence is checked by `provideConfig`, identical to `validate`'s flag. */
|
|
26
|
+
const configFlag = Flag.file("config").pipe(Flag.optional, Flag.withDescription("explicit config file; skips discovery"));
|
|
27
|
+
/**
|
|
28
|
+
* K-3: `Flag.string`, deliberately not `Flag.choice` against
|
|
29
|
+
* `PROFILE_NAMES` (`PROFILES/Profile.ts:10`) — an unrecognised name is a
|
|
30
|
+
* CLI-rendered warning (K-4), matching `Profiles.get`'s own `Option.none`
|
|
31
|
+
* contract (P-38), not a parser-level `CliError.InvalidValue`.
|
|
32
|
+
*/
|
|
33
|
+
const profileFlag = Flag.string("profile").pipe(Flag.optional, Flag.withDescription("profile to scaffold with (default: the config's bundle.profile or software-project)"));
|
|
34
|
+
/**
|
|
35
|
+
* Stands in for `profile.config` when `Profiles.get` returns `None`.
|
|
36
|
+
* `OkfitConfig.merge` only visits `Object.keys(override)`
|
|
37
|
+
* (`CORE/OkfitConfig.ts:229-245`), so merging this in leaves every other key
|
|
38
|
+
* at the base's value — it exists only to satisfy `merge`'s signature.
|
|
39
|
+
*/
|
|
40
|
+
const NO_PROFILE_CONFIG = { extensions: {} };
|
|
41
|
+
/**
|
|
42
|
+
* `bundle` and `bundle.profile`/`bundle.path` are all `optionalKey` in the
|
|
43
|
+
* schema (`CORE/OkfitConfig.ts`), so the type checker sees `string |
|
|
44
|
+
* undefined` two levels deep even though `OkfitConfig.DEFAULTS` always sets
|
|
45
|
+
* both at runtime — the same `??` fallback `commands/validate.ts`'s own
|
|
46
|
+
* `DEFAULT_PROFILE_NAME` uses.
|
|
47
|
+
*/
|
|
48
|
+
const DEFAULT_PROFILE_NAME = OkfitConfig.DEFAULTS.bundle?.profile ?? "software-project";
|
|
49
|
+
/** See {@link DEFAULT_PROFILE_NAME}. */
|
|
50
|
+
const DEFAULT_BUNDLE_PATH = OkfitConfig.DEFAULTS.bundle?.path ?? "okf";
|
|
51
|
+
const countsOf = (diagnostics, concepts) => ({
|
|
52
|
+
errors: diagnostics.filter((diagnostic) => diagnostic.severity === "error").length,
|
|
53
|
+
warnings: diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length,
|
|
54
|
+
info: diagnostics.filter((diagnostic) => diagnostic.severity === "info").length,
|
|
55
|
+
concepts
|
|
56
|
+
});
|
|
57
|
+
/**
|
|
58
|
+
* `okfit init [path] [--profile <name>] [--config <file>]` (K-2, K-3; no
|
|
59
|
+
* `--format`, human output only, K-6).
|
|
60
|
+
*
|
|
61
|
+
* Handler order, fixed by the contract:
|
|
62
|
+
*
|
|
63
|
+
* 1. `cwd = process.cwd()`; `discoveryCwd = Option.getOrElse(path, () => cwd)`.
|
|
64
|
+
* 2. `provideConfig({ explicitConfigPath: config, discoveryCwd })` wraps
|
|
65
|
+
* everything from step 3 on, identical to `validate`'s own pre-flight
|
|
66
|
+
* (K-57).
|
|
67
|
+
* 3. `sources = yield* (yield* OkfitConfigFile).discover`; the winner is
|
|
68
|
+
* `sources[0]`.
|
|
69
|
+
* 4. `profileName = Option.getOrElse(input.profile, () =>
|
|
70
|
+
* fileConfig.bundle?.profile ?? DEFAULTS.bundle.profile)` (K-3, the one
|
|
71
|
+
* difference from `validate`'s step 4). `Profiles.get(profileName)`.
|
|
72
|
+
* `None` and `profileName !== "none"` warns (K-4); `"none"` is silent.
|
|
73
|
+
* 5. `merged = OkfitConfig.merge(OkfitConfig.merge(DEFAULTS, profileConfig),
|
|
74
|
+
* fileConfig)`, `profileConfig` falling back to `NO_PROFILE_CONFIG` when
|
|
75
|
+
* no profile resolved.
|
|
76
|
+
* 6. `merged.okf_version !== OKF_SPEC_VERSION` warns (K-15).
|
|
77
|
+
* 7. `projectRoot`/`bundleRoot` via `config/anchor.ts`; `now = yield* Now`;
|
|
78
|
+
* `layout` falls back to `Profiles.softwareProject.layout` when no
|
|
79
|
+
* profile resolved (this file's own decision 2 above — `Layout` has no
|
|
80
|
+
* `DEFAULTS` equivalent).
|
|
81
|
+
* 8. `paths = targetPaths(...)`; every path stat-ed; ANY existing fails
|
|
82
|
+
* with `InitOverwriteError` before a single byte is written (K-28).
|
|
83
|
+
* 9. `mkdir -p` every target's parent, THEN write `configValue(...)`
|
|
84
|
+
* through `OkfitConfigFile.write` and every `files(...)` entry through
|
|
85
|
+
* `fs.writeFileString` — `OkfitConfigFile.write` deliberately does not
|
|
86
|
+
* create its parent (`CF/index.d.ts:517-521`), and `save` is unusable
|
|
87
|
+
* here since it targets the XDG `defaultPath`, which K-14 forbids.
|
|
88
|
+
* 10. `Console.log` the K-51 success line.
|
|
89
|
+
* 11. self-validate (K-29): `run({ root: bundleRoot, config: merged,
|
|
90
|
+
* profile, now })` over the bundle just written, rendered exactly as
|
|
91
|
+
* `validate --format human` does, `setExitCode` to its
|
|
92
|
+
* `forDiagnostics` result. The handler SUCCEEDS (K-7); the failure path
|
|
93
|
+
* is only `InitOverwriteError` at step 8, or an infrastructure error
|
|
94
|
+
* that already carries its own `[Runtime.errorExitCode]`.
|
|
95
|
+
*
|
|
96
|
+
* @public
|
|
97
|
+
*/
|
|
98
|
+
const initCommand = Command.make("init", {
|
|
99
|
+
path: pathArg,
|
|
100
|
+
config: configFlag,
|
|
101
|
+
profile: profileFlag
|
|
102
|
+
}, (input) => Effect.gen(function* () {
|
|
103
|
+
const cwd = process.cwd();
|
|
104
|
+
const discoveryCwd = Option.getOrElse(input.path, () => cwd);
|
|
105
|
+
yield* provideConfig({
|
|
106
|
+
explicitConfigPath: input.config,
|
|
107
|
+
discoveryCwd
|
|
108
|
+
})(Effect.gen(function* () {
|
|
109
|
+
const winner = (yield* (yield* OkfitConfigFile).discover)[0];
|
|
110
|
+
const fileConfig = winner === void 0 ? { extensions: {} } : winner.value;
|
|
111
|
+
const profileName = Option.getOrElse(input.profile, () => fileConfig.bundle?.profile ?? DEFAULT_PROFILE_NAME);
|
|
112
|
+
const profile = Profiles.get(profileName);
|
|
113
|
+
if (Option.isNone(profile) && profileName !== "none") yield* Console.error(`warning: unknown profile "${profileName}"; continuing with defaults`);
|
|
114
|
+
const profileConfig = Option.match(profile, {
|
|
115
|
+
onNone: () => NO_PROFILE_CONFIG,
|
|
116
|
+
onSome: (resolved) => resolved.config
|
|
117
|
+
});
|
|
118
|
+
const merged = OkfitConfig.merge(OkfitConfig.merge(OkfitConfig.DEFAULTS, profileConfig), fileConfig);
|
|
119
|
+
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`);
|
|
120
|
+
const path = yield* Path.Path;
|
|
121
|
+
const fs = yield* FileSystem.FileSystem;
|
|
122
|
+
const projectRoot = resolveProjectRoot({
|
|
123
|
+
pathArg: input.path,
|
|
124
|
+
explicitConfigPath: input.config,
|
|
125
|
+
discovered: winner === void 0 ? Option.none() : Option.some({
|
|
126
|
+
path: winner.path,
|
|
127
|
+
resolver: winner.resolver
|
|
128
|
+
}),
|
|
129
|
+
cwd,
|
|
130
|
+
path
|
|
131
|
+
});
|
|
132
|
+
const bundleRoot = resolveBundleRoot(projectRoot, merged, path);
|
|
133
|
+
const now = yield* Now;
|
|
134
|
+
const scaffoldOptions = {
|
|
135
|
+
projectRoot,
|
|
136
|
+
bundleRoot,
|
|
137
|
+
layout: Option.match(profile, {
|
|
138
|
+
onNone: () => Profiles.softwareProject.layout,
|
|
139
|
+
onSome: (resolved) => resolved.layout
|
|
140
|
+
}),
|
|
141
|
+
profileName,
|
|
142
|
+
projectTitle: path.basename(projectRoot),
|
|
143
|
+
today: DateTime.formatIso(now).slice(0, 10)
|
|
144
|
+
};
|
|
145
|
+
const paths = targetPaths(scaffoldOptions);
|
|
146
|
+
const existing = [];
|
|
147
|
+
for (const target of paths) if (yield* fs.exists(target)) existing.push(target);
|
|
148
|
+
if (existing.length > 0) return yield* new InitOverwriteError({
|
|
149
|
+
paths: existing,
|
|
150
|
+
cwd
|
|
151
|
+
});
|
|
152
|
+
for (const target of paths) yield* fs.makeDirectory(path.dirname(target), { recursive: true });
|
|
153
|
+
const bundlePath = merged.bundle?.path ?? DEFAULT_BUNDLE_PATH;
|
|
154
|
+
const encoded = yield* Schema.encodeEffect(OkfitConfig)(configValue({
|
|
155
|
+
...scaffoldOptions,
|
|
156
|
+
bundlePath
|
|
157
|
+
}));
|
|
158
|
+
const toml = yield* TomlCodec.stringify(encoded);
|
|
159
|
+
yield* fs.writeFileString(`${projectRoot}/${CONFIG_RELATIVE_PATH}`, `${SCHEMA_DIRECTIVE}${toml}`);
|
|
160
|
+
const scaffoldFiles = yield* files(scaffoldOptions);
|
|
161
|
+
for (const file of scaffoldFiles) yield* fs.writeFileString(file.path, file.contents);
|
|
162
|
+
yield* Console.log(`Initialized ${displayRoot(cwd, bundleRoot, path)} with the ${profileName} profile`);
|
|
163
|
+
const result = yield* run({
|
|
164
|
+
root: bundleRoot,
|
|
165
|
+
config: merged,
|
|
166
|
+
profile,
|
|
167
|
+
now
|
|
168
|
+
}).pipe(Effect.provide(Layer.mergeAll(Git.layer, GitHistory.layer)));
|
|
169
|
+
const diagnostics = collect(result.report.conformance, result.report.lint, result.profileDiagnostics);
|
|
170
|
+
for (const line of human(diagnostics, { color: useColor() })) yield* Console.log(line);
|
|
171
|
+
yield* Console.error(summary(countsOf(diagnostics, result.bundle.concepts.size), displayRoot(cwd, bundleRoot, path)));
|
|
172
|
+
setExitCode(forDiagnostics(diagnostics));
|
|
173
|
+
}));
|
|
174
|
+
})).pipe(Command.withDescription("Scaffold a new OKF bundle: a config file, the bundle's root and per-directory index files, a project stub, and an initial log entry."));
|
|
175
|
+
|
|
176
|
+
//#endregion
|
|
177
|
+
export { initCommand };
|