Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/commands-sync.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Commands sync

# `src/data/commands.json` is a copy of `docs/commands.json` in omm-hippo/omm.
# This job fails when the copy has drifted, so /commands can never describe a
# CLI that no longer exists (omm-hippo/omm#347). The Worker build itself is
# covered by the CI workflow, which already runs on every pull request.
on:
pull_request:
schedule:
- cron: "17 5 * * *"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: commands-sync-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
check:
name: commands.json 동기화 확인
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run check-commands
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,26 @@ served under `/ko`.
require Cloudflare authentication or invoke remote Workers AI. The assistant
uses its deterministic fallback without the live inference configuration.

## Command reference

`/commands` lists every command the CLI exports, and each `/commands/<name>`
page ends with a "CLI reference" section showing that command's usage line,
arguments, options and sub-commands exactly as `omm <name> --help` prints them.
A command the CLI exports but this site has no hand-written page for still gets
a reference-only page from `src/app/[locale]/commands/[name]`.

All of it renders from `src/data/commands.json`, a copy of `docs/commands.json`
in [omm-hippo/omm](https://github.com/omm-hippo/omm), which is generated there
from `src/omm/cli.py`. Do not edit the copy by hand:

```sh
npm run sync-commands # fetch the current export and write the copy
npm run check-commands # fail if the committed copy has drifted
```

`.github/workflows/commands-sync.yml` runs the check on every pull request and
once a day, so the site cannot quietly describe a CLI that has moved on.

## OMM AI assistant

`/assistant` and `/ko/assistant` provide a constrained OMM command selector.
Expand Down
32 changes: 31 additions & 1 deletion design/FACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,39 @@ Windows 11 run recorded above, shown on the Windows page with a caption saying
it was taken under heavy load. The macOS and Linux pages have no capture, so
they list the field names `omm scan` prints instead of inventing a table.

## Command reference data (`src/data/commands.json`)

The "CLI reference" section on every `/commands/<name>` page, the command table
on `/commands`, and the fallback page at `src/app/[locale]/commands/[name]`
render **verbatim** from `src/data/commands.json`. That file is a byte copy of
`docs/commands.json` in `github.com/omm-hippo/omm`, which
`scripts/export_command_reference.py` generates from `src/omm/cli.py` there.
Nothing in it is written, reworded or translated on this side: usage lines,
argument names, flags, metavars, defaults and help text are the strings
`omm <command> --help` prints, so a Korean page shows Korean headings above
English flags — which is what the reader will actually type.

Update path: `npm run sync-commands` fetches
`https://raw.githubusercontent.com/omm-hippo/omm/main/docs/commands.json`,
checks `schema_version === 1`, and rewrites the copy. `npm run check-commands`
does the same fetch and exits 1 when the committed copy differs, printing which
command paths moved; `.github/workflows/commands-sync.yml` runs it on every pull
request and once a day. Editing `src/data/commands.json` by hand is always
wrong — the next sync overwrites it, and the check job fails in the meantime.

Shape: a flat `commands` array sorted by `path`, where `path[0]` is the site
route and a two-element `path` is a sub-command rendered under the group page as
an `#<sub>` anchor (`/commands/setting#version`). Options carrying
`"global": true` are the flags the CLI injects into every command
(`--json`, `--quiet`, `--yes`); the site collects them into one "Shared flags"
note instead of repeating them on 24 pages. Hidden commands are not exported.
Background: omm-hippo/omm#347.

## Command doc pages (`/commands`, `/commands/search`)

Source of truth for these pages is the omm product repo at
The prose, examples, captured runs and troubleshooting on these pages are
hand-written and reviewed; only the "CLI reference" section is generated (see
the section above). Source of truth for these pages is the omm product repo at
`~/Project/Localfit` (remote `origin` = `github.com/omm-hippo/omm`). Content
lives in `src/i18n/commands/` — `base.ts` for everything language-independent
(options, example commands, captured output, verbatim errors and their
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
"test": "tsx --test tests/*.test.ts",
"test:assistant": "tsx --test tests/assistant.test.ts tests/command-docs-sync.test.ts",
"check:omm-sync": "node scripts/check-omm-sync.mjs",
"sync-commands": "node scripts/sync-commands.mjs",
"check-commands": "node scripts/sync-commands.mjs --check",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
Expand Down
165 changes: 165 additions & 0 deletions scripts/sync-commands.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
#!/usr/bin/env node
/**
* Keeps `src/data/commands.json` identical to `docs/commands.json` in
* omm-hippo/omm, which is generated from `src/omm/cli.py` by
* `scripts/export_command_reference.py` there.
*
* npm run sync-commands — fetch and write the file
* npm run check-commands — fetch and fail if the committed copy differs
*
* The check job is what stops the site from quietly describing a CLI that no
* longer exists (omm-hippo/omm#347).
*/

import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";

const SOURCE_URL =
process.env.OMM_COMMANDS_URL ??
"https://raw.githubusercontent.com/omm-hippo/omm/main/docs/commands.json";
const SCHEMA_VERSION = 1;

const REPO_ROOT = fileURLToPath(new URL("../", import.meta.url));
const TARGET = path.join(REPO_ROOT, "src/data/commands.json");

const check = process.argv.includes("--check");

function fail(message) {
process.stderr.write(`commands.json sync failed: ${message}\n`);
process.exit(1);
}

function validate(document, origin) {
if (typeof document !== "object" || document === null) {
fail(`${origin} is not a JSON object`);
}
if (document.schema_version !== SCHEMA_VERSION) {
fail(
`${origin} has schema_version ${JSON.stringify(document.schema_version)}, expected ${SCHEMA_VERSION}`,
);
}
if (!Array.isArray(document.commands) || document.commands.length === 0) {
fail(`${origin} has no commands`);
}
for (const entry of document.commands) {
if (!Array.isArray(entry.path) || entry.path.length === 0) {
fail(`${origin} has an entry without a path`);
}
if (entry.kind !== "command" && entry.kind !== "group") {
fail(`${origin}: ${entry.path.join(" ")} has kind ${JSON.stringify(entry.kind)}`);
}
}
return document;
}

function paths(document) {
return document.commands.map((entry) => entry.path.join(" "));
}

/**
* What the check compares. `omm_version` moves with every release commit in
* the product repo, so comparing it would fail this site's builds on a bump
* that changed no command at all. The CLI's own check ignores it too; a real
* change to any command, flag, default or help text still shows up here.
*/
function comparable(document) {
const rest = { ...document };
delete rest.omm_version;
return `${JSON.stringify(rest, null, 2)}\n`;
}

/** The command paths that differ, so a failing check names the real change. */
function diffPaths(local, remote) {
const left = new Set(paths(local));
const right = new Set(paths(remote));
return {
added: [...right].filter((entry) => !left.has(entry)).sort(),
removed: [...left].filter((entry) => !right.has(entry)).sort(),
};
}

async function fetchRemote() {
let response;
try {
response = await fetch(SOURCE_URL, {
headers: { accept: "application/json" },
});
} catch (error) {
fail(`could not reach ${SOURCE_URL}: ${error.message}`);
}
// Until the export lands on the product repo's default branch there is
// nothing to compare against. Checking must not turn that into a red build
// on a pull request that has not touched the copy; syncing still fails,
// because someone asking for a sync wants the file.
if (response.status === 404 && check) {
process.stdout.write(
`commands.json check skipped: ${SOURCE_URL} does not exist yet (HTTP 404).\n`,
);
process.exit(0);
}
if (!response.ok) {
fail(`${SOURCE_URL} returned HTTP ${response.status}`);
}
const text = await response.text();
let document;
try {
document = JSON.parse(text);
} catch (error) {
fail(`${SOURCE_URL} is not valid JSON: ${error.message}`);
}
return validate(document, SOURCE_URL);
}

async function readLocal() {
let text;
try {
text = await readFile(TARGET, "utf8");
} catch (error) {
if (error.code === "ENOENT") {
fail("src/data/commands.json is missing — run `npm run sync-commands`");
}
throw error;
}
return validate(JSON.parse(text), "src/data/commands.json");
}

const remote = await fetchRemote();
const serialised = `${JSON.stringify(remote, null, 2)}\n`;

if (!check) {
await writeFile(TARGET, serialised, "utf8");
process.stdout.write(
`commands.json synced: ${remote.commands.length} entries from omm ${remote.omm_version}.\n`,
);
process.exit(0);
}

const local = await readLocal();

if (comparable(local) === comparable(remote)) {
process.stdout.write(
`commands.json is current: ${local.commands.length} entries from omm ${local.omm_version}.\n`,
);
process.exit(0);
}

const { added, removed } = diffPaths(local, remote);
process.stderr.write(
[
"commands.json sync failed: the committed copy differs from omm-hippo/omm.",
` local omm_version: ${local.omm_version}`,
` remote omm_version: ${remote.omm_version}`,
` only upstream: ${added.length > 0 ? added.join(", ") : "(none)"}`,
` only on site: ${removed.length > 0 ? removed.join(", ") : "(none)"}`,
added.length === 0 && removed.length === 0
? " the command list matches; a usage line, flag, default or help text changed."
: "",
"Run `npm run sync-commands` and commit the result.",
"",
]
.filter(Boolean)
.join("\n"),
);
process.exit(1);
Loading
Loading