cornfield 0.0.0-stage → 0.1.2
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/CHANGELOG.md +65 -0
- package/LICENSE +21 -0
- package/README.md +84 -2
- package/cli/cornfield.js +320 -0
- package/cli/release-guard.js +42 -0
- package/core/cloud.js +395 -0
- package/core/config.js +298 -0
- package/core/derive.js +109 -0
- package/core/deriver-kit.js +343 -0
- package/core/fixture.js +322 -0
- package/core/index.js +17 -0
- package/core/personas.js +214 -0
- package/core/proposal.js +890 -0
- package/core/release-notes.js +220 -0
- package/core/repo.js +53 -0
- package/core/routes.js +46 -0
- package/core/schema.js +258 -0
- package/core/sync.js +230 -0
- package/core/text.js +18 -0
- package/core/validate.js +235 -0
- package/explorer/agents-model.js +215 -0
- package/explorer/bench.js +279 -0
- package/explorer/camera.js +53 -0
- package/explorer/check-model.js +321 -0
- package/explorer/check.js +219 -0
- package/explorer/cloud-model.js +151 -0
- package/explorer/explorer.css +905 -0
- package/explorer/flows.js +158 -0
- package/explorer/forge-panel-model.js +537 -0
- package/explorer/forge-panel.js +1123 -0
- package/explorer/frame.js +209 -0
- package/explorer/house-model.js +850 -0
- package/explorer/house.css +71 -0
- package/explorer/house.js +329 -0
- package/explorer/index.html +320 -0
- package/explorer/input.js +216 -0
- package/explorer/keyboard-model.js +120 -0
- package/explorer/keyboard.js +193 -0
- package/explorer/labels.js +235 -0
- package/explorer/layers.js +122 -0
- package/explorer/layout.js +260 -0
- package/explorer/live-model.js +484 -0
- package/explorer/live.js +296 -0
- package/explorer/main.js +227 -0
- package/explorer/mockups-model.js +75 -0
- package/explorer/mockups.js +209 -0
- package/explorer/outage-model.js +241 -0
- package/explorer/panels.js +215 -0
- package/explorer/plugins.js +127 -0
- package/explorer/proposal-model.js +318 -0
- package/explorer/proposal.js +503 -0
- package/explorer/render.js +223 -0
- package/explorer/renderer-canvas2d.js +203 -0
- package/explorer/renderer-webgl.js +448 -0
- package/explorer/renderer.js +71 -0
- package/explorer/review-model.js +98 -0
- package/explorer/review.js +205 -0
- package/explorer/scenario-ui.js +398 -0
- package/explorer/scenario.js +279 -0
- package/explorer/scene.js +730 -0
- package/explorer/shaders.js +535 -0
- package/explorer/sheet-model.js +47 -0
- package/explorer/sheets.js +230 -0
- package/explorer/spatial.js +159 -0
- package/explorer/state.js +154 -0
- package/explorer/theme-boot.js +16 -0
- package/explorer/theme-model.js +215 -0
- package/explorer/theme.js +103 -0
- package/explorer/view.js +170 -0
- package/explorer/whatif-model.js +403 -0
- package/explorer/whatif.js +907 -0
- package/package.json +68 -4
- package/plugins/cloud/hosts.js +124 -0
- package/plugins/cloud/index.js +178 -0
- package/plugins/cloud/sources.js +293 -0
- package/plugins/compose/index.js +226 -0
- package/plugins/compose/routes.js +214 -0
- package/plugins/docs/index.js +149 -0
- package/plugins/docs/markdown.js +400 -0
- package/plugins/docs/site.js +294 -0
- package/plugins/house/browser.js +493 -0
- package/plugins/house/home-assistant.js +178 -0
- package/plugins/openapi/index.js +198 -0
- package/plugins/personas/index.js +22 -0
- package/plugins/starter/index.js +370 -0
- package/plugins/starter/languages.js +240 -0
- package/plugins/starter/toml.js +200 -0
- package/plugins/world/index.js +141 -0
- package/server/auth.js +589 -0
- package/server/bind.js +60 -0
- package/server/db.js +146 -0
- package/server/deploys.js +42 -0
- package/server/files.js +175 -0
- package/server/forge-actions.js +320 -0
- package/server/forge-forgejo.js +308 -0
- package/server/forge-github.js +333 -0
- package/server/forge-http.js +262 -0
- package/server/forge-routes.js +412 -0
- package/server/forge.js +360 -0
- package/server/mockups.js +226 -0
- package/server/pipeline.js +351 -0
- package/server/proposal-pulls.js +144 -0
- package/server/proposals.js +378 -0
- package/server/proxy.js +74 -0
- package/server/release-notes.js +72 -0
- package/server/reviews.js +324 -0
- package/server/server.js +315 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to cornfield. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/).
|
|
4
|
+
|
|
5
|
+
**From 0.1.1 on, each release's notes are on [GitHub Releases](https://github.com/popcorn245/cornfield/releases)**, written by the release workflow from the pull request titles ([RELEASING.md](RELEASING.md)). This file keeps the history up to 0.1.0.
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-10-20
|
|
8
|
+
|
|
9
|
+
The first release: the map and its proposals, forge sign-in for GitHub and Forgejo, reviews as PR comments, and an issues and PRs panel that points into the map. A 0.x release: the formats and the API may still change before 1.0.
|
|
10
|
+
|
|
11
|
+
**Needs Node 22.13 or newer** (`node:sqlite` without a flag). The image is `node:22-alpine`.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
#### The map and the explorer
|
|
16
|
+
|
|
17
|
+
- cornfield itself, extracted from the topology tool of a private monorepo:
|
|
18
|
+
- The explorer: the WebGL2 map with its Canvas 2D fallback, orbit, data-flow and House views, labels, search, layers, the bench (`/?bench=1`, `/?fixture=5k`).
|
|
19
|
+
- Proposals: the format and its validator, Before / Diff / After, the scenario player, what-if edits, Approve / Send back.
|
|
20
|
+
- Personas, the cloud registry and its detectors, outage scenarios, the House view over a house source.
|
|
21
|
+
- The deriver contract (version 1) and three derivers: `starter` (npm workspaces, modules and imports), `personas`, `cloud`.
|
|
22
|
+
- The CLI: `serve`, `sync`, `validate`, `status`, `proposal`, `review`.
|
|
23
|
+
- The server: an allowlist, never a folder; a strict Content-Security-Policy; loopback only unless sign-in is configured.
|
|
24
|
+
- A Dockerfile, `compose.example.yml`, GitHub Actions for tests, secret scanning and (inert until switched on) releases.
|
|
25
|
+
- Light mode (MAP-01): the explorer follows the system's light or dark setting, and a toggle in the header steps through system, light and dark (kept in the browser; `?theme=light|dark` for one visit). Both renderers and the labels draw the light map, its colours derived from the dark ones: the same hues, deepened to at least 3:1 on the light ground (4.5:1 for text).
|
|
26
|
+
- Keyboard and screen readers (MAP-02): the map takes the focus and its keys (arrows pan, + and - zoom, 0 fits, N and P step through the nodes, Enter and Escape go into and out of the panel, Alt+arrow moves a node in Freeform); a live region says where the keyboard landed; Enter in the search selects the match; a what-if edge can be drawn from a list. The closed panel is inert, the menus say whether they are open, and the page has its landmarks and heading. axe-core (a dev dependency) checks each state of the page in the tests. [docs/accessibility.md](docs/accessibility.md).
|
|
27
|
+
- Mockups beside a proposal (PIPE-02, #15): a proposal may list `mockups`, screens (png, jpg, webp, svg or html) kept beside it in the spec branch, each with the `node` it belongs to, a `path` inside the proposals folder and a `title`. `validate` holds the path inside the folder and the node to one the proposal touches. A node's panel lists its mockups, and each opens in a window over the map (the title is the alt text; keyboard and screen reader are covered). They come from the checkout, the worktrees and open spec PRs (read at the head commit; `fileBytes` joins the forge interface). HTML and SVG are untrusted: every mockup is served with `Content-Security-Policy: sandbox; default-src 'none'`, `nosniff` and a same-origin frame policy, and the explorer shows HTML in an `<iframe sandbox>` with nothing allowed and SVG as an image. Approving a spec PR's proposal records each mockup's SHA-256, as the server reads it at the head, in the review comment's block (`mockups`), so a file changed afterwards reads "Changed since you approved"; `validate` also fails when a listed file is not there.
|
|
28
|
+
|
|
29
|
+
#### Derivers
|
|
30
|
+
|
|
31
|
+
- The `docs` deriver: a repo's Markdown on the map as an app with a page per document, its links to other docs and to the code they describe; a broken link fails `validate`.
|
|
32
|
+
- The `compose` deriver: stacks and containers from Docker compose files, and an app with a `runs-on` edge per host a Caddyfile or Traefik labels route to them (SRC-02). The sample repo's stack now comes from its compose file.
|
|
33
|
+
- The `openapi` deriver: an API's endpoints from its OpenAPI, Swagger or OpenCollection file, as page nodes with their methods under the API's app (SRC-05). `test/fixtures/openapi` is a repo with nothing but an OpenAPI file.
|
|
34
|
+
- The starter deriver reads Python packages, Go modules and Rust crates the way it reads npm workspaces: their manifests, the imports between them, and a dependency declared and never imported (SRC-04). A fixture repo per language in `test/fixtures/python`, `go` and `rust`.
|
|
35
|
+
- The `world` deriver: the machines of `stacks/world.json` (hostnames only, never an address) as machine nodes, with `runs-on` edges from the stacks and packages that run on them and from a virtual machine to its host, and the tailnet detector of the `cloud` deriver reads the machines that joined the tailnet (SRC-03). Opt-in: it is not in the default derivers.
|
|
36
|
+
- The deriver kit, `cornfield/deriver-kit`: the deriver contract as JSDoc types, `defineDeriver`, and a test kit (`fixtureRepo`, `runDeriver`, `checkDeriver`) that runs a repo's own deriver on a fixture repo as `sync` and `validate` would and lists everything `validate` would fail on. Guide: `docs/deriver-kit.md`.
|
|
37
|
+
- `sync` takes away an edge of a derived kind only for the deriver that emits that kind, at the end where it puts its own nodes (`madeBy` and `claims` from `deriveTopology`), so a hand-written edge into a derived node of another deriver stays.
|
|
38
|
+
- A container's `selfHosted` and `summary` are hand-written: `sync` keeps what you write there.
|
|
39
|
+
|
|
40
|
+
#### Sign-in, the server and its store
|
|
41
|
+
|
|
42
|
+
- The SQLite store (`server/db.js`, #2): `cornfield.db` in the data folder over Node's built-in `node:sqlite`, numbered migrations, and the four tables of v1: `sessions`, `reviews`, `actions`, `forge_cache`.
|
|
43
|
+
- Sign-in and the admins-only guard (`server/auth.js`, #5): GitHub or Forgejo OAuth (with PKCE and a signed state), sessions in `cornfield.db` (the cookie's value hashed, the forge token encrypted), the permission asked again every 15 minutes, the sign-in and "ask a maintainer" pages, Sign out in the header, and the guard on every route but `/healthz`. With sign-in configured and an https public URL the server may listen beyond loopback; without, it still refuses. Setting up an OAuth app on each forge: `docs/sign-in.md`.
|
|
44
|
+
- Foreign `Host` headers are refused (`server/bind.js` `hostAllowed`, #47): a request whose `Host` is not a loopback name (or, with sign-in, the public URL's host) is a 421 on every route, `/healthz` included, before the guard and the proxies, so DNS rebinding cannot read the map from a user's browser. See `docs/sign-in.md`.
|
|
45
|
+
- Home Assistant as a house source (SRC-01): with `CORNFIELD_HA_URL` and `CORNFIELD_HA_TOKEN` set, the server reads the floor, area, device and entity registries over the WebSocket API (read-only) and serves them as the house answer; areas map to a house file's rooms by name and alias, and a `mobile_app` device (a person's phone) is left out entirely. The token never leaves the server.
|
|
46
|
+
- Always on (CF-10): `compose.example.yml` with sign-in (public URL, forge OAuth pair and session secret from the environment, a missing one stops `compose up`, the port published on loopback for the reverse proxy, a read-only non-root container), the image's health check on `/healthz`, and [docs/deploy.md](docs/deploy.md): Caddy and Traefik examples, forwarded headers (cornfield trusts none), the data volume's permissions, security notes, rotating the session secret, backup. The image smoke test now also checks the user, the data folder, the health check and the compose file.
|
|
47
|
+
|
|
48
|
+
#### The forge
|
|
49
|
+
|
|
50
|
+
- The forge interface and the GitHub adapter (`server/forge.js`, `server/forge-github.js`, `server/forge-http.js`, #3): who is signed in and their permission, issues, pull requests and their files, comments, checks, re-running a failed Actions job, landing a green pull request at the head its checks ran on, branches, a file at a ref; pagination, rate limits and errors in plain words. Tests run against recorded answers and a fake forge.
|
|
51
|
+
- The Forgejo adapter (`server/forge-forgejo.js`, #4) behind the same interface and the same contract tests, over Forgejo's v1 API (Gitea-compatible): its token header, its three permission levels (its admins are the maintainers), issues and pull requests through `Link` paging, Actions results read as commit statuses, landing a green pull request at the head its checks ran on. Forgejo's API cannot re-run a check, so none is `rerunnable`. Tests run against hand-written recorded answers and a fake forge.
|
|
52
|
+
- Proposals from spec PRs (`server/proposal-pulls.js`, #6): with sign-in, the Proposals menu lists the open pull requests that add or change a file in the proposals folder beside the checkout and the worktrees, names the PR, and reads each file at the PR's head commit with the signed-in maintainer's own token. A PR that deletes a proposal lists none; a forge that cannot be asked is said plainly above the list. Without a forge nothing changes.
|
|
53
|
+
- Reviews are PR comments (`server/reviews.js`, #7): Approve and Send back on a proposal of a spec PR post a comment as the signed-in maintainer, a human line and a fenced `cornfield-review <slug>` block holding `{ verdict, note, edits }`, and write the `actions` audit row; the latest comment by an admin (admin or maintain) is the review, and a reader's block, a malformed or quoted one is ignored. `cornfield review <slug>` reads it from the PR (`CORNFIELD_FORGE_TOKEN`, or `GITHUB_TOKEN`; `--pr <n>`). Reviews Stage 1 kept in the data folder are posted once on the one open spec PR that holds their proposal and renamed `.migrated`; the data-folder path is gone, so a draft in the checkout or a worktree, with no PR, cannot be reviewed until it is pushed as a spec PR.
|
|
54
|
+
- The issues and PRs panel (`explorer/forge-panel.js`, `server/forge-routes.js`, #8): with a forge in the config and a maintainer signed in, a header button opens a panel of the repo's pull requests and issues with filters (state, label, author, search), links out to the forge, and rate limits and errors in plain words. Selecting a pull request asks for its files and checks and lights the nodes the files are (the deepest `file` that holds each; a file on no node is listed as not on the map) and the tests a failing check names; the rest of the map fades back. The server side is read-only: `GET /api/forge/issues`, `/pulls` and `/pulls/<n>`, behind the sign-in guard, as the signed-in maintainer with their own token, cached per user for a minute in `forge_cache` with an ETag (the last answer is served, marked old, while the forge is rate-limited). A bottom sheet on a phone, a slide-over from the left from 768px; keyboard reachable, announced through the map's live region, inert when closed, in light and dark. With no forge in the config nothing appears. Guide: `docs/issues-and-prs.md`.
|
|
55
|
+
- Safe actions (`server/forge-actions.js`, `explorer/forge-panel.js`, CF-08): from a selected pull request a maintainer can land a green one (the forge's own merge by the config's `forge.mergeMethod`, at the head its checks ran on, once at least one check passed and the forge says it is ready: on GitHub `mergeable_state` clean and no workflow still running) and re-run a failed or cancelled check, and nothing else: no force-merge, no deploy. `POST /api/forge/pulls/<n>/merge` and `/rerun` are same-origin, JSON, signed-in maintainer only, with the permission asked of the forge at that moment; the server reads the pull request and its checks again (open, not a draft, every check green, the head is the one the page showed, else 409), refuses a double click (one at a time, a few seconds apart, 20 a minute), and writes every attempt, refused or failed or done, to the `actions` table before anything is asked of the forge. The panel's Land it opens a confirm dialog showing the pull request, its head commit, where it goes and each check (focus held, Escape cancels, announced); a failed check has Re-run where the forge can run it again, and the panel says why where it cannot. Guide: `docs/issues-and-prs.md`.
|
|
56
|
+
- Agents, tracked (`explorer/agents-model.js`, #10, ADR 0007): the issues and PRs panel has an Agents tab that groups branches, spec PRs and pull requests by the issue ("Closes / Fixes / Resolves #n" in the text, or its number in the branch name) and the proposal (the spec PR, or its slug in the branch name) they belong to, with what links to neither in a group of its own, and marks the work agents made and why. What counts as an agent is the config's new `agents` key (`authors`, `labels`, `branchPrefixes`, and `bots`, on by default: a `[bot]` login or the forge's Bot type), empty by default and validated. One more read-only route, `GET /api/forge/work`, behind the sign-in guard and cached like the others; pull requests now carry their text. Nothing is launched. Switches for agent-made only and for merged and closed work; keyboard reachable, announced, in light and dark, and in the phone sheet. Guide: `docs/agents.md`.
|
|
57
|
+
- Deploys on the map (`server/deploys.js`, `explorer/forge-panel.js`, PIPE-03, #16): a read-only `runs({ limit })` on the forge interface, in both adapters (GitHub Actions workflow runs; Forgejo Actions runs from Forgejo 11 and Gitea 1.24, said in words, with a 501, where an instance cannot list them) and in the shared contract test, and `GET /api/forge/deploys`. A new `deploys` key in `cornfield.config.js` says which workflows are deploys (default `*deploy*`) and which map nodes each restarts (ids or labels; a stack brings its containers). The panel's new Deploys list shows the latest deploys with their state, branch, commit and who started them; selecting one lights the nodes it restarted (red when it failed) through the paint pull requests use, turning on the layers those nodes are on. A deploy no pattern names is listed, not dropped. Nothing is ever deployed, re-run or cancelled. Guide: `docs/deploys.md`.
|
|
58
|
+
- Release notes from the map (`core/release-notes.js`, `server/release-notes.js`, PIPE-04): the pull requests merged since the last version tag (or a tag you choose), grouped by what they changed on the map: a pull request is listed under each area (the app or package below the root) it touched, with the nodes it changed; one that touches nothing on the map is under "Other"; one with the `breaking` label (`releaseNotes.breakingLabel` in the config) is under "Breaking", first. Each line links its pull request, and the same inputs always give the same Markdown. The forge interface gains `tags()`, `tag(name)` and `createReleaseDraft({ tag, name, body })` in both adapters and the shared contract test. In the issues and PRs panel a Release notes tab previews the draft, copies it (the clipboard, else the text selected), and, after a confirm step, posts it as a **draft** release: `POST /api/forge/release-drafts`, the only write of the panel, same-origin and for a signed-in maintainer asked about again at that moment, with a row in the audit (`actions`) before the forge is asked. It never publishes a release and never makes or pushes a tag. `cornfield notes [--since <tag>]` prints the Markdown (read-only, with `CORNFIELD_FORGE_TOKEN`). Guide: `docs/issues-and-prs.md`.
|
|
59
|
+
- The pipeline's own steps (PIPE-05, `server/pipeline.js`, `server/forge-actions.js`, `explorer/forge-panel.js`): **Start a spec** on an open issue makes a branch `spec/<nnnn>-<slug>` (the issue's number in four digits at least, as proposals are numbered), one commit with a stub proposal and a draft pull request that says `Refs #<n>`; **Open the build issue** on a spec pull request whose latest maintainer review approves the proposal as the head holds it now (an approval records the proposal file's SHA-256 in its block, so a push that changes it waits for a new approval) opens an issue that links the pull request and the proposal and lists its node and edge changes as a checklist; landing a green pull request is the third step and is the existing action. Each is one button and a confirm step that shows exactly what will be made (the server's plan, read from the forge now), is asked with the plan's digest and refused with a 409 if the server's new plan differs, is idempotent (a second ask finds what the first made, trusted only when a maintainer made it); text from a proposal or an issue has its mentions and references broken with a zero-width space, and a build issue is found again by the forge's search (`searchIssues` joins the forge interface), is audited (`spec`, `build`), and re-checks everything on the server like landing does. Nothing launches an agent. The forge interface gains `issue`, `createBranch`, `createFile`, `createPull` (a draft one) and `createIssue` on both adapters, with contract tests; an issue now carries its `body`. New config key `forge.baseBranch` (default `main`). See [docs/issues-and-prs.md](docs/issues-and-prs.md).
|
|
60
|
+
|
|
61
|
+
#### Docs
|
|
62
|
+
|
|
63
|
+
- `cornfield docs`: the docs as a small static site built from that map (a page per document, the links around it, a map of the docs' links), published for cornfield itself to GitHub Pages by `.github/workflows/pages.yml`.
|
|
64
|
+
|
|
65
|
+
[0.1.0]: https://github.com/popcorn245/CornField/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 popcorn245
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,85 @@
|
|
|
1
|
-
#
|
|
1
|
+
# cornfield
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**The dev dashboard with the map at its centre.** cornfield draws your repo as a living topology map (packages, apps, pages, tables, services, stacks, machines and the cloud services you depend on) and keeps it true to the code. Changes are proposed *on the map* before anything is built: you see what a change adds, changes and removes, play its scenarios step by step, try what-if edits, and approve it or send it back.
|
|
4
|
+
|
|
5
|
+
It is one Node codebase with no build step and no runtime dependencies, shipped two ways: an npm CLI you run against a repo on your laptop, and a Docker image for an always-on dashboard.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
*cornfield mapping itself: each folder is a package, each module a node, the lines are imports, and the grey nodes are the cloud services its code and CI reach.*
|
|
10
|
+
|
|
11
|
+
<p>
|
|
12
|
+
<img src="docs/images/proposal-1280.png" alt="The v1 proposal open on cornfield's own map: what it adds is ringed, the rest dimmed" width="68%">
|
|
13
|
+
<img src="docs/images/map-375.png" alt="The same map on a phone" width="28%">
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
*Left: a proposal open on the map (Before / Diff / After, its scenarios, Approve or Send back). Right: the map on a phone.*
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
cd your-repo
|
|
22
|
+
npx cornfield sync # derive topology.json from the code
|
|
23
|
+
npx cornfield validate # schema, drift from the code, every proposal
|
|
24
|
+
npx cornfield serve # the map on http://127.0.0.1:3350
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
No config is needed: the starter deriver maps any npm-workspaces repo, Go modules, Rust crates and Python packages (or, without any, each top-level folder with code). Add a `cornfield.config.js` when you want more (see [docs/config.md](docs/config.md)).
|
|
28
|
+
|
|
29
|
+
## Local, or behind sign-in
|
|
30
|
+
|
|
31
|
+
**Without sign-in, cornfield only listens on 127.0.0.1 and refuses any other address**: everything it serves (your map, your proposals) would be visible to anyone who could reach it. Open it on the machine it runs on, or reach it over an SSH tunnel:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
ssh -L 3350:127.0.0.1:3350 you@the-machine
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**With forge sign-in** (a GitHub or Forgejo OAuth app in env, and an https public URL) it may listen on any address behind a reverse proxy, and shows the dashboard to the repo's maintainers only, enforced on the server for every route. Setting it up: [docs/sign-in.md](docs/sign-in.md).
|
|
38
|
+
|
|
39
|
+
## What it does today
|
|
40
|
+
|
|
41
|
+
- **The map.** WebGL2 (Canvas 2D fallback), 5,000 nodes and 15,000 edges at frame rate. Orbit, data-flow and House views, layers, search, focus, freeform drag. `/?fixture=5k&bench=1` measures it. All of it works by keyboard, and a screen reader is told where you are: [docs/accessibility.md](docs/accessibility.md).
|
|
42
|
+
- **Derived, not drawn by hand.** `cornfield sync` runs the derivers named in your config and merges what they find into `topology.json`; hand-written parts (descriptions, flows, anything of a type no deriver owns) are kept. `cornfield validate` fails on drift, so CI keeps the map honest.
|
|
43
|
+
- **Proposals.** `docs/specs/proposals/NNNN-slug.json` says what a change would add, change and remove, with scenarios. cornfield lists them from your checkout, from every git worktree in the configured worktrees folder and, with sign-in, from your open spec PRs, and shows Before / Diff / After, the scenario player, what-if edits, and Approve / Send back. Writing, reviewing and landing one in your repo: [docs/proposals.md](docs/proposals.md). The format and its validator: [core/proposal.js](core/proposal.js).
|
|
44
|
+
- **Issues and pull requests.** With a forge in the config and sign-in, a panel of its pull requests and issues (GitHub; Forgejo behind the same interface), with filters. Select a pull request and the nodes its files are light up on the map, and a failing check lights the tests it names. A Deploys list reads the forge's workflow runs and lights what a selected deploy restarted, from a mapping in the config ([docs/deploys.md](docs/deploys.md)). A maintainer can land a green pull request, re-run a failed check, start a spec on an issue and open the build issue of an approved spec, each one button with a confirm step. A bottom sheet on a phone. A **Release notes** tab drafts the notes from the pull requests merged since the last tag, grouped by what they changed on the map, to copy or to post as a draft release (never published); `cornfield notes` prints the same Markdown. [docs/issues-and-prs.md](docs/issues-and-prs.md).
|
|
45
|
+
- **Agents, tracked.** The panel's Agents tab groups branches, spec PRs and PRs by the issue and the proposal they belong to, and marks the work agents made (by author, bot account, label or branch prefix, set in the config). Nothing is launched. [docs/agents.md](docs/agents.md).
|
|
46
|
+
- **Personas, not people.** `topology.personas.json` holds archetypes (Admin, Member, Guest); scenarios act as them, never as a real person.
|
|
47
|
+
- **The cloud you do not control.** `topology.cloud.json` describes the outside services; the cloud deriver's detectors (hosts in code, Dockerfiles, container images, CI workflows, a Caddyfile) keep it complete in both directions. "Cloudflare is down" plays as an outage: what breaks now, at the next deploy, at the next certificate renewal. [docs/cloud.md](docs/cloud.md).
|
|
48
|
+
- **The house, optional.** A House view of floors, rooms, devices, machines and people (as counts), read from a source: a JSON file in your repo, your own API, later Home Assistant. cornfield only displays it. [docs/house.md](docs/house.md).
|
|
49
|
+
|
|
50
|
+
## Run it always on
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
docker run -d --name cornfield --network host \
|
|
54
|
+
-v "$PWD:/repo:ro" -v cornfield-data:/data ghcr.io/popcorn245/cornfield
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
That form has no sign-in, so it only listens on the host's loopback. For a hosted cornfield, [compose.example.yml](compose.example.yml) adds forge sign-in, the public URL, secrets from the environment and a reverse proxy in front: [docs/deploy.md](docs/deploy.md).
|
|
58
|
+
|
|
59
|
+
## Commands
|
|
60
|
+
|
|
61
|
+
| Command | What it does |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `cornfield serve` | the dashboard (`--port`, or `CORNFIELD_PORT`) |
|
|
64
|
+
| `cornfield sync` | derive the map from the code into `topology.json` |
|
|
65
|
+
| `cornfield validate` | schema, drift, the personas and cloud files, every proposal; exit 1 on a problem |
|
|
66
|
+
| `cornfield status` | what the map holds, the warnings, the validation |
|
|
67
|
+
| `cornfield proposal <file>` | one proposal against the map: its counts, errors and what has landed |
|
|
68
|
+
| `cornfield review <slug>` | the review of a proposal, read from its spec PR (verdict, note, what-if edits; needs `CORNFIELD_FORGE_TOKEN`) |
|
|
69
|
+
| `cornfield docs` | the docs as a static site, built from the map of their links (`--out`, default `_site`) |
|
|
70
|
+
|
|
71
|
+
Every command takes `--repo <dir>` and `--config <file>`.
|
|
72
|
+
|
|
73
|
+
## Extending it
|
|
74
|
+
|
|
75
|
+
- **Derivers** read your code and say which nodes and edges the map must hold: [docs/derivers.md](docs/derivers.md) (contract 1). Put your own code on the map (routes, jobs, queues) with a deriver of yours, and test it against a fixture repo with one import, `cornfield/deriver-kit`: [docs/deriver-kit.md](docs/deriver-kit.md).
|
|
76
|
+
- **Docs** are on the map too: the `docs` deriver maps each document and its links (to other docs and to the code), `validate` fails on a broken link, and `cornfield docs` builds them as a site ([docs/derivers.md](docs/derivers.md#the-docs-site)).
|
|
77
|
+
- **Sources** are browser modules for the live layer, the house, devices and permission checks: [docs/config.md](docs/config.md#sources).
|
|
78
|
+
|
|
79
|
+
## The plan
|
|
80
|
+
|
|
81
|
+
cornfield grows in the open. The vision: [docs/vision.md](docs/vision.md). The v1 spec and its issues: [docs/specs/0001-v1.md](docs/specs/0001-v1.md). Decisions: [docs/adr/](docs/adr/). After v1: [docs/roadmap.md](docs/roadmap.md). To propose a change, see [CONTRIBUTING.md](CONTRIBUTING.md): cornfield uses its own map for its own features.
|
|
82
|
+
|
|
83
|
+
## Licence
|
|
84
|
+
|
|
85
|
+
MIT, see [LICENSE](LICENSE).
|
package/cli/cornfield.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @module cli/cornfield
|
|
4
|
+
*
|
|
5
|
+
* The cornfield command, run against a repo (the current folder, or `--repo <dir>`):
|
|
6
|
+
*
|
|
7
|
+
* cornfield serve the dev dashboard on http://127.0.0.1:3350 (loopback only unless sign-in is configured)
|
|
8
|
+
* cornfield sync derive the map from the code and write topology.json
|
|
9
|
+
* cornfield validate the schema, drift from the code, the personas and cloud files, every proposal
|
|
10
|
+
* cornfield status what the map holds, the warnings, and the validation
|
|
11
|
+
* cornfield proposal <file> one proposal against the map: its summary, its errors, what has landed
|
|
12
|
+
* cornfield review <slug> the review of a proposal, from its spec PR (approve or send back, with the edits)
|
|
13
|
+
* cornfield notes release notes from the pull requests merged since the last tag, grouped by the map (read-only; Markdown)
|
|
14
|
+
* cornfield docs the docs as a static site, from the docs deriver's map of them (--out, default _site)
|
|
15
|
+
* cornfield help | --version
|
|
16
|
+
*
|
|
17
|
+
* Options: `--repo <dir>`, `--config <file>`, `--port <n>`, `--pr <n>` (review). Env: CORNFIELD_PORT, CORNFIELD_HOST,
|
|
18
|
+
* CORNFIELD_PUBLIC_URL, CORNFIELD_DATA, CORNFIELD_CONFIG (core/config.js); CORNFIELD_FORGE_TOKEN, or GITHUB_TOKEN /
|
|
19
|
+
* GH_TOKEN on GitHub, for `review` (a token of someone who can see the repo's PRs; only their reads are used).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { loadConfig, loadDerivers } from '../core/config.js';
|
|
26
|
+
import { deriveTopology } from '../core/derive.js';
|
|
27
|
+
import { planSync, placeholderCounts } from '../core/sync.js';
|
|
28
|
+
import { validateRepo, readTopology } from '../core/validate.js';
|
|
29
|
+
import { proposalProblems, proposalStatus, summarize } from '../core/proposal.js';
|
|
30
|
+
|
|
31
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const PKG = JSON.parse(fs.readFileSync(path.join(HERE, '..', 'package.json'), 'utf8'));
|
|
33
|
+
|
|
34
|
+
const HELP = `cornfield ${PKG.version}: the topology map and dev dashboard for a repo
|
|
35
|
+
|
|
36
|
+
Usage: cornfield <command> [options]
|
|
37
|
+
|
|
38
|
+
Commands:
|
|
39
|
+
serve run the dashboard on this machine (http://127.0.0.1:3350)
|
|
40
|
+
sync derive the map from the code into topology.json
|
|
41
|
+
validate check the map, its drift from the code, and every proposal (exit 1 on a problem)
|
|
42
|
+
status what the map holds, the warnings, and the validation
|
|
43
|
+
proposal <file> check one proposal against the map
|
|
44
|
+
review <slug> print the review of a proposal: the latest admin comment on its spec PR (--pr <n> to name the PR)
|
|
45
|
+
notes draft release notes from the pull requests merged since the last tag, grouped by what they changed
|
|
46
|
+
on the map, as Markdown on stdout (--since <tag>, --breaking-label <label>; reads the forge,
|
|
47
|
+
as the token in CORNFIELD_FORGE_TOKEN; changes nothing)
|
|
48
|
+
docs build the docs as a static site from their map (--out <dir>, default _site)
|
|
49
|
+
help this text
|
|
50
|
+
|
|
51
|
+
Options:
|
|
52
|
+
--repo <dir> the repo to work on (default: the current folder)
|
|
53
|
+
--config <file> the config file (default: cornfield.config.js in the repo)
|
|
54
|
+
--port <n> the port for serve (default 3350, or CORNFIELD_PORT)
|
|
55
|
+
--pr <n> the spec PR for review (default: the one open PR that holds the proposal)
|
|
56
|
+
--version print the version
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
/** `--name value` pairs and the rest */
|
|
60
|
+
export function parseArgs(argv) {
|
|
61
|
+
const flags = {};
|
|
62
|
+
const rest = [];
|
|
63
|
+
for (let i = 0; i < argv.length; i++) {
|
|
64
|
+
const a = argv[i];
|
|
65
|
+
if (a === '--version' || a === '-v') flags.version = true;
|
|
66
|
+
else if (a === '--help' || a === '-h') flags.help = true;
|
|
67
|
+
else if (a.startsWith('--')) {
|
|
68
|
+
const [k, inline] = a.slice(2).split('=');
|
|
69
|
+
flags[k] = inline !== undefined ? inline : argv[++i];
|
|
70
|
+
} else rest.push(a);
|
|
71
|
+
}
|
|
72
|
+
return { flags, rest };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
76
|
+
|
|
77
|
+
async function setup(flags) {
|
|
78
|
+
const root = path.resolve(flags.repo || process.cwd());
|
|
79
|
+
const env = { ...process.env, ...(flags.port ? { CORNFIELD_PORT: String(flags.port) } : {}) };
|
|
80
|
+
const config = await loadConfig(root, { env, file: flags.config });
|
|
81
|
+
const derivers = await loadDerivers(config);
|
|
82
|
+
return { config, derivers };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function saveTopology(config, data) {
|
|
86
|
+
const file = path.join(config.root, config.topology);
|
|
87
|
+
fs.writeFileSync(file, `${JSON.stringify({ version: data.version || '1.0.0', ...data, updatedAt: new Date().toISOString() }, null, 2)}\n`, 'utf8');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function sync(flags) {
|
|
91
|
+
const { config, derivers } = await setup(flags);
|
|
92
|
+
const current = readTopology(config) || { version: '1.0.0', nodes: [], edges: [] };
|
|
93
|
+
const derived = await deriveTopology({ root: config.root, config, derivers, map: current });
|
|
94
|
+
derived.warnings.forEach((w) => console.warn(` warning: ${w}`));
|
|
95
|
+
const { next, changes } = planSync(current, derived);
|
|
96
|
+
if (!changes.length) {
|
|
97
|
+
console.log(`\n${config.topology} already matches the code.\n`);
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
saveTopology(config, next);
|
|
101
|
+
changes.slice(0, 200).forEach((c) => console.log(` ${c}`));
|
|
102
|
+
if (changes.length > 200) console.log(` … and ${changes.length - 200} more`);
|
|
103
|
+
console.log(`\nSynced ${plural(changes.length, 'change')} from the code into ${config.topology}.\n`);
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function validate(flags) {
|
|
108
|
+
const { config, derivers } = await setup(flags);
|
|
109
|
+
const { errors, warnings } = await validateRepo({ config, derivers });
|
|
110
|
+
warnings.forEach((w) => console.warn(` warning: ${w}`));
|
|
111
|
+
if (errors.length) {
|
|
112
|
+
console.error(`\nValidation failed with ${plural(errors.length, 'error')}:`);
|
|
113
|
+
errors.forEach((e) => console.error(` - ${e}`));
|
|
114
|
+
console.error('');
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
console.log(`\nValid: the schema holds, ${config.topology} matches the code, and every proposal checks out.\n`);
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function status(flags) {
|
|
122
|
+
const { config, derivers } = await setup(flags);
|
|
123
|
+
const data = readTopology(config);
|
|
124
|
+
if (!data) {
|
|
125
|
+
console.log(`\n${config.topology} is not there yet: run cornfield sync.\n`);
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
console.log(`\n${config.title}: ${plural(data.nodes.length, 'node')}, ${plural(data.edges.length, 'edge')}`);
|
|
129
|
+
const byType = Object.entries(Object.groupBy(data.nodes, (n) => n.type)).sort((a, b) => b[1].length - a[1].length);
|
|
130
|
+
for (const [type, nodes] of byType) console.log(` ${type.padEnd(10)} ${String(nodes.length).padStart(5)} ${nodes.slice(0, 6).map((n) => n.label).join(', ')}${nodes.length > 6 ? ', …' : ''}`);
|
|
131
|
+
const cloud = data.nodes.filter((n) => n.type === 'external' && n.kind === 'cloud');
|
|
132
|
+
if (cloud.length) {
|
|
133
|
+
const tiers = Object.entries(Object.groupBy(cloud, (n) => n.plan?.tier || 'unknown')).map(([t, ns]) => `${ns.length} ${t}`).join(', ');
|
|
134
|
+
console.log(`\nCloud: ${plural(cloud.length, 'service')} (${tiers})`);
|
|
135
|
+
}
|
|
136
|
+
const placeholders = placeholderCounts(data);
|
|
137
|
+
if (placeholders.size) {
|
|
138
|
+
const total = [...placeholders.values()].reduce((s, c) => s + c, 0);
|
|
139
|
+
console.log(`\n${plural(total, 'node')} still have the description sync wrote; write a real one in ${config.topology}.`);
|
|
140
|
+
}
|
|
141
|
+
const { errors, warnings } = await validateRepo({ config, derivers, topology: data });
|
|
142
|
+
if (warnings.length) {
|
|
143
|
+
console.log(`\n${plural(warnings.length, 'warning')} from the code:`);
|
|
144
|
+
warnings.forEach((w) => console.log(` - ${w}`));
|
|
145
|
+
}
|
|
146
|
+
if (errors.length) {
|
|
147
|
+
console.log(`\n${plural(errors.length, 'validation error')}:`);
|
|
148
|
+
errors.forEach((e) => console.log(` - ${e}`));
|
|
149
|
+
} else console.log('\nValid: the schema holds and the map matches the code.');
|
|
150
|
+
console.log(`\nMap: cornfield serve, then http://127.0.0.1:${config.port}\n`);
|
|
151
|
+
return errors.length ? 1 : 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function proposal(flags, file) {
|
|
155
|
+
if (!file) {
|
|
156
|
+
console.error('Usage: cornfield proposal <file>');
|
|
157
|
+
return 1;
|
|
158
|
+
}
|
|
159
|
+
const { config } = await setup(flags);
|
|
160
|
+
let p;
|
|
161
|
+
try {
|
|
162
|
+
p = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8'));
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.error(`\n${file}: ${err.code === 'ENOENT' ? 'no such file' : `not valid JSON (${err.message})`}\n`);
|
|
165
|
+
return 1;
|
|
166
|
+
}
|
|
167
|
+
const topology = readTopology(config) || { nodes: [], edges: [] };
|
|
168
|
+
const { errors, building } = proposalProblems(topology, p);
|
|
169
|
+
const s = summarize(p);
|
|
170
|
+
console.log(`\n${p?.title || path.basename(file)}${p?.spec ? ` (spec ${p.spec})` : ''}`);
|
|
171
|
+
if (p?.summary) console.log(` ${p.summary}`);
|
|
172
|
+
console.log(` ${s.added} added · ${s.changed} changed · ${s.removed} removed · ${plural(s.scenarios, 'scenario')}`);
|
|
173
|
+
if (errors.length) {
|
|
174
|
+
console.log(`\n${plural(errors.length, 'error')}:`);
|
|
175
|
+
errors.forEach((e) => console.log(` - ${e.path || '(file)'}: ${e.message}`));
|
|
176
|
+
console.log('');
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
const { items, summary } = proposalStatus(topology, p);
|
|
180
|
+
console.log(`\n Against ${config.topology}: ${summary.landed} landed · ${summary.pending} pending · ${summary.diverged} diverged${summary.live ? ` · ${summary.live} live` : ''}${building ? ' (the build has begun)' : ''}`);
|
|
181
|
+
for (const i of items) console.log(` ${i.status.padEnd(8)} ${i.op.padEnd(6)} ${i.kind.padEnd(4)} ${i.key}`);
|
|
182
|
+
console.log('\nThe proposal is valid.\n');
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The token `review` reads the PR with: cornfield's own variable, then the forge's usual one */
|
|
187
|
+
function forgeToken(config, env) {
|
|
188
|
+
const names = ['CORNFIELD_FORGE_TOKEN', ...(config.forge.type === 'github' ? ['GITHUB_TOKEN', 'GH_TOKEN'] : [])];
|
|
189
|
+
return names.map((n) => env[n]).find((v) => typeof v === 'string' && v.trim() !== '') || null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** `forgeOptions` are the forge's own overrides (tests point it at a fake) */
|
|
193
|
+
export async function review(flags, slug, forgeOptions = {}) {
|
|
194
|
+
if (!slug) {
|
|
195
|
+
console.error('Usage: cornfield review <slug> [--pr <n>]');
|
|
196
|
+
return 1;
|
|
197
|
+
}
|
|
198
|
+
const { config } = await setup(flags);
|
|
199
|
+
if (!config.forge) {
|
|
200
|
+
console.error('\nA review is a comment on the proposal\'s spec PR, and no forge is configured: set `forge: { type, url, repo }` in the config.\n');
|
|
201
|
+
return 1;
|
|
202
|
+
}
|
|
203
|
+
const token = forgeToken(config, process.env);
|
|
204
|
+
if (!token) {
|
|
205
|
+
console.error(`\nReading the PR needs a token: set CORNFIELD_FORGE_TOKEN${config.forge.type === 'github' ? ' (or GITHUB_TOKEN)' : ''} to one that can see ${config.forge.repo}.\n`);
|
|
206
|
+
return 1;
|
|
207
|
+
}
|
|
208
|
+
const { createForge, ForgeError } = await import('../server/forge.js');
|
|
209
|
+
const { listPullProposals, forgeWords, pullNumber } = await import('../server/proposal-pulls.js');
|
|
210
|
+
const { latestReview } = await import('../server/reviews.js');
|
|
211
|
+
try {
|
|
212
|
+
const forge = createForge(config.forge, { token, ...forgeOptions });
|
|
213
|
+
let n = null;
|
|
214
|
+
if (flags.pr !== undefined || flags.source !== undefined) {
|
|
215
|
+
n = /^[1-9]\d{0,8}$/.test(String(flags.pr)) ? Number(flags.pr) : pullNumber(flags.source);
|
|
216
|
+
if (n === null) {
|
|
217
|
+
console.error('\n--pr is a pull request number (or --source pull/<n>).\n');
|
|
218
|
+
return 1;
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
const { entries, problem } = await listPullProposals(forge, { proposals: config.proposals });
|
|
222
|
+
const holders = entries.filter((e) => e.slug === slug);
|
|
223
|
+
if (holders.length === 0) {
|
|
224
|
+
console.error(`\nNo open spec PR holds a proposal "${slug}" in ${config.proposals}${problem ? ` (${problem})` : ''}. Name the PR with --pr <n>.\n`);
|
|
225
|
+
return 1;
|
|
226
|
+
}
|
|
227
|
+
if (holders.length > 1) {
|
|
228
|
+
console.error(`\nMore than one open spec PR holds "${slug}": ${holders.map((e) => `#${e.pull.number}`).join(', ')}. Name one with --pr <n>.\n`);
|
|
229
|
+
return 1;
|
|
230
|
+
}
|
|
231
|
+
n = holders[0].pull.number;
|
|
232
|
+
}
|
|
233
|
+
const found = await latestReview(forge, n, slug);
|
|
234
|
+
if (!found) {
|
|
235
|
+
console.log(`\nNo review of ${slug} on PR #${n} yet.\n`);
|
|
236
|
+
return 0;
|
|
237
|
+
}
|
|
238
|
+
console.log(JSON.stringify(found, null, 2));
|
|
239
|
+
return 0;
|
|
240
|
+
} catch (err) {
|
|
241
|
+
console.error(`\n${err instanceof ForgeError ? forgeWords(err) : err.message}\n`);
|
|
242
|
+
return 1;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function notes(flags) {
|
|
247
|
+
const { config } = await setup(flags);
|
|
248
|
+
if (!config.forge) {
|
|
249
|
+
console.error('\nNo forge is configured: set `forge` in cornfield.config.js, so there are pull requests to read.\n');
|
|
250
|
+
return 1;
|
|
251
|
+
}
|
|
252
|
+
if (['since', 'breaking-label'].some((k) => k in flags && !flags[k])) {
|
|
253
|
+
console.error('Usage: cornfield notes [--since <tag>] [--breaking-label <label>]');
|
|
254
|
+
return 1;
|
|
255
|
+
}
|
|
256
|
+
const topology = readTopology(config);
|
|
257
|
+
if (!topology) {
|
|
258
|
+
console.error(`\n${config.topology} is not there yet: run cornfield sync, so the notes can follow the map.\n`);
|
|
259
|
+
return 1;
|
|
260
|
+
}
|
|
261
|
+
const { createForge, ForgeError } = await import('../server/forge.js');
|
|
262
|
+
const { releaseNotes } = await import('../server/release-notes.js');
|
|
263
|
+
// Reads only. The token is the person's own; without one only a public repo answers, with the forge's lower limit
|
|
264
|
+
const token = process.env.CORNFIELD_FORGE_TOKEN || null;
|
|
265
|
+
try {
|
|
266
|
+
const forge = createForge(config.forge, { token });
|
|
267
|
+
const found = await releaseNotes(forge, { topology, since: flags.since || null, breakingLabel: flags['breaking-label'] || config.releaseNotes.breakingLabel });
|
|
268
|
+
console.error(`${plural(found.count, 'pull request')} merged${found.since ? ` since ${found.since.name}` : ' (no version tag yet)'}.`);
|
|
269
|
+
if (found.truncated) console.error(' There are more than the newest 100; only those are in the notes.');
|
|
270
|
+
process.stdout.write(found.markdown);
|
|
271
|
+
return 0;
|
|
272
|
+
} catch (err) {
|
|
273
|
+
if (!(err instanceof ForgeError)) throw err;
|
|
274
|
+
console.error(`\n${err.message}\n`);
|
|
275
|
+
return 1;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function docs(flags) {
|
|
280
|
+
const { config, derivers } = await setup(flags);
|
|
281
|
+
const { buildSite } = await import('../plugins/docs/site.js');
|
|
282
|
+
const out = path.resolve(config.root, flags.out || '_site');
|
|
283
|
+
const { pages, files, warnings, problems } = await buildSite({ config, derivers, out });
|
|
284
|
+
warnings.forEach((w) => console.warn(` warning: ${w}`));
|
|
285
|
+
problems.forEach((p) => console.warn(` problem: ${p}`));
|
|
286
|
+
console.log(`\nBuilt ${plural(pages, 'page')} (${plural(files.length, 'file')}) into ${path.relative(process.cwd(), out) || out}.\n`);
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function serve(flags) {
|
|
291
|
+
const { config } = await setup(flags);
|
|
292
|
+
const { listen } = await import('../server/server.js');
|
|
293
|
+
const { url, signIn } = await listen(config);
|
|
294
|
+
console.log(`\ncornfield ${PKG.version} is serving ${config.title}`);
|
|
295
|
+
console.log(` ${url}${signIn && config.publicUrl ? `, opened at ${config.publicUrl}` : ''}`);
|
|
296
|
+
if (signIn) console.log(` Sign-in with ${config.forge.type === 'github' ? 'GitHub' : 'Forgejo'}: only the maintainers of ${config.forge.repo} see the dashboard.\n`);
|
|
297
|
+
else console.log(' Loopback only: sign-in is not configured (docs/sign-in.md), so nothing else can reach it.\n');
|
|
298
|
+
return null; // keeps running
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
302
|
+
const { flags, rest } = parseArgs(argv);
|
|
303
|
+
const [command = 'help', arg] = rest;
|
|
304
|
+
if (flags.version) { console.log(PKG.version); return 0; }
|
|
305
|
+
if (flags.help || command === 'help') { console.log(HELP); return 0; }
|
|
306
|
+
const commands = { serve, sync, validate, status, proposal: (f) => proposal(f, arg), review: (f) => review(f, arg), notes, docs };
|
|
307
|
+
if (!commands[command]) {
|
|
308
|
+
console.error(`Unknown command: ${command}\n\n${HELP}`);
|
|
309
|
+
return 1;
|
|
310
|
+
}
|
|
311
|
+
return commands[command](flags);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const isMain = process.argv[1] && fs.realpathSync(path.resolve(process.argv[1])) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
315
|
+
if (isMain) {
|
|
316
|
+
main().then((code) => { if (code !== null) process.exitCode = code; }).catch((err) => {
|
|
317
|
+
console.error(`\ncornfield: ${err.message}\n`);
|
|
318
|
+
process.exitCode = 1;
|
|
319
|
+
});
|
|
320
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @module cli/release-guard
|
|
4
|
+
*
|
|
5
|
+
* `prepublishOnly`: stops an `npm publish` that is not the release workflow's. A publish goes ahead only
|
|
6
|
+
* when all of these hold, so a stray `npm publish` (from the wrong folder, a fork, a laptop) does nothing:
|
|
7
|
+
*
|
|
8
|
+
* - it runs in cornfield's own repo root (this package.json is the one being published);
|
|
9
|
+
* - CORNFIELD_RELEASE=1 is set (only .github/workflows/release.yml sets it);
|
|
10
|
+
* - the release tag is v<version> of this package.json: CORNFIELD_RELEASE_TAG, which the release workflow sets to the
|
|
11
|
+
* tag semantic-release just made (a run on main has GITHUB_REF_NAME `main`, and Actions does not let a step change
|
|
12
|
+
* a GITHUB_ variable), else GITHUB_REF_NAME for a run on the tag itself.
|
|
13
|
+
*
|
|
14
|
+
* RELEASING.md says how a release is made.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const NAMES = ['cornfield', '@popcorn245/cornfield'];
|
|
23
|
+
|
|
24
|
+
export function releaseProblems({ cwd = process.cwd(), env = process.env, root = ROOT } = {}) {
|
|
25
|
+
const problems = [];
|
|
26
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
27
|
+
if (path.resolve(cwd) !== path.resolve(root)) problems.push(`npm publish runs from ${cwd}, not from cornfield's root (${root}).`);
|
|
28
|
+
if (!NAMES.includes(pkg.name)) problems.push(`the package is "${pkg.name}", not one of ${NAMES.join(', ')}.`);
|
|
29
|
+
if (env.CORNFIELD_RELEASE !== '1') problems.push('CORNFIELD_RELEASE is not 1: only the release workflow publishes (RELEASING.md).');
|
|
30
|
+
const tag = env.CORNFIELD_RELEASE_TAG || env.GITHUB_REF_NAME;
|
|
31
|
+
if (tag !== `v${pkg.version}`) problems.push(`the release tag is ${JSON.stringify(tag || null)}, not v${pkg.version}.`);
|
|
32
|
+
return problems;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
36
|
+
if (isMain) {
|
|
37
|
+
const problems = releaseProblems();
|
|
38
|
+
if (problems.length) {
|
|
39
|
+
console.error(`\nThis publish is refused:\n${problems.map((p) => ` - ${p}`).join('\n')}\n`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
}
|