Compare commits
14
Commits
e0d7afecae
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7b169b87e | ||
|
|
a5093c1efc | ||
|
|
045924ec36 | ||
|
|
d3fec24bc6 | ||
|
|
4e7aa1ff1f | ||
|
|
af8f739bfe | ||
|
|
b3617ed195 | ||
|
|
da6e8b9b51 | ||
|
|
1cc528c8aa | ||
|
|
fb8b8d01d3 | ||
|
|
fb96b2f90b | ||
|
|
73b3a97dce | ||
|
|
104cbd050c | ||
|
|
49c7297d08 |
+378
-74
@@ -7,21 +7,6 @@ by saying so.*
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-07 — what this tool is `osprey`
|
||||
|
||||
**Decided:** *`loom-cli` is a fetcher and a comparator.* **It fetches a document
|
||||
you depend on and tells you whether the publisher's copy has moved.** *It reports
|
||||
and never repairs.*
|
||||
|
||||
**Believed to advance:** *every act in the loom model is a file in somebody's
|
||||
repository — publishing is writing one, adopting is fetching a URL, homing a gap
|
||||
is an `mv`.* **Almost nothing needs a program**, so the program should be the
|
||||
part that cannot be done by hand: an HTTP request, repeated.
|
||||
|
||||
**Belief that could be shown wrong:** *that reporting is enough.* **If every
|
||||
report is followed by the same manual act, we have moved the work rather than
|
||||
removed it**, and the missing command will be obvious.
|
||||
|
||||
## 2026-09-07 — the design lives here and not in a spec `osprey`
|
||||
|
||||
**Decided:** *this log is the artifact of `osprey`.* **No specification document
|
||||
@@ -72,40 +57,6 @@ somebody's published surface or from a file they may rename at will.
|
||||
location.* **If anybody writes code that parses it back into a URL, the path
|
||||
should stop looking like one.**
|
||||
|
||||
## 2026-09-07 — the lock, and what it holds `osprey`
|
||||
|
||||
**Decided:** *one record per adopted document in `.loom/externals/.locks`: the
|
||||
**resolved** origin URL, and the `ETag` it came with.*
|
||||
|
||||
**Believed to advance:** *the specimen said there would be no lock file, on the
|
||||
strength of the `ETag`-as-blob-hash claim.* **That claim failed twice**, so the
|
||||
file exists and is named for what it is. *It was briefly called `.etags`, from
|
||||
when we believed there would be one field.*
|
||||
|
||||
**Resolved, and not the short form.** *Measured: gitea `303`s
|
||||
`/loom/externals/raw/externals.md` to `/raw/branch/main/…`* — **so a lock holding a
|
||||
short URL is locked to whatever the default branch is at the time you ask**, and a
|
||||
branch rename reports as a change in the document.
|
||||
|
||||
**Belief that could be shown wrong:** *that one file is cheap.* **Two fetches in
|
||||
one round conflict inside it, and the conflict is in a file no human can resolve
|
||||
by reading.** *If that bites, it becomes one record per document and the tree is
|
||||
mirrored twice.*
|
||||
|
||||
## 2026-09-07 — unlocked is a state `osprey`
|
||||
|
||||
**Decided:** *a document with no lock is reported as unlocked.* **`check` never
|
||||
adopts whatever the remote is currently serving as the lock.**
|
||||
|
||||
**Believed to advance:** *adopting it would assert the local copy is the one being
|
||||
served, which is the thing you were about to check.* **It is the hash assumption
|
||||
in a different coat, and it fails silently in the one case that matters — a copy
|
||||
somebody edited.**
|
||||
|
||||
**Belief that could be shown wrong:** *that anybody will run `pull` again to fix
|
||||
it.* **If unlocked documents simply accumulate, the report is noise and something
|
||||
has to lock them.**
|
||||
|
||||
## 2026-09-07 — `404` is unresolved, and that is no longer provisional `osprey`
|
||||
|
||||
**Decided:** *`check` reports a `404` as unresolvable — the document was withdrawn
|
||||
@@ -450,16 +401,6 @@ unknown.**
|
||||
next token*, **and because if a read scope is not enough, that is the only argument
|
||||
for keeping ssh.**
|
||||
|
||||
## 2026-09-07 — `--help` is the documentation surface `marmalade`
|
||||
|
||||
**Decided:** *`--help` is what a person or an agent actually runs when handed an
|
||||
unfamiliar CLI, so it is the surface that has to be good* — **say what a command
|
||||
does and what it will not do.**
|
||||
|
||||
**A `.loom/published/` for `loomctl` is deferred, not declined.** *The distinction
|
||||
matters because `init` was declined.* **It earns publication when somebody depends
|
||||
on this tool's behaviour rather than reading its help.**
|
||||
|
||||
## 2026-09-07 — decided by fallback: where the credential lives `marmalade`
|
||||
|
||||
**Nobody chose this; the fallback became the decision and that is recorded so it
|
||||
@@ -789,21 +730,6 @@ designed to be ephemeral is the first step in it not being ephemeral.**
|
||||
unit.* **If two presences ever work from different machines, the cart has nowhere
|
||||
to live**, *and everything above stops being true at once.*
|
||||
|
||||
## 2026-09-07 — conversion now destroys, so the log is the only record `quince`
|
||||
|
||||
**Under `cart/v1` the cart is untracked, so converting a round deletes its dailies
|
||||
outright.** *Every previous conversion left them in git.*
|
||||
|
||||
**So an entry that was not written before the `rm` is gone**, *and the write-ahead
|
||||
log is the mechanism* — **stage as you go, because at conversion the cart is the
|
||||
only copy and you are about to delete it.**
|
||||
|
||||
**And it sharpens a conflict recorded in `cart.usages.md`:** *the annotation
|
||||
protocol here says commit before dissolving because git is the only archive.*
|
||||
**With an ignored cart there is no archive, so dissolving a notes file destroys
|
||||
the annotations outright.** *Both documents are loom's; this is where an adopter
|
||||
can see the collision.*
|
||||
|
||||
## 2026-09-07 — declined for now: reference-only adoption `quince`
|
||||
|
||||
**`externals` offers two ways out of the confidentiality rule.** *We implement
|
||||
@@ -837,3 +763,381 @@ hours.**
|
||||
*The warning now names both exits, including the one we have not built. **Telling
|
||||
somebody a rule and not the way out of it is how a guardrail becomes something
|
||||
people route around.***
|
||||
|
||||
## 2026-09-07 — measured: only gitea resolves its own short raw URL `rowan`
|
||||
|
||||
**A lock must record a resolved URL** — *a short form follows whatever the default
|
||||
branch is at the time you ask, so a branch rename reports as a change in the
|
||||
document.* **Adopting by name means building that URL, and the three hosts do not
|
||||
behave alike:**
|
||||
|
||||
```
|
||||
gitea /{owner}/{repo}/raw/{path} 303 -> /raw/branch/main/{path} resolved
|
||||
github raw.githubusercontent.com/{o}/{r}/HEAD/{path} 200, no redirect NOT resolved
|
||||
gitlab /{owner}/{repo}/-/raw/HEAD/{path} 200, no redirect NOT resolved
|
||||
```
|
||||
|
||||
**`HEAD` works on all three and resolves on none.** *It would put a moving ref in
|
||||
the lock, which is the hazard we recorded and then nearly built.*
|
||||
|
||||
**So gitea's redirect does the work, and the other two need the branch resolved
|
||||
first** — *`git ls-remote --symref <url> HEAD`, which is one round trip and no
|
||||
clone.*
|
||||
|
||||
**Belief that could be shown wrong:** *that gitea keeps redirecting.* **The short
|
||||
form is a convenience, not a documented interface**, *and if it stops we resolve
|
||||
the branch the same way as everybody else.*
|
||||
|
||||
## 2026-09-07 — supersedes "git is needed for `list` only" `rowan`
|
||||
|
||||
**Adopting by name against github or gitlab needs `git ls-remote` to resolve the
|
||||
default branch.** *Against gitea it does not, because the redirect answers the
|
||||
same question.*
|
||||
|
||||
**So the earlier claim — `list` is the only command that needs `git`, which makes
|
||||
`list` the seam — is now wrong for two of three flavors.** *The seam is unchanged
|
||||
in practice here, where everything is gitea, and the entry would otherwise read as
|
||||
still true.*
|
||||
|
||||
*Recorded because the code changed in a way that quietly moved a boundary somebody
|
||||
might rely on.*
|
||||
|
||||
## 2026-09-08 — measured: the private path works, and nothing is unexercised now `rowan`
|
||||
|
||||
**Supersedes the entry that recorded the git-over-HTTPS assumption as unexercised.
|
||||
It has been run.** *A read-scoped token, issued for one session and destroyed
|
||||
after.*
|
||||
|
||||
```
|
||||
git clone --filter=blob:none --depth=1 over HTTPS + token, private repo OK, 124KB
|
||||
loomctl external list jeffry/homelab-impl OK, authenticated
|
||||
short raw URL + 303, authenticated 404 anon -> 200, resolved branch
|
||||
If-None-Match across the redirect, authenticated 304
|
||||
```
|
||||
|
||||
**So a private repository can adopt from another private one**, *by name and by
|
||||
URL, with `check` working off the lock afterwards.* **Every path in the tool has
|
||||
now been run at least once.**
|
||||
|
||||
*Incidentally confirmed: `homelab-impl` publishes nothing and adopts six documents
|
||||
from `homelab-cluster`, each with a `.usages.md`.* **Consuming inward, which is
|
||||
the arrangement that made the confidentiality hazard impossible in that shape.**
|
||||
|
||||
## 2026-09-08 — the tool is a mast, not a lock `rowan`
|
||||
|
||||
**Recorded because it decides a question that keeps coming back, and nothing in
|
||||
the code says it.**
|
||||
|
||||
> **`loomctl` grants no access.** *It reads what your credentials already let you
|
||||
> read.* **Anything it does, a person could do with copy and paste** — *and the
|
||||
> locks mean nothing outside the tool and the discipline of the agreement.*
|
||||
|
||||
**So it warns and does not refuse.** *The point is not to make the wrong thing
|
||||
impossible; it is to make it deliberate* — **the same move as annotating a file
|
||||
you could simply edit.**
|
||||
|
||||
**Which is also why `add <full-url>` stays**, *even though it can reach outside a
|
||||
publisher's `.loom/published/` and is, in that form, a small general-purpose file
|
||||
fetcher.* **A general-purpose tool is not the aim**, *so the escape hatch is kept
|
||||
and made to say what it is, rather than removed.*
|
||||
|
||||
**What the tool adds over copy and paste is not restriction. It is the record** —
|
||||
*origin, resolved branch, `ETag`.* **A pasted document has no provenance**, *so
|
||||
nobody can later ask where it came from or whether we were allowed to have it* —
|
||||
**not because the question is hard, but because the evidence is gone.**
|
||||
|
||||
## 2026-09-08 — the confidentiality check is coarse, deliberately `rowan`
|
||||
|
||||
**`add` now asks whether *this* repository is readable anonymously**, *by resolving
|
||||
`origin` and making one unauthenticated request.* **So the warning fires when the
|
||||
source is private and the destination is public, rather than on every adoption a
|
||||
private repository performs** — *which was the previous behaviour and would have
|
||||
been noise in exactly the workflow that is legitimate.*
|
||||
|
||||
**Belief that could be shown wrong, and it is a known blind spot rather than a
|
||||
guess:** *the signal distinguishes public from not-public and nothing finer.*
|
||||
**Two repositories private to different people is the case where private-to-private
|
||||
genuinely widens access, and this check cannot see it** — *so the tool says so, in
|
||||
the message, instead of implying a verdict it has not earned.*
|
||||
|
||||
**Failing open is deliberate:** *no origin, or an unparseable one, reports "I
|
||||
cannot tell" and warns.* **"Cannot tell" must never read as "not public."**
|
||||
|
||||
## 2026-09-08 — three gaps closed, and the workaround they justified is now the rule `rowan`
|
||||
|
||||
**`publication`, `recording-decisions` and `cart` were amended in answer to gaps
|
||||
filed from here.** *Closed at reconciliation rather than at repair: each stayed
|
||||
true of the copy we held until `check` reported `200`.* **The gap files for the
|
||||
first two are deleted; `cart.gaps.md` keeps four of five.**
|
||||
|
||||
**What survives is what they justified, and in every case it is the same shape —
|
||||
something we invented locally became specified behaviour, unchanged in practice
|
||||
and entirely changed in standing:**
|
||||
|
||||
- **`wal.md`.** *We built it before `cart/v2` named it, and only because it was
|
||||
suggested to us.* **`v2` cites that:** *a practice that works only when somebody
|
||||
is standing there to suggest it is not a practice.*
|
||||
- **"A fetch you did not perform."** *Ours was a declined command and an insight
|
||||
kept without an implementation; it is now the test in `publication`.*
|
||||
- **The compaction rule**, *including the four kinds that may never be dropped,*
|
||||
**plus one addition we did not think of: a compaction is itself a decision and
|
||||
gets an entry.**
|
||||
|
||||
## 2026-09-08 — declined: fixing `annotating` `rowan`
|
||||
|
||||
**Four real gaps stand unfixed, deliberately.** *It is the most-depended-on
|
||||
document here and a revision costs every adopter an afternoon.*
|
||||
|
||||
**Two of the four are not gaps in the agreement at all.** *The rule exists — the
|
||||
source hash that detects a violated freeze, and **dissolve** as the definition of
|
||||
what responding is — **but it lives in a local editor protocol and not in the
|
||||
agreement**.* **That needs a decision about where a rule lives, which is not an
|
||||
amendment and is not ours.**
|
||||
|
||||
**Local answers keep running:** *verify the source hash and the anchors before
|
||||
responding, and say so if they disagree.*
|
||||
|
||||
## 2026-09-08 — a casting file goes stale on amendments that add no role `rowan`
|
||||
|
||||
**Twice in one day.** *`cart/v1` and `cart/v2` each invalidated nothing we had
|
||||
cast and each left `cart.usages.md` incomplete.*
|
||||
|
||||
> **`check` reports that a document moved and cannot say whether your casting
|
||||
> still covers it.** *The mechanism is that it prints the `.usages.md` beside a
|
||||
> staged polad, which it did, both times.* **It points; it cannot make anybody
|
||||
> read.**
|
||||
|
||||
**Belief that could be shown wrong:** *that pointing is enough.* **Nothing checks
|
||||
that a facet was updated, or that it was opened.**
|
||||
|
||||
*Related, and the reason this entry can be trusted: this round arrived headed
|
||||
`rowan` in a file named for a round that had already converted, with no `name`
|
||||
file.* **The name is what tags durable entries**, *so a mismatch mis-files the
|
||||
record permanently.* **The heading won; it is recorded in `cart.usages.md`.**
|
||||
|
||||
## 2026-09-08 — declined: a round on the third-file question `rowan`
|
||||
|
||||
**Not opened, and not sent upstream as a gap either.** *Left open in
|
||||
`cart.gaps.md` where it was filed.*
|
||||
|
||||
**Because it may not be a category.** *Ours appeared because a round was converted
|
||||
while somebody was still writing, which the agreement already forbids.* **A third
|
||||
file that only ever arrives as a conversion-timing failure has its fix upstream**,
|
||||
*and the choice between moving it and rejecting it never has to be made.*
|
||||
|
||||
> **Before writing the rule, count how many arrive any other way.**
|
||||
|
||||
*One instance is not a sample* — **which is the reason `cart/v2` gave for leaving
|
||||
it alone, applied one step further.**
|
||||
|
||||
## 2026-09-08 — `orient`, and declined: a second orientation file `sorrel`
|
||||
|
||||
**Decided:** *one generated file, `.loom/orientation.md`, for whoever arrives
|
||||
next.* **It restates no rule** — *every rule it might repeat is owned by a
|
||||
document already in the working tree, and a copy of a rule is a copy that goes
|
||||
stale.*
|
||||
|
||||
**Declined: splitting it into an externals half and a publishing half.** *The
|
||||
splitting rule is **generate what varies, adopt what does not*** — **and the
|
||||
publishing half varies not at all and is already adopted**, *so `publication.md`
|
||||
appears in the index like any other adopted document, in exactly the repositories
|
||||
that adopted it.* **A second command would emit a file whose whole content is a
|
||||
pointer to a file already in the tree**, *and would revive the `published` noun
|
||||
that went with `published check`.*
|
||||
|
||||
**Declined: `.loom/externals/README.md` as the path**, *which was measured rather
|
||||
than argued:*
|
||||
|
||||
```
|
||||
$ loomctl external check
|
||||
README.md unlocked no origin recorded — ...
|
||||
```
|
||||
|
||||
**`check` walks that directory for adopted documents, so a generated file there
|
||||
reports as unlocked in every repository using the feature, forever.** *And the
|
||||
deeper reason is the invariant: everything under `.loom/externals/` is somebody
|
||||
else's document, which is what makes "do not edit these" a rule you can state in
|
||||
four words.*
|
||||
|
||||
**The `.usages.md` is pointed at, not summarised.** *What depends on a document is
|
||||
free prose, and anything that extracts a claim from prose will eventually extract
|
||||
it wrong.*
|
||||
|
||||
**Belief that could be shown wrong:** *that a fixed path is worth more than a
|
||||
configured one.* **It only pays if it is the same everywhere**, *which is not ours
|
||||
to decide and is with `bedrock`.*
|
||||
|
||||
## 2026-09-08 — a probe is not a fetch, and confusing them clears alarms `sorrel`
|
||||
|
||||
**Recorded because the bug was one line and the trap is permanent.**
|
||||
|
||||
*Visibility was computed by one function used in two places.* **At `add` time,
|
||||
"no credential is configured" means the fetch that just succeeded was anonymous,
|
||||
so the source is public** — *sound.* **In the audit, nothing is being fetched:
|
||||
"no credential configured" says nothing about whether a stranger could read the
|
||||
URL**, *and returning `public` there would have **silently cleared real
|
||||
alarms**.*
|
||||
|
||||
> **The worst failure available to a check is to report that it found nothing.**
|
||||
|
||||
**Probing is now its own function that always asks with no credential at all**,
|
||||
*because what matters is what a stranger can read and not what we can.* **Found by
|
||||
testing the alarm path rather than after shipping it.**
|
||||
|
||||
## 2026-09-08 — `orient`, not `prompt`, and why that was mine to notice `sorrel`
|
||||
|
||||
**Renamed before it shipped.** *`prompt` reads as "produce something to feed a
|
||||
model", and the file is a table of contents a person can read.*
|
||||
|
||||
**The correction was right and the assumption behind it was mine:** *`prompt` was
|
||||
not being used in the narrow sense, and I took it at the narrowest reading
|
||||
available.* **Second time in two days** — *the first was reading `others` as
|
||||
`everyone` in `publication.md`, which cost a command that was built, validated
|
||||
three times, and then declined.*
|
||||
|
||||
*Recorded as a working note rather than a decision: **when a word could be read
|
||||
narrowly or broadly, this repository has evidence that I pick narrow.***
|
||||
|
||||
## 2026-09-08 — published: what the tool guarantees `tansy`
|
||||
|
||||
**`.loom/published/guarantees.md`**, *and step 4 of `publication` was run: `200`
|
||||
anonymously, off no credential of ours.*
|
||||
|
||||
**The earlier decision did not reverse; its condition fired.** *`.loom/published/`
|
||||
was recorded as deferred rather than declined, on the condition that somebody
|
||||
depends on the tool's behaviour rather than reading its help.* **A docs site whose
|
||||
build calls `add`, `check` and `apply` is that consumer.**
|
||||
|
||||
*The delta is not the command list — that is a copy of what the code owns.*
|
||||
**`--help` says what the tool does; a published page says what will keep being
|
||||
true**, *and a reader cannot learn from a binary which of its behaviours survives
|
||||
the next version.*
|
||||
|
||||
**The page includes what is not promised**, *which is the half a reimplementation
|
||||
needs most:* **the command surface, the orientation file's format across
|
||||
versions, the precision of the visibility signal, and that anything is checked
|
||||
when nobody is looking.**
|
||||
|
||||
## 2026-09-08 — we do not promise the surface; we promise the change lands `tansy`
|
||||
|
||||
**A consumer's README tells a person to type `loomctl external add loom/<repo>
|
||||
<doc>.md`.** *Nothing parses it — which is exactly why no check would catch a
|
||||
rename.* **The thing that moved would not be a document anybody adopted.**
|
||||
|
||||
**Promising the surface would freeze the CLI. So the promise is narrower and
|
||||
runs:**
|
||||
|
||||
> **We do not promise the surface holds. We promise a change to it lands on the
|
||||
> page you already lock.**
|
||||
|
||||
*`guarantees.md` carries an append-only `Surface changes` section*, **so a rename
|
||||
reports `200` to every consumer the day it happens.**
|
||||
|
||||
**Belief that could be shown wrong:** *that anybody remembers to write in it.*
|
||||
**Nothing enforces the section** — *the same weakness as a facet nobody updates.*
|
||||
|
||||
## 2026-09-08 — two ETags can be compared and never ordered `tansy`
|
||||
|
||||
**Found by accident, and it is a consequence of a rule we published this
|
||||
morning.**
|
||||
|
||||
*The docs site stamped a hand-written guide with the `ETag` it was read against,
|
||||
and its banner said `written against an older guarantees`.* **It was backwards:
|
||||
the guide was ahead of the site's copy, not behind.**
|
||||
|
||||
**And it cannot be fixed by trying harder.** *An `ETag` is opaque — that is the
|
||||
point of it, and we guarantee we never interpret one.* **So a consumer can know
|
||||
that two versions disagree and can never know which is behind.**
|
||||
|
||||
*The banner now claims no direction.* **Anybody building freshness on this
|
||||
inherits the same limit**, *and it belongs in the log because it follows from the
|
||||
guarantee rather than from anybody's code.*
|
||||
|
||||
*Consequence nobody predicted: reconciling cleared the banner with no
|
||||
acknowledgement, which is correct — an acknowledgement is for a guide that is
|
||||
behind, and this one was ahead.*
|
||||
|
||||
## 2026-09-08 — compacted, and it barely compressed `tansy`
|
||||
|
||||
**A compaction is itself a decision, so this is the entry `recording-decisions`
|
||||
asks for.** *Six entries dropped, fifty kept. The uncompacted log is in git
|
||||
history, which is where a reader who needs it goes.*
|
||||
|
||||
**Dropped, each because a competent reader can now recover it from the artifact,
|
||||
and here is which artifact:**
|
||||
|
||||
- *`what this tool is`* — **`.loom/published/guarantees.md` and `README.md`**
|
||||
- *`the lock, and what it holds`* — **`guarantees.md` documents the format**
|
||||
- *`unlocked is a state`* — **`guarantees.md`, and `add` refuses in code**
|
||||
- *`--help is the documentation surface`* — **`README.md` says so and points there**
|
||||
- *`the lock records visibility, and the word it will not use`* — **`guarantees.md`
|
||||
carries the field and the refusal of the word `private`**
|
||||
- *`conversion now destroys, so the log is the only record`* — **`cart/v2`, adopted
|
||||
here, and `cart.usages.md` names `wal.md` as the mechanism**
|
||||
|
||||
> **Every one of them became droppable by being published.** *Nothing was
|
||||
> summarised away; the content moved into a document somebody else can lock, which
|
||||
> is a stronger place for it than a log only we read.*
|
||||
|
||||
## What did not compress, and why that is the finding
|
||||
|
||||
**Fifty entries survived, and the reason is the rule rather than sentiment.**
|
||||
*Almost all of them are one of the four kinds that may never be dropped:*
|
||||
|
||||
**Declines** — *`init`, `published check`, a separate `adopt` verb, a plugin
|
||||
boundary, an audience declaration, reference-only adoption, resolving `404` over
|
||||
ssh, a second orientation file, worktrees, a spec, fixing `annotating`.* **Not one
|
||||
is recoverable from the code, because the code is what they are absent from.**
|
||||
|
||||
**Measurements** — *the `ETag` is a blob hash on gitea and not on GitHub; three
|
||||
hosts and one partial clone; only gitea resolves its own short raw URL; a refused
|
||||
write names the token's scopes; a private repository over HTTPS with a read
|
||||
token.* **Facts about other people's systems, which no artifact here holds.**
|
||||
|
||||
**Supersessions** — *publication is not publicness; a cart is not committed; `add`
|
||||
does not replace; `git` is not needed for `list` only; the private path is
|
||||
exercised.* **Each is a change of mind, which is most of what a reader wants.**
|
||||
|
||||
**Beliefs shown wrong** — *the specimen's hash-as-lock; `others` read as
|
||||
`everyone`; a probe treated as a fetch; a banner that claimed a direction two
|
||||
opaque strings cannot support.*
|
||||
|
||||
> **The log is long because the work was exploratory, not because it is padded.**
|
||||
> *A project that mostly built things would compact to a page. This one mostly
|
||||
> found out it was wrong about things, and that is the part the code cannot
|
||||
> hold.*
|
||||
|
||||
**Belief that could be shown wrong:** *that fifty is readable.* **It is today.**
|
||||
*The next remedy is not another compaction — it is the guide `bedrock` needed,
|
||||
and it is not needed yet.*
|
||||
|
||||
## 2026-09-08 — a `404` at adoption has a third reading, and it is resolvable
|
||||
|
||||
**`externals` names two readings of a `404` — withdrawn, or access lost — and
|
||||
they are the two a **locked** document can have.** *Adoption by name has a third:
|
||||
**a document that was never there under that name**.*
|
||||
|
||||
*Found in use: `loomctl external add jeffry/homelab-cluster database` reported the
|
||||
ambiguity for what was a missing `s`.*
|
||||
|
||||
**The tool was reporting an ambiguity it had the means to resolve.** *For the
|
||||
by-name form it knows the repository, so on a `404` it now lists the published
|
||||
surface:*
|
||||
|
||||
```
|
||||
loomctl: jeffry/homelab-cluster publishes no "database.md". It publishes:
|
||||
databases.md gitea.md private-access.md ...
|
||||
```
|
||||
|
||||
**And it only claims that when the listing succeeds.** *If listing fails too, the
|
||||
repository itself is unreachable and the original ambiguity is the honest
|
||||
answer* — **the same discipline as reporting `public` and `not-public` rather than
|
||||
`private`: say the thing you verified.**
|
||||
|
||||
**Not a gap against `externals`.** *The third reading cannot occur where that
|
||||
document is speaking, which is `check` against a lock.* **It appears only at
|
||||
adoption, which is ours.**
|
||||
|
||||
**Belief that could be shown wrong:** *that an extra round trip on a failure is
|
||||
free.* **It is one partial clone on a path nobody takes twice**, *and if adoption
|
||||
against large repositories becomes common the listing should be bounded.*
|
||||
|
||||
Vendored
+13
-11
@@ -1,12 +1,14 @@
|
||||
# loomctl locks — one record per adopted document.
|
||||
# path<TAB>url<TAB>etag The url is resolved: a short form would follow
|
||||
# whatever the default branch is at the time you ask.
|
||||
git.hypertheory-labs.dev/jeffry/homelab-cluster/gitea.md https://git.hypertheory-labs.dev/jeffry/homelab-cluster/raw/branch/main/.loom/published/gitea.md "530c5bef62bbd325956ed170bb2decf37975e4b9"
|
||||
git.hypertheory-labs.dev/loom/annotating/annotating.md https://git.hypertheory-labs.dev/loom/annotating/raw/branch/main/.loom/published/annotating.md "9b1f7e6ca92f2339b2d433686c27845362944bdb"
|
||||
git.hypertheory-labs.dev/loom/bedrock/loom-directory.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/loom-directory.md "ee0f49cb900c0812678061971194325d9cba366a"
|
||||
git.hypertheory-labs.dev/loom/bedrock/publication.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/publication.md "eb0cb63629a36b056f215dfbe24567d1918cec38"
|
||||
git.hypertheory-labs.dev/loom/bedrock/recording-decisions.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/recording-decisions.md "970d4b4da76aac99c9b1f1b6580ec20daa42e329"
|
||||
git.hypertheory-labs.dev/loom/bedrock/sibling-facets.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/sibling-facets.md "a46446a34ccb8bfc533d3cce19f4c88548c4fa04"
|
||||
git.hypertheory-labs.dev/loom/bedrock/starting.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/starting.md "7d997a30248a88c90b23f2f9453d7cfceb03848e"
|
||||
git.hypertheory-labs.dev/loom/cart/cart.md https://git.hypertheory-labs.dev/loom/cart/raw/branch/main/.loom/published/cart.md "49fc852bdd280293f0f5e0050034e3b89ff7255e"
|
||||
git.hypertheory-labs.dev/loom/externals/externals.md https://git.hypertheory-labs.dev/loom/externals/raw/branch/main/.loom/published/externals.md "a7586eb52caf275d9bcedbbd8042c43e5aaad0b9"
|
||||
# path<TAB>url<TAB>etag[<TAB>visibility]
|
||||
# The url is resolved: a short form would follow whatever the default branch
|
||||
# is at the time you ask. visibility is what the source could be read as when
|
||||
# it was fetched, because that is checked once and the copy is durable.
|
||||
git.hypertheory-labs.dev/jeffry/homelab-cluster/gitea.md https://git.hypertheory-labs.dev/jeffry/homelab-cluster/raw/branch/main/.loom/published/gitea.md "530c5bef62bbd325956ed170bb2decf37975e4b9" public
|
||||
git.hypertheory-labs.dev/loom/annotating/annotating.md https://git.hypertheory-labs.dev/loom/annotating/raw/branch/main/.loom/published/annotating.md "9b1f7e6ca92f2339b2d433686c27845362944bdb" public
|
||||
git.hypertheory-labs.dev/loom/bedrock/loom-directory.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/loom-directory.md "ee0f49cb900c0812678061971194325d9cba366a" public
|
||||
git.hypertheory-labs.dev/loom/bedrock/publication.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/publication.md "c0d9231aa8eed3041c60b72da5735585457fbc90" public
|
||||
git.hypertheory-labs.dev/loom/bedrock/recording-decisions.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/recording-decisions.md "d190eeae7929b9a99ecf09211d94e7e5b32635bd" public
|
||||
git.hypertheory-labs.dev/loom/bedrock/sibling-facets.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/sibling-facets.md "a46446a34ccb8bfc533d3cce19f4c88548c4fa04" public
|
||||
git.hypertheory-labs.dev/loom/bedrock/starting.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/starting.md "b9eefba0f4668a496ccfc6a1377277f0721456d2" public
|
||||
git.hypertheory-labs.dev/loom/cart/cart.md https://git.hypertheory-labs.dev/loom/cart/raw/branch/main/.loom/published/cart.md "15331f1a9cc81bf61a44830cfbb7c274f4c2b119" public
|
||||
git.hypertheory-labs.dev/loom/externals/externals.md https://git.hypertheory-labs.dev/loom/externals/raw/branch/main/.loom/published/externals.md "50673ccffc14d57150ac0a9027b0712d9dcf940d"
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# Gaps — `publication`
|
||||
|
||||
## Whether publishing implies that the audience can read it
|
||||
|
||||
**The document says publishing is a change of kind and that `.loom/published/` is
|
||||
what a repository has handed over for others to depend on.** *It does not say that
|
||||
the handing over must succeed.*
|
||||
|
||||
**We built a command on the assumption that it meant *anyone* could fetch it, and
|
||||
that was wrong** — *others is not everyone, and a repository may publish to a
|
||||
private audience deliberately.* **But the opposite is not addressed either:
|
||||
nothing here says that a document in `published/` which nobody in its intended
|
||||
audience can fetch has not been published.**
|
||||
|
||||
*The sentence we needed is in a sibling and not here:* **publishing is not an act
|
||||
you can complete alone.** *It appears as an aside about a tool, not as a property
|
||||
of publication.*
|
||||
|
||||
*Local answer: we declined to build the check, on the belief that a registry with
|
||||
named consumers answers it better than a probe can. **The convention still does not
|
||||
say whether an unfetchable publication is a publication.***
|
||||
|
||||
## Where confidentiality lives
|
||||
|
||||
**`externals` now says confidentiality does not travel with the copy.** *That rule
|
||||
is about adopting, and it exists because of a property of publishing* — **what a
|
||||
publisher may safely put in `published/` depends on who can read the repository it
|
||||
is in**, *and this document, which is where publishing is defined, does not
|
||||
mention visibility at all.*
|
||||
@@ -15,6 +15,43 @@ should be unmissable.*
|
||||
**`ls .loom/published/` is *what have we committed to?*** — and it is the question
|
||||
to ask before changing anything.
|
||||
|
||||
## Publishing is not an act you can complete alone
|
||||
|
||||
**A file in `.loom/published/` that its intended readers cannot fetch is not
|
||||
published.** *The move is the act, but the act is not finished until somebody who
|
||||
is not you can perform the read.*
|
||||
|
||||
> **The failure is invisible from the inside.** *Every check available to the
|
||||
> publisher passes — the file is there, the commit landed, the path is right.*
|
||||
> **Only a reader can detect it, and a reader who cannot fetch it does not know
|
||||
> the document exists to ask about.**
|
||||
|
||||
*The commonest form is a document that answers "why can you not see this?" and is
|
||||
published **inside** the thing that cannot be seen.*
|
||||
|
||||
**So publication has a visibility, and it is a property of the act rather than of
|
||||
the file.** *"Others" is not "everyone".* **Naming who the readers are is part of
|
||||
publishing**, because a repository that depends on this one has to know whether it
|
||||
is inside that set — *and because a reader who copies a document out inherits the
|
||||
copy without inheriting the visibility it was published under.*
|
||||
|
||||
**The test is a fetch you did not perform.**
|
||||
|
||||
## Doing it
|
||||
|
||||
1. **Decide it is a contract and not a detail.** *This is the whole decision and
|
||||
the rest is mechanics.*
|
||||
2. **Move the file into `.loom/published/`.** *One commit, so the diff carries it.*
|
||||
3. **Say who the readers are** — *if it is not obvious from where it landed.*
|
||||
4. **Fetch it.** *From somewhere that is not your machine and not your account.*
|
||||
5. **Record the decision**, if you keep a log.
|
||||
|
||||
**Step 4 is the one that gets skipped and the only one that can fail.** *Steps 1
|
||||
to 3 are things you do; step 4 is a fact about the world that you find out.*
|
||||
|
||||
*Then say what it depends on. A published document that quietly requires a private
|
||||
one has moved the problem rather than solved it.*
|
||||
|
||||
## What is published is a document
|
||||
|
||||
**Prose, in whatever form its readers need.** *If a repository organises its
|
||||
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
# Gaps — `recording-decisions`
|
||||
|
||||
## Whether a log may ever be compacted, and what compaction may not touch
|
||||
|
||||
**The document says entries are appended, newest last, and never revised** — *"a
|
||||
revised record cannot show that anybody changed their mind, which is most of what
|
||||
a reader wants from it."*
|
||||
|
||||
**It also acknowledges no upper bound.** *Ours reached about thirty entries in a
|
||||
day across three rounds, and `bedrock`'s own README exists because its log needed
|
||||
a guide.* **At some size the log stops being readable, and the only remedies are
|
||||
revision, which this forbids, or a guide, which is a second document that can
|
||||
drift.**
|
||||
|
||||
**The gap is not "may we compact".** *It is that **"never revised" and "somebody
|
||||
must be able to read it" both hold and eventually conflict**, and the document
|
||||
does not say which gives.*
|
||||
|
||||
*Local answer, provisional: **compaction is allowed and git history is where the
|
||||
uncompacted log lives.*** *An entry may be dropped when a competent reader could
|
||||
recover it by reading the code.* **An entry may never be dropped when it records a
|
||||
decline, a measurement, a belief that was shown wrong, or one entry superseding
|
||||
another** — *because those are precisely the record of somebody changing their
|
||||
mind, and dropping them is the failure this document names.*
|
||||
|
||||
> **Which means compaction is safe in exactly the cases where the entry was
|
||||
> redundant with the artifact, and unsafe in exactly the cases the log exists
|
||||
> for.**
|
||||
@@ -24,6 +24,29 @@ reads.*
|
||||
you already rejected — because the rejection lived somewhere that did not
|
||||
survive.*
|
||||
|
||||
## Compaction, and the entries it may never touch
|
||||
|
||||
**"Never revised" and "somebody must be able to read it" both hold, and far
|
||||
enough out they conflict** — *a log nobody can get through has stopped being a
|
||||
record.*
|
||||
|
||||
**Compaction is allowed. Dropping an entry is not the same as revising one**, and
|
||||
the uncompacted log is in git history, which is where a reader who needs it goes.
|
||||
|
||||
> **An entry may be dropped when a competent reader could recover it by reading
|
||||
> the artifact.**
|
||||
>
|
||||
> **An entry may never be dropped when it records a decline, a measurement, a
|
||||
> belief that was shown wrong, or one entry superseding another.**
|
||||
|
||||
*Which means **compaction is safe in exactly the cases where the entry was
|
||||
redundant with the artifact, and unsafe in exactly the cases the log exists
|
||||
for.*** **The four protected kinds are the ones with no other home:** *the code
|
||||
shows what was built and can never show what was refused, what was measured, what
|
||||
turned out false, or that somebody changed their mind.*
|
||||
|
||||
**A compaction is itself a decision, and gets an entry.**
|
||||
|
||||
## Where it lives
|
||||
|
||||
`.loom/event-log.md`. **A stream, so a file.**
|
||||
|
||||
@@ -60,13 +60,16 @@ what they came for.*
|
||||
|
||||
## Look at one instead of reading this
|
||||
|
||||
**There is a worked example — six documents, one gap, no decomposition — and it
|
||||
is private.**
|
||||
**[`jeffry/homelab-cluster`](https://git.hypertheory-labs.dev/jeffry/homelab-cluster)** —
|
||||
*six documents, one gap, no decomposition.* **It describes what a home cluster
|
||||
offers to somebody deciding whether to put something on it**, and a reader who
|
||||
has never heard of any of this still gets what they came for.
|
||||
|
||||
*It describes a cluster in enough detail to be a target list, so it is not
|
||||
published, and **this page will not link you to something you cannot fetch.***
|
||||
**If you have access, ask for it by name; if you do not, the two questions at the
|
||||
top are the whole of it.**
|
||||
**It was private, and being unable to link to it is what made the split obvious.**
|
||||
*The operational tree is a version inventory and a target list; the pages telling
|
||||
somebody what to decide are neither.* **So the manifests moved to a private
|
||||
sibling and the documents stayed** — *and the documents got better for being
|
||||
written to a stranger, which was not the reason for doing it.*
|
||||
|
||||
> **A public page naming a private thing as its canonical answer is worse than no
|
||||
> example**, and this page did exactly that until somebody measured it.
|
||||
|
||||
@@ -44,19 +44,17 @@ been written.*
|
||||
presence's daily* — **but "move it" and "reject it" are different acts with
|
||||
different costs**, *and picking one was ours to invent.*
|
||||
|
||||
## What must be extracted before converting, now that conversion destroys
|
||||
**Added after the fact, and it may dissolve the gap rather than answer it:** *ours
|
||||
appeared because the round was converted while somebody was still writing.* **The
|
||||
agreement already has a rule against that** — *do not convert while somebody is
|
||||
mid-thought* — **so this third file was a symptom and not a category.**
|
||||
|
||||
**`v1` makes the cart untracked. So converting deletes the dailies outright**,
|
||||
*where every earlier conversion left them in git.*
|
||||
> **Before writing a rule for third files, count how many arrive any other way.**
|
||||
> *If they are all conversion-timing failures, the fix is upstream of here and the
|
||||
> two acts never have to be chosen between.*
|
||||
|
||||
**The write-ahead log is named in `v1` but not required**, *and nothing says that
|
||||
at conversion the cart is the only copy.* **Two rules now both push toward loss** —
|
||||
*act as if the shelf is discarded daily, and the cart is not committed* — **and
|
||||
neither says what has to be written down first.**
|
||||
|
||||
*Local answer: a `wal.md` in the cart, staged as we go, so converting is a move
|
||||
rather than a rewrite. **We would not have thought of it if it had not been
|
||||
suggested.***
|
||||
*One instance is not a sample. **We would rather leave this open than freeze a
|
||||
rule out of a single case**, which is the same reason `v2` did not answer it.*
|
||||
|
||||
## Whether an unanswered `Affirm` and a fallback are the same silence
|
||||
|
||||
|
||||
+29
-1
@@ -1,6 +1,6 @@
|
||||
# Agreement — the cart
|
||||
|
||||
**v1.** Depends on `annotating/v0`.
|
||||
**v2.** Depends on `annotating/v0`.
|
||||
|
||||
**How two parties work out what something means before one of them changes it.**
|
||||
|
||||
@@ -92,6 +92,34 @@ words.** That costs precision and buys something better:
|
||||
> **Pinning a defect in place lets you be exact without having understood.
|
||||
> Restating surfaces the misunderstanding that pinning hides.**
|
||||
|
||||
## Converting destroys, so extract first
|
||||
|
||||
**The cart is not committed. So conversion does not archive it — it deletes it.**
|
||||
*Every earlier version of this agreement was wrong about that by accident: a
|
||||
committed cart left its dailies in history, and "gone" was only ever true of the
|
||||
tree.*
|
||||
|
||||
> **At conversion the cart is the only copy of everything in it.** *The dailies,
|
||||
> the annotations, the answers to every prompt, and the write-ahead log.*
|
||||
|
||||
**So before converting, extract:**
|
||||
|
||||
- **every answer to a `Question`, `Select` or `Affirm`** — *an answer lands in a
|
||||
notes file, a notes file dies at dissolve, and an untracked cart has no history
|
||||
to fall back on.* **A prompt answered and not extracted was never answered.**
|
||||
- **every write-ahead entry that survived** — *promote or discard, and both are
|
||||
acts.*
|
||||
- **every fallback that quietly became the decision** — *say so in the entry, in
|
||||
those words.*
|
||||
|
||||
**Staging as you go turns conversion into a move rather than a rewrite.** *Keep
|
||||
the write-ahead log current during the round and there is nothing to reconstruct
|
||||
at the end, which is when you have least appetite for it.*
|
||||
|
||||
*Two rules in this agreement now both push toward loss — **act as if the shelf is
|
||||
discarded daily**, and **the cart is not committed.** They are both right and
|
||||
together they mean **nothing in a cart is safe by default.***
|
||||
|
||||
## Conversion emits two things
|
||||
|
||||
**The artifact** — the code, the decision, the change — **and the declines.**
|
||||
|
||||
@@ -64,3 +64,37 @@ no archive*, **so dissolving a notes file destroys the annotations outright.**
|
||||
|
||||
*Both documents are loom's. **Recorded here because the conflict is visible from
|
||||
inside an adopter and not from inside either document.***
|
||||
|
||||
---
|
||||
|
||||
## Cast against `v2`: extract before converting
|
||||
|
||||
**`v2` adds no role.** *It adds an obligation at conversion, and this file is
|
||||
where an adopter looks for what this repository does about it.*
|
||||
|
||||
**The write-ahead log is `wal.md`, in the cart, staged during the round.** *Cast
|
||||
2026-09-07. It existed here before `v2` named it, as a local answer to the gap
|
||||
`v2` closed.*
|
||||
|
||||
> **What comes out before the `rm`:** *every answer to a `Question`, `Select` or
|
||||
> `Affirm`; every surviving write-ahead entry; and every fallback that quietly
|
||||
> became the decision, said in those words.* **Destination is
|
||||
> `.loom/event-log.md`**, *which is the durable record cast at the top of this
|
||||
> file.*
|
||||
|
||||
**The status of our workaround changed and the code did not.** *Staging as we went
|
||||
was a thing we invented and would not have thought of unaided.* **It is now
|
||||
specified behaviour** — *unchanged in practice, entirely changed in standing.*
|
||||
|
||||
## Still uncast, because the document does not ask
|
||||
|
||||
**Nothing here says what a cart's `name` file must agree with.** *This round
|
||||
arrived as a daily headed `rowan`, in a file called `loom-osprey.md`, with no
|
||||
`name` file at all.* **The name is what tags durable entries** — *"everything
|
||||
tagged with this name was considered in a single sphere of understanding"* — **so a
|
||||
disagreement between the heading and the file mis-files the record, silently and
|
||||
permanently.**
|
||||
|
||||
*Local answer: **the daily's own heading wins**, `name` is written to match, and
|
||||
the file is renamed.* **Recorded here rather than filed as a gap**, *because it may
|
||||
just be somebody typing fast.*
|
||||
|
||||
@@ -62,6 +62,7 @@ what to decide are not.**
|
||||
|
||||
- **where it was fetched from** — *resolved, as above*
|
||||
- **the publisher's `ETag`** — *verbatim*
|
||||
- **the visibility the source could be read at, when it was fetched**
|
||||
|
||||
**It is committed**, because the thing it locks is committed, and *a lock that
|
||||
travels separately from what it locks is the drift this is meant to prevent.*
|
||||
@@ -71,6 +72,26 @@ travels separately from what it locks is the drift this is meant to prevent.*
|
||||
> asserts your copy is the one being served, which is the thing you were going to
|
||||
> check.
|
||||
|
||||
### Why visibility is in the lock and not left to the moment
|
||||
|
||||
**Access is checked once and the copy is durable.** *A token gets you the read;
|
||||
nothing revisits it afterwards.* **So the adoption's legitimacy rests on the
|
||||
relative visibility of two repositories — and that is changeable by a checkbox, a
|
||||
year later, by somebody who never saw the adoption.**
|
||||
|
||||
> **Record `public` or `not-public`. Never `private`.** *An anonymous request can
|
||||
> tell you a repository is not public. It cannot tell two repositories private to
|
||||
> **different people** apart* — **which is exactly the case where private into
|
||||
> private genuinely widens access.** *Record the word you can verify.*
|
||||
|
||||
**Checking it costs one request per run, not one per document**, *because the only
|
||||
thing that must be current is **your own** visibility.* **Re-check a source only
|
||||
when the alarm would fire** — *you are public, it was recorded not-public* — *and
|
||||
if it has since gone public, update the lock and say nothing.*
|
||||
|
||||
*A stored visibility decays in both directions, which is why the alarm re-checks
|
||||
rather than trusting the record.*
|
||||
|
||||
## Freshness is a conditional request
|
||||
|
||||
**Locked on the publisher's `ETag`, verbatim — never a hash you compute.** *A
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<!-- Generated by `loomctl orient`. Safe to overwrite; nobody typed it.
|
||||
Regenerate after adopting a document or reconciling a change. -->
|
||||
|
||||
# What this repository depends on
|
||||
|
||||
Copies of other people's documents are kept under `.loom/externals/`, at a path
|
||||
that says where each came from. **They are copies: do not edit them.** Anything
|
||||
you want to say about one goes in a file *beside* it, never into it.
|
||||
|
||||
Three moves, and each has a document that owns the rule:
|
||||
|
||||
- **A copy is wrong, or you needed something it does not say** — write it in
|
||||
`<name>.gaps.md` beside the copy.
|
||||
- **What of ours depends on a copy** — write it in `<name>.usages.md` beside it.
|
||||
- **A source changed** — `loomctl external check` says so and stages the new copy;
|
||||
`loomctl external apply` takes it. Neither edits anything on its own.
|
||||
|
||||
## `git.hypertheory-labs.dev/jeffry/homelab-cluster/gitea.md`
|
||||
|
||||
- source: https://git.hypertheory-labs.dev/jeffry/homelab-cluster/raw/branch/main/.loom/published/gitea.md
|
||||
- what of ours depends on it: `.loom/externals/git.hypertheory-labs.dev/jeffry/homelab-cluster/gitea.usages.md`
|
||||
|
||||
## `git.hypertheory-labs.dev/loom/annotating/annotating.md`
|
||||
|
||||
- source: https://git.hypertheory-labs.dev/loom/annotating/raw/branch/main/.loom/published/annotating.md
|
||||
- what of ours depends on it: **nothing recorded** — no `.usages.md`
|
||||
- what we expected and did not find: `.loom/externals/git.hypertheory-labs.dev/loom/annotating/annotating.gaps.md`
|
||||
|
||||
## `git.hypertheory-labs.dev/loom/bedrock/loom-directory.md`
|
||||
|
||||
- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/loom-directory.md
|
||||
- what of ours depends on it: **nothing recorded** — no `.usages.md`
|
||||
|
||||
## `git.hypertheory-labs.dev/loom/bedrock/publication.md`
|
||||
|
||||
- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/publication.md
|
||||
- what of ours depends on it: **nothing recorded** — no `.usages.md`
|
||||
|
||||
## `git.hypertheory-labs.dev/loom/bedrock/recording-decisions.md`
|
||||
|
||||
- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/recording-decisions.md
|
||||
- what of ours depends on it: **nothing recorded** — no `.usages.md`
|
||||
|
||||
## `git.hypertheory-labs.dev/loom/bedrock/sibling-facets.md`
|
||||
|
||||
- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/sibling-facets.md
|
||||
- what of ours depends on it: **nothing recorded** — no `.usages.md`
|
||||
|
||||
## `git.hypertheory-labs.dev/loom/bedrock/starting.md`
|
||||
|
||||
- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/starting.md
|
||||
- what of ours depends on it: **nothing recorded** — no `.usages.md`
|
||||
|
||||
## `git.hypertheory-labs.dev/loom/cart/cart.md`
|
||||
|
||||
- source: https://git.hypertheory-labs.dev/loom/cart/raw/branch/main/.loom/published/cart.md
|
||||
- what of ours depends on it: `.loom/externals/git.hypertheory-labs.dev/loom/cart/cart.usages.md`
|
||||
- what we expected and did not find: `.loom/externals/git.hypertheory-labs.dev/loom/cart/cart.gaps.md`
|
||||
|
||||
## `git.hypertheory-labs.dev/loom/externals/externals.md`
|
||||
|
||||
- source: https://git.hypertheory-labs.dev/loom/externals/raw/branch/main/.loom/published/externals.md
|
||||
- what of ours depends on it: `.loom/externals/git.hypertheory-labs.dev/loom/externals/externals.usages.md`
|
||||
|
||||
## This repository publishes
|
||||
|
||||
See `.loom/published/`. What is handed over for others to depend on is
|
||||
there; the rest of the repository is not hidden, it is simply not what
|
||||
anybody depends on.
|
||||
@@ -0,0 +1,114 @@
|
||||
# What `loomctl` guarantees
|
||||
|
||||
**Not what it does — `--help` says that, and the commands will change.** *This is
|
||||
what will keep being true while they do.*
|
||||
|
||||
---
|
||||
|
||||
## It grants no access, and records where the copy came from
|
||||
|
||||
**`loomctl` reads what your credentials already let you read.** *Everything it
|
||||
does is possible with copy and paste.* **It is a mast, not a lock** — *the point
|
||||
is to make the wrong thing deliberate, not impossible.*
|
||||
|
||||
> **What it adds over a paste is provenance.** *A pasted document cannot answer
|
||||
> "where did this come from, and were we allowed to have it" — not because the
|
||||
> question is hard, but because the evidence is gone.*
|
||||
|
||||
## It never writes over the network
|
||||
|
||||
**No push, no publish, no `POST`, no token that needs write scope.** *Every byte
|
||||
it writes is a file in your working tree.* **Committing and pushing are yours**,
|
||||
*because the consequences of a push land on people a tool cannot experience.*
|
||||
|
||||
**A credential given to `loomctl` should never carry write scope**, *and if one
|
||||
does, nothing here will use it.*
|
||||
|
||||
## Freshness is a conditional request against the publisher's `ETag`
|
||||
|
||||
**Stored verbatim, opaque, never a hash we compute.** *A fetch that normalises
|
||||
anything breaks a local digest and reports a change that did not happen.*
|
||||
|
||||
*This is `externals`' rule and `loomctl` is a second holder of it. **It is stated
|
||||
here so that the tool holding it is a fact somebody can find**, and so that
|
||||
breaking it is visible rather than silent* — **a specimen was written in this
|
||||
repository that computed a hash instead, and it was wrong within a day.**
|
||||
|
||||
**The URL in a lock is resolved.** *A short form follows whatever the default
|
||||
branch is at the time you ask, so a branch rename would report as a change in the
|
||||
document.*
|
||||
|
||||
## It reports; it does not repair
|
||||
|
||||
**Nothing is overwritten.** *A document that moved upstream is written to a
|
||||
staging area as a candidate, and taking it is a separate act.* **A local copy
|
||||
that differs from what the publisher serves is left alone** — *it is the only
|
||||
evidence that something changed while nothing was watching.*
|
||||
|
||||
**`add` adopts what is absent and refuses what is already adopted.** *A document
|
||||
that is present but unlocked is locked only when the bytes are identical to what
|
||||
the publisher serves*, **so a lock's claim — this copy is the one being served —
|
||||
is verified rather than assumed.**
|
||||
|
||||
## The lock file
|
||||
|
||||
**`.loom/externals/.locks`, one record per adopted document, tab-separated,
|
||||
ordered by path.**
|
||||
|
||||
```
|
||||
path <TAB> url <TAB> etag [ <TAB> visibility ]
|
||||
```
|
||||
|
||||
- **`path`** — *relative to `.loom/externals/`, and **for a person to read**.* **It
|
||||
does not round-trip to a URL**; *it drops the route, the branch, and the
|
||||
publisher's `.loom/published/`.*
|
||||
- **`url`** — *the resolved origin, branch and all.*
|
||||
- **`etag`** — *the publisher's, verbatim, including its quotes.*
|
||||
- **`visibility`** — *optional. What the source could be read as **when it was
|
||||
fetched**: `public` or `not-public`.* **Never `private`** — *an anonymous
|
||||
request tells those two apart and nothing finer.*
|
||||
|
||||
**Records with three fields remain valid.** *A repository does not stop working
|
||||
because the tool learned something new.*
|
||||
|
||||
## What is not promised
|
||||
|
||||
**The command surface.** *Names, flags and output are `--help`'s business and may
|
||||
change. Nothing should parse them.*
|
||||
|
||||
**The generated orientation file's format.** *It is byte-deterministic within a
|
||||
version so that its diff is readable; it is not stable across versions.*
|
||||
|
||||
**That visibility is precise.** *The signal distinguishes public from not-public
|
||||
and nothing finer, so it cannot see two repositories private to different people
|
||||
— which is the case where adopting between private repositories genuinely widens
|
||||
access.* **The tool says so where it reports it, rather than implying a verdict it
|
||||
has not earned.**
|
||||
|
||||
**That anything is checked when you are not looking.** *Nothing here runs on a
|
||||
schedule, and a document nobody checks is a document nobody is checking.*
|
||||
|
||||
## What is promised about the things that are not promised
|
||||
|
||||
**The command surface may change. This page will say when it does.**
|
||||
|
||||
*A consumer whose README tells a person to type `loomctl external add loom/<repo>
|
||||
<doc>.md` depends on that command by name — **not by parsing it, which is why no
|
||||
check would ever catch a rename**. The thing that moved would not be a document
|
||||
they adopted.*
|
||||
|
||||
> **So the exclusion is narrower than "you are on your own".** *We do not promise
|
||||
> the surface holds; **we promise that a change to it lands here**, on a page you
|
||||
> can lock* — **and a lock on this page reports `200` the day a command is
|
||||
> renamed.**
|
||||
|
||||
**That is the whole mechanism, and it costs one section:**
|
||||
|
||||
### Surface changes
|
||||
|
||||
*Append-only, newest last. Nothing yet — the commands have not been renamed since
|
||||
this page existed.*
|
||||
|
||||
---
|
||||
|
||||
*Verified fetchable by somebody who is not us on 2026-09-08.*
|
||||
@@ -1,17 +1,18 @@
|
||||
# loom-cli
|
||||
|
||||
**Not built.** *A small tool for the operations a person should not do by hand:
|
||||
fetch a document you depend on, and find out when it changed.*
|
||||
**`loomctl` fetches the documents this repository depends on, and tells you when
|
||||
they change.** It reports; it never repairs.
|
||||
|
||||
**The design is in [`.loom/event-log.md`](.loom/event-log.md)**, *not in a spec.*
|
||||
**Every entry says what was decided and the belief that could turn out false**, so
|
||||
you can see which parts are load-bearing and which were guesses. *Everything
|
||||
tagged `osprey` was decided in one round.*
|
||||
```
|
||||
go build -o loomctl .
|
||||
./loomctl --help
|
||||
```
|
||||
|
||||
**Three commands.** *`pull` adopts a document and writes its lock; `check` asks
|
||||
every publisher whether their copy has moved; `reachable` asks whether somebody
|
||||
who is not you can fetch what you published.* **None of them repairs anything.**
|
||||
`check` only reads. Nothing here writes over the network, so a credential it is
|
||||
given never needs write scope.
|
||||
|
||||
**`bedrock` and `externals`, under [`.loom/externals/`](.loom/externals/), are not
|
||||
discardable.** *Read them as given — accommodating them is what makes this a loom
|
||||
tool rather than some other thing.*
|
||||
**Why it is the way it is:** [`.loom/event-log.md`](.loom/event-log.md). Every
|
||||
entry says what was decided and the belief that could turn out to be false.
|
||||
|
||||
**What it depends on, and what we could not tell from those documents:**
|
||||
[`.loom/externals/`](.loom/externals/) — the `.gaps.md` files beside each one.
|
||||
|
||||
+77
-18
@@ -1,9 +1,12 @@
|
||||
// Package config reads the per-host settings loomctl needs to talk to a git host.
|
||||
//
|
||||
// The config is not only a secret. It is how you talk to a host at all, which is
|
||||
// why it is keyed by host rather than being a single token. It lives in the
|
||||
// user's home directory and never in a repository — see .loom/event-log.md,
|
||||
// "decided by fallback: where the credential lives".
|
||||
// The config is not only a secret. It is how you talk to a host at all — which
|
||||
// is why it is a set of named contexts with one current, and why a host's URL
|
||||
// shapes live here rather than in what a person types. Nobody should have to
|
||||
// know that gitea serves raw files from /raw/branch/<branch>/ to adopt a
|
||||
// document.
|
||||
//
|
||||
// It lives in the user's home directory and never in a repository.
|
||||
package config
|
||||
|
||||
import (
|
||||
@@ -16,15 +19,19 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Host is what we know about one git host.
|
||||
type Host struct {
|
||||
// Token is a read-only personal access token. It must not carry write
|
||||
// scope: loomctl never writes over the network.
|
||||
// Context is one named way of talking to one host.
|
||||
type Context struct {
|
||||
Host string `json:"host"`
|
||||
// Flavor selects the URL shapes: gitea, github or gitlab. Empty means gitea.
|
||||
Flavor string `json:"flavor,omitempty"`
|
||||
// Token is read-only. loomctl never writes over the network, so a token it
|
||||
// is given should not carry write scope.
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Hosts map[string]Host `json:"hosts"`
|
||||
CurrentContext string `json:"current-context"`
|
||||
Contexts map[string]Context `json:"contexts"`
|
||||
}
|
||||
|
||||
// Path is where the config lives. Never inside a repository.
|
||||
@@ -39,10 +46,10 @@ func Path() string {
|
||||
return filepath.Join(home, ".config", "loomctl", "config.json")
|
||||
}
|
||||
|
||||
// Load reads the config. A missing file is not an error: everything loomctl does
|
||||
// against a public repository works with no credential at all.
|
||||
// Load reads the config. A missing file is not an error: everything loomctl
|
||||
// does against a public repository works with no credential and no context.
|
||||
func Load() (*Config, error) {
|
||||
c := &Config{Hosts: map[string]Host{}}
|
||||
c := &Config{Contexts: map[string]Context{}}
|
||||
p := Path()
|
||||
if p == "" {
|
||||
return c, nil
|
||||
@@ -57,16 +64,31 @@ func Load() (*Config, error) {
|
||||
if err := json.Unmarshal(b, c); err != nil {
|
||||
return nil, fmt.Errorf("parsing %s: %w", p, err)
|
||||
}
|
||||
if c.Hosts == nil {
|
||||
c.Hosts = map[string]Host{}
|
||||
if c.Contexts == nil {
|
||||
c.Contexts = map[string]Context{}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Current returns the context a bare `owner/repo` is resolved against.
|
||||
func (c *Config) Current() (Context, error) {
|
||||
if c.CurrentContext == "" {
|
||||
return Context{}, fmt.Errorf("no current-context in %s — a bare owner/repo has no host to resolve against", Path())
|
||||
}
|
||||
ctx, ok := c.Contexts[c.CurrentContext]
|
||||
if !ok {
|
||||
return Context{}, fmt.Errorf("current-context %q is not defined in %s", c.CurrentContext, Path())
|
||||
}
|
||||
if ctx.Host == "" {
|
||||
return Context{}, fmt.Errorf("context %q has no host", c.CurrentContext)
|
||||
}
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
// TokenFor returns the token for a host, or "" if we have none.
|
||||
//
|
||||
// An environment variable wins over the file, so a token can be supplied for one
|
||||
// invocation without ever being written to disk.
|
||||
// An environment variable wins over the file, so a token can be supplied for
|
||||
// one invocation without ever being written to disk.
|
||||
func (c *Config) TokenFor(host string) string {
|
||||
if t := os.Getenv("LOOMCTL_TOKEN_" + envKey(host)); t != "" {
|
||||
return t
|
||||
@@ -74,11 +96,48 @@ func (c *Config) TokenFor(host string) string {
|
||||
if t := os.Getenv("LOOMCTL_TOKEN"); t != "" {
|
||||
return t
|
||||
}
|
||||
return c.Hosts[host].Token
|
||||
for _, ctx := range c.Contexts {
|
||||
if ctx.Host == host && ctx.Token != "" {
|
||||
return ctx.Token
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// envKey turns a hostname into the shape an environment variable can carry.
|
||||
func envKey(host string) string {
|
||||
r := strings.NewReplacer(".", "_", "-", "_", ":", "_")
|
||||
return strings.ToUpper(r.Replace(host))
|
||||
}
|
||||
|
||||
// RawURL builds the address a document is served from, given owner/repo and a
|
||||
// path inside the repository.
|
||||
//
|
||||
// For gitea the short form is deliberate: the host redirects it to the resolved
|
||||
// branch, so the URL recorded in the lock names a branch rather than a moving
|
||||
// ref, without anybody having to know which branch it was.
|
||||
func (c Context) RawURL(ownerRepo, pathInRepo string) (string, bool) {
|
||||
switch c.flavor() {
|
||||
case "gitea":
|
||||
return fmt.Sprintf("https://%s/%s/raw/%s", c.Host, ownerRepo, pathInRepo), true
|
||||
case "github":
|
||||
return fmt.Sprintf("https://raw.githubusercontent.com/%s/%%s/%s", ownerRepo, pathInRepo), false
|
||||
case "gitlab":
|
||||
return fmt.Sprintf("https://%s/%s/-/raw/%%s/%s", c.Host, ownerRepo, pathInRepo), false
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// CloneURL is what git is pointed at.
|
||||
func (c Context) CloneURL(ownerRepo string) string {
|
||||
return fmt.Sprintf("https://%s/%s.git", c.Host, ownerRepo)
|
||||
}
|
||||
|
||||
func (c Context) flavor() string {
|
||||
if c.Flavor == "" {
|
||||
return "gitea"
|
||||
}
|
||||
return strings.ToLower(c.Flavor)
|
||||
}
|
||||
|
||||
// Flavors names what RawURL understands, for error messages.
|
||||
func Flavors() string { return "gitea, github, gitlab" }
|
||||
|
||||
Vendored
+110
@@ -0,0 +1,110 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"git.hypertheory-labs.dev/loom/loom-cli/internal/config"
|
||||
)
|
||||
|
||||
// AddByName adopts a document named the way `list` prints it, against the
|
||||
// current context.
|
||||
//
|
||||
// loomctl external add loom/cart cart
|
||||
//
|
||||
// Nobody should have to type a host's raw-file route to adopt a document. The
|
||||
// route belongs to the host, so it lives in the context; the published
|
||||
// directory belongs to the convention, so it lives in the code; and what is
|
||||
// left — which repository, which document — is the only part a person knows.
|
||||
func AddByName(root, ownerRepo, name string, out io.Writer) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, err := cfg.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Count(ownerRepo, "/") != 1 {
|
||||
return fmt.Errorf("want owner/repo, got %q", ownerRepo)
|
||||
}
|
||||
if !strings.HasSuffix(name, ".md") {
|
||||
name += ".md"
|
||||
}
|
||||
|
||||
raw, resolved := ctx.RawURL(ownerRepo, PublishedDir+"/"+name)
|
||||
if raw == "" {
|
||||
return fmt.Errorf("context %q has flavor %q; known flavors are %s",
|
||||
cfg.CurrentContext, ctx.Flavor, config.Flavors())
|
||||
}
|
||||
if !resolved {
|
||||
// The lock must record a resolved URL. A short form that stays short
|
||||
// follows whatever the default branch is at the time you ask, so a
|
||||
// branch rename would report as a change in the document.
|
||||
branch, err := defaultBranch(ctx.CloneURL(ownerRepo))
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving the default branch of %s: %w", ownerRepo, err)
|
||||
}
|
||||
raw = fmt.Sprintf(raw, branch)
|
||||
}
|
||||
err = Add(root, raw, "", out)
|
||||
|
||||
// A 404 here has a reading the convention does not list, because it can only
|
||||
// happen at adoption: the name is wrong. We know the repository, so rather
|
||||
// than reporting an ambiguity we can resolve, look.
|
||||
var nf *NotFoundError
|
||||
if errors.As(err, &nf) {
|
||||
var names strings.Builder
|
||||
if lerr := List(ctx.CloneURL(ownerRepo), &names); lerr == nil {
|
||||
return fmt.Errorf("%s publishes no %q. It publishes:\n%s",
|
||||
ownerRepo, name, indent(names.String()))
|
||||
}
|
||||
// Listing failed too, so the repository itself is unreachable and the
|
||||
// original ambiguity stands.
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func indent(s string) string {
|
||||
var b strings.Builder
|
||||
for _, line := range strings.Split(strings.TrimRight(s, "\n"), "\n") {
|
||||
fmt.Fprintf(&b, " %s\n", line)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// ListByName enumerates a repository named against the current context.
|
||||
func ListByName(ownerRepo string, out io.Writer) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, err := cfg.Current()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return List(ctx.CloneURL(ownerRepo), out)
|
||||
}
|
||||
|
||||
// defaultBranch asks the remote which branch HEAD points at, without cloning.
|
||||
//
|
||||
// Only hosts whose raw URLs do not redirect need this. Gitea resolves its own
|
||||
// short form, which is why adopting from gitea needs no git at all.
|
||||
func defaultBranch(cloneURL string) (string, error) {
|
||||
cmd := exec.Command("git", "ls-remote", "--symref", cloneURL, "HEAD")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
for _, line := range strings.Split(stdout.String(), "\n") {
|
||||
if after, ok := strings.CutPrefix(line, "ref: refs/heads/"); ok {
|
||||
return strings.TrimSpace(strings.SplitN(after, "\t", 2)[0]), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no symref in git ls-remote output")
|
||||
}
|
||||
Vendored
+68
@@ -0,0 +1,68 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"git.hypertheory-labs.dev/loom/loom-cli/internal/lock"
|
||||
)
|
||||
|
||||
// auditExposure reports documents adopted from a source that could not be read
|
||||
// anonymously, into a repository that now can.
|
||||
//
|
||||
// Access is verified once, at fetch, and the copy is durable. Whether the
|
||||
// adoption is still legitimate rests on the relative visibility of two
|
||||
// repositories — a fact somebody can change with a checkbox a year later,
|
||||
// without ever seeing the adoption. This is what turns that from a silent
|
||||
// permanent hazard into something that runs.
|
||||
func auditExposure(root string, locks *lock.Set, out io.Writer) (changed bool) {
|
||||
var suspect []lock.Record
|
||||
for _, r := range locks.All() {
|
||||
if r.Visibility == lock.NotPublic {
|
||||
suspect = append(suspect, r)
|
||||
}
|
||||
}
|
||||
if len(suspect) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Only our own visibility has to be current, and it is one request for the
|
||||
// whole run rather than one per document.
|
||||
public, known := selfVisibility(root)
|
||||
if known && !public {
|
||||
return false // adopted private into private; nothing has widened
|
||||
}
|
||||
if !known {
|
||||
fmt.Fprintf(out, "\n%d document(s) came from a source that needed a credential, and I cannot\n", len(suspect))
|
||||
fmt.Fprintf(out, "tell who may read this repository — no usable origin.\n")
|
||||
return false
|
||||
}
|
||||
|
||||
// The stored value decays in both directions. A source that has since gone
|
||||
// public would otherwise raise this alarm forever, so re-check — but only
|
||||
// the suspects, and only when the alarm would actually fire.
|
||||
var still []lock.Record
|
||||
for _, r := range suspect {
|
||||
// Probe with no credential: what matters is what a stranger can read,
|
||||
// not what we can.
|
||||
if probeAnonymous(r.URL) == lock.Public {
|
||||
r.Visibility = lock.Public
|
||||
locks.Put(r)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
still = append(still, r)
|
||||
}
|
||||
if len(still) == 0 {
|
||||
return changed
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "\nEXPOSURE this repository is public and holds %d document(s) adopted from\n", len(still))
|
||||
fmt.Fprintf(out, " sources that are not:\n")
|
||||
for _, r := range still {
|
||||
fmt.Fprintf(out, " %s\n", r.Path)
|
||||
}
|
||||
fmt.Fprintf(out, " Confidentiality does not travel with the copy. This was legitimate\n")
|
||||
fmt.Fprintf(out, " when adopted if this repository was not public then.\n")
|
||||
return changed
|
||||
}
|
||||
Vendored
+72
-15
@@ -119,6 +119,7 @@ func Add(root, raw, override string, out io.Writer) error {
|
||||
if body == nil {
|
||||
return fmt.Errorf("%s: unexpected 304 for a document we do not have", raw)
|
||||
}
|
||||
vis := sourceVisibility(cfg, body.url)
|
||||
|
||||
// A document that is here but unlocked was fetched by hand before the tool
|
||||
// existed. Supplying its URL is the only way it can ever be locked, because
|
||||
@@ -145,7 +146,7 @@ func Add(root, raw, override string, out io.Writer) error {
|
||||
}
|
||||
// Identical, so the assertion a lock makes — this local copy is the one
|
||||
// being served — is verified rather than assumed.
|
||||
locks.Put(lock.Record{Path: rel, URL: body.url, ETag: etag})
|
||||
locks.Put(lock.Record{Path: rel, URL: body.url, ETag: etag, Visibility: vis})
|
||||
if err := locks.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -161,14 +162,15 @@ func Add(root, raw, override string, out io.Writer) error {
|
||||
if err := os.WriteFile(dest, body.data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
locks.Put(lock.Record{Path: rel, URL: body.url, ETag: etag})
|
||||
locks.Put(lock.Record{Path: rel, URL: body.url, ETag: etag, Visibility: vis})
|
||||
if err := locks.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "adopted %s\n", rel)
|
||||
fmt.Fprintf(out, " from %s\n", body.url)
|
||||
warnIfNotPublic(cfg, body.url, out)
|
||||
warnIfNotPublic(root, body.url, vis, out)
|
||||
notePublishedSurface(body.url, out)
|
||||
if etag == "" {
|
||||
fmt.Fprintf(out, " etag (none served — check cannot ask conditionally)\n")
|
||||
} else {
|
||||
@@ -185,32 +187,85 @@ func Add(root, raw, override string, out io.Writer) error {
|
||||
// moment of adoption. The tool can see half of that — whether this fetch needed
|
||||
// a credential — and cannot see the other half, which is who can read the
|
||||
// repository the copy is landing in. It reports the half it knows.
|
||||
func warnIfNotPublic(cfg *config.Config, raw string, out io.Writer) {
|
||||
// sourceVisibility reports what a document could be read as at the moment it was
|
||||
// fetched, given that the fetch had just succeeded.
|
||||
//
|
||||
// It is free: when no credential was configured the fetch itself was anonymous,
|
||||
// so the answer is already known; when one was, the extra request is the one the
|
||||
// warning needed anyway.
|
||||
//
|
||||
// It must not be used to re-check a document we are not fetching. "No credential
|
||||
// configured" says nothing about whether a probe would succeed, and treating it
|
||||
// as public there would silently clear a real alarm.
|
||||
func sourceVisibility(cfg *config.Config, raw string) string {
|
||||
if u, err := url.Parse(raw); err == nil && cfg.TokenFor(u.Host) == "" {
|
||||
return lock.Public // it came back without a credential
|
||||
}
|
||||
return probeAnonymous(raw)
|
||||
}
|
||||
|
||||
// probeAnonymous asks, with no credential at all, whether a URL can be read.
|
||||
func probeAnonymous(raw string) string {
|
||||
req, err := http.NewRequest(http.MethodHead, raw, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if cfg.TokenFor(req.URL.Host) == "" {
|
||||
return // no credential was used, so the fetch was already anonymous
|
||||
return ""
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return lock.Public
|
||||
}
|
||||
return lock.NotPublic
|
||||
}
|
||||
|
||||
// warnIfNotPublic says something when adopting would widen who can read a
|
||||
// document.
|
||||
//
|
||||
// Adopting is copying, so a document from a repository somebody may not read
|
||||
// ends up in a repository they may, and the publisher loses control of it at the
|
||||
// moment of adoption.
|
||||
func warnIfNotPublic(root, raw, vis string, out io.Writer) {
|
||||
if vis != lock.NotPublic {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, " WARN this needed a credential — anonymously it is %s.\n", resp.Status)
|
||||
fmt.Fprintf(out, " Confidentiality does not travel with the copy: this now lives in\n")
|
||||
fmt.Fprintf(out, " .loom/externals/ and is readable by anyone who can read THIS\n")
|
||||
fmt.Fprintf(out, " repository, which I cannot see. Do not adopt from a source less\n")
|
||||
public, known := selfVisibility(root)
|
||||
if known && !public {
|
||||
fmt.Fprintf(out, " NOTE private source, and this repository is not public either.\n")
|
||||
fmt.Fprintf(out, " Access is not widened by that alone — but this check only\n")
|
||||
fmt.Fprintf(out, " tells public from not-public, so it cannot see two repositories\n")
|
||||
fmt.Fprintf(out, " private to different people. That case does widen it.\n")
|
||||
return
|
||||
}
|
||||
if !known {
|
||||
fmt.Fprintf(out, " WARN this needed a credential, and I cannot tell who may read this\n")
|
||||
fmt.Fprintf(out, " repository — no usable origin. Check before you commit.\n")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, " WARN this needed a credential, and THIS repository is public.\n")
|
||||
fmt.Fprintf(out, " Confidentiality does not travel with the copy: adopting this\n")
|
||||
fmt.Fprintf(out, " publishes it to everyone. Do not adopt from a source less\n")
|
||||
fmt.Fprintf(out, " readable than the repository you are adopting into.\n")
|
||||
fmt.Fprintf(out, " Two ways out: ask them to publish it — usually the thing you\n")
|
||||
fmt.Fprintf(out, " needed was not the confidential part — or keep no copy and\n")
|
||||
fmt.Fprintf(out, " record only the dependency, which loomctl cannot do yet.\n")
|
||||
}
|
||||
|
||||
// NotFoundError is a 404, which over HTTP carries more than one reading.
|
||||
//
|
||||
// The convention names two — withdrawn, or access lost — because those are the
|
||||
// two a locked document can have. Adoption by name has a third: a document that
|
||||
// was never there under that name. Callers that know the repository can tell
|
||||
// them apart; this type is how they get the chance.
|
||||
type NotFoundError struct{ URL string }
|
||||
|
||||
func (e *NotFoundError) Error() string {
|
||||
return fmt.Sprintf("404 unresolved: %s was withdrawn, or this credential cannot see it — "+
|
||||
"over HTTP these are the same response", e.URL)
|
||||
}
|
||||
|
||||
type fetched struct {
|
||||
data []byte
|
||||
url string
|
||||
@@ -239,8 +294,7 @@ func fetch(cfg *config.Config, raw, ifNoneMatch string) (*fetched, string, error
|
||||
}
|
||||
return &fetched{data: b, url: resp.Request.URL.String()}, resp.Header.Get("ETag"), nil
|
||||
case http.StatusNotFound:
|
||||
return nil, "", fmt.Errorf("404 unresolved: %s was withdrawn, or this credential cannot see it — "+
|
||||
"over HTTP these are the same response", raw)
|
||||
return nil, "", &NotFoundError{URL: raw}
|
||||
default:
|
||||
return nil, "", fmt.Errorf("%s: %s", resp.Status, raw)
|
||||
}
|
||||
@@ -289,6 +343,8 @@ func Check(root string, out io.Writer) error {
|
||||
staged += did
|
||||
results = append(results, st)
|
||||
}
|
||||
auditPrinted := &strings.Builder{}
|
||||
auditExposure(root, locks, auditPrinted)
|
||||
if err := locks.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -309,6 +365,7 @@ func Check(root string, out io.Writer) error {
|
||||
if staged > 0 {
|
||||
fmt.Fprintf(out, "\n%d staged in %s — apply or discard; nothing here drifts into being kept.\n", staged, PoladDir)
|
||||
}
|
||||
io.WriteString(out, auditPrinted.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Vendored
+97
@@ -0,0 +1,97 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// selfVisibility reports whether the repository we are adopting into can be read
|
||||
// anonymously.
|
||||
//
|
||||
// This is the half of the confidentiality rule the tool was previously blind to.
|
||||
// Knowing only that a source is private makes the warning fire on every adoption
|
||||
// a private repository performs, which is the legitimate case — and a warning
|
||||
// that always fires is a warning nobody reads.
|
||||
//
|
||||
// The signal is coarse on purpose. It distinguishes public from not-public and
|
||||
// nothing finer, so it cannot see that two repositories are private to different
|
||||
// groups. That case widens access and this check will miss it.
|
||||
func selfVisibility(root string) (public bool, known bool) {
|
||||
out, err := exec.Command("git", "-C", root, "remote", "get-url", "origin").Output()
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
host, ownerRepo, ok := splitRemote(strings.TrimSpace(string(out)))
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodHead, "https://"+host+"/"+ownerRepo, nil)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode == http.StatusOK, true
|
||||
}
|
||||
|
||||
// splitRemote pulls a host and owner/repo out of a git remote, whether it is
|
||||
// ssh, ssh:// or https.
|
||||
func splitRemote(remote string) (host, ownerRepo string, ok bool) {
|
||||
s := remote
|
||||
if i := strings.Index(s, "://"); i >= 0 {
|
||||
s = s[i+3:]
|
||||
}
|
||||
if at := strings.Index(s, "@"); at >= 0 {
|
||||
s = s[at+1:]
|
||||
}
|
||||
// scp-style "host:owner/repo.git" and url-style "host:port/owner/repo.git"
|
||||
var rest string
|
||||
if i := strings.IndexAny(s, ":/"); i >= 0 {
|
||||
host, rest = s[:i], s[i+1:]
|
||||
} else {
|
||||
return "", "", false
|
||||
}
|
||||
if j := strings.Index(rest, "/"); j >= 0 && isPort(rest[:j]) {
|
||||
rest = rest[j+1:]
|
||||
}
|
||||
rest = strings.TrimSuffix(strings.Trim(rest, "/"), ".git")
|
||||
if host == "" || strings.Count(rest, "/") != 1 {
|
||||
return "", "", false
|
||||
}
|
||||
return host, rest, true
|
||||
}
|
||||
|
||||
func isPort(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// notePublishedSurface says when an adopted document did not come from the
|
||||
// publisher's published surface.
|
||||
//
|
||||
// What is not exported is not hidden — the rest of a repository is there to read.
|
||||
// It is simply not what you depend on, and a lock against it records a dependency
|
||||
// on something that was never a contract.
|
||||
func notePublishedSurface(rawURL string, out interface{ Write([]byte) (int, error) }) {
|
||||
if strings.Contains(rawURL, "/"+PublishedDir+"/") {
|
||||
return
|
||||
}
|
||||
var b bytes.Buffer
|
||||
fmt.Fprintf(&b, " NOTE not from %s — what is not exported is not hidden, but it is\n", PublishedDir)
|
||||
fmt.Fprintf(&b, " not what you depend on. Nothing promises this path will still\n")
|
||||
fmt.Fprintf(&b, " be there, or still mean this, tomorrow.\n")
|
||||
out.Write(b.Bytes())
|
||||
}
|
||||
Vendored
+34
@@ -0,0 +1,34 @@
|
||||
package external
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSplitRemote(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
remote, host, ownerRepo string
|
||||
ok bool
|
||||
}{
|
||||
{"ssh://git@git.hypertheory-labs.dev:2222/loom/loom-cli.git", "git.hypertheory-labs.dev", "loom/loom-cli", true},
|
||||
{"ssh://git@git.hypertheory-labs.dev/loom/loom-cli.git", "git.hypertheory-labs.dev", "loom/loom-cli", true},
|
||||
{"git@github.com:octocat/Hello-World.git", "github.com", "octocat/Hello-World", true},
|
||||
{"https://gitlab.com/gitlab-org/gitlab-svgs.git", "gitlab.com", "gitlab-org/gitlab-svgs", true},
|
||||
{"https://gitlab.com/gitlab-org/gitlab-svgs", "gitlab.com", "gitlab-org/gitlab-svgs", true},
|
||||
// A nested group is not owner/repo, and guessing would produce a URL
|
||||
// that answers about the wrong repository.
|
||||
{"https://gitlab.com/group/sub/project.git", "", "", false},
|
||||
{"/srv/git/bare.git", "", "", false},
|
||||
{"", "", "", false},
|
||||
} {
|
||||
host, or, ok := splitRemote(tc.remote)
|
||||
if ok != tc.ok || host != tc.host || or != tc.ownerRepo {
|
||||
t.Errorf("splitRemote(%q) = (%q, %q, %v), want (%q, %q, %v)",
|
||||
tc.remote, host, or, ok, tc.host, tc.ownerRepo, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfVisibilityUnknownOutsideARepo(t *testing.T) {
|
||||
// Failing open matters: "cannot tell" must not read as "not public".
|
||||
if _, known := selfVisibility(t.TempDir()); known {
|
||||
t.Error("selfVisibility in a non-repository reported a known answer")
|
||||
}
|
||||
}
|
||||
+35
-5
@@ -25,8 +25,10 @@ const Dir = ".loom/externals"
|
||||
const File = ".locks"
|
||||
|
||||
const header = "# loomctl locks — one record per adopted document.\n" +
|
||||
"# path<TAB>url<TAB>etag The url is resolved: a short form would follow\n" +
|
||||
"# whatever the default branch is at the time you ask.\n"
|
||||
"# path<TAB>url<TAB>etag[<TAB>visibility]\n" +
|
||||
"# The url is resolved: a short form would follow whatever the default branch\n" +
|
||||
"# is at the time you ask. visibility is what the source could be read as when\n" +
|
||||
"# it was fetched, because that is checked once and the copy is durable.\n"
|
||||
|
||||
// Record is one adopted document.
|
||||
type Record struct {
|
||||
@@ -38,8 +40,28 @@ type Record struct {
|
||||
URL string
|
||||
// ETag is the publisher's, verbatim, including its quotes.
|
||||
ETag string
|
||||
// Visibility is what the source could be read as when it was fetched:
|
||||
// "public", "not-public", or empty for locks written before this was
|
||||
// recorded.
|
||||
//
|
||||
// It is deliberately coarse. An anonymous request tells public from
|
||||
// not-public and nothing finer, so this cannot distinguish two repositories
|
||||
// private to different people — which is the case where adopting between
|
||||
// private repositories genuinely widens access.
|
||||
//
|
||||
// It is recorded because access is checked once, at fetch, and the copy is
|
||||
// durable. Whether an adoption is still legitimate depends on the relative
|
||||
// visibility of two repositories, which somebody can change with a checkbox
|
||||
// a year later without ever seeing the adoption.
|
||||
Visibility string
|
||||
}
|
||||
|
||||
// Visibility values. Never "private": the signal cannot support the word.
|
||||
const (
|
||||
Public = "public"
|
||||
NotPublic = "not-public"
|
||||
)
|
||||
|
||||
// Set is every lock, keyed by path.
|
||||
type Set struct {
|
||||
file string
|
||||
@@ -75,10 +97,14 @@ func LoadFile(file string) (*Set, error) {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(line, "\t")
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("%s:%d: want 3 tab-separated fields, got %d", file, n, len(parts))
|
||||
if len(parts) != 3 && len(parts) != 4 {
|
||||
return nil, fmt.Errorf("%s:%d: want 3 or 4 tab-separated fields, got %d", file, n, len(parts))
|
||||
}
|
||||
s.recs[parts[0]] = Record{Path: parts[0], URL: parts[1], ETag: parts[2]}
|
||||
r := Record{Path: parts[0], URL: parts[1], ETag: parts[2]}
|
||||
if len(parts) == 4 {
|
||||
r.Visibility = parts[3]
|
||||
}
|
||||
s.recs[parts[0]] = r
|
||||
}
|
||||
return s, sc.Err()
|
||||
}
|
||||
@@ -112,7 +138,11 @@ func (s *Set) Save() error {
|
||||
var b strings.Builder
|
||||
b.WriteString(header)
|
||||
for _, r := range s.All() {
|
||||
if r.Visibility == "" {
|
||||
fmt.Fprintf(&b, "%s\t%s\t%s\n", r.Path, r.URL, r.ETag)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "%s\t%s\t%s\t%s\n", r.Path, r.URL, r.ETag, r.Visibility)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(p), ".locks-*")
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package lock
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Locks written before visibility was recorded have three fields, and must keep
|
||||
// loading: a repository does not get to stop working because the tool learned
|
||||
// something new.
|
||||
func TestLoadsThreeAndFourFieldRecords(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
p := Path(root)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := "# comment\n\nh/o/r/old.md\thttps://h/old\t\"1\"\n" +
|
||||
"h/o/r/new.md\thttps://h/new\t\"2\"\tnot-public\n"
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := Load(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old, _ := s.Get("h/o/r/old.md")
|
||||
if old.Visibility != "" {
|
||||
t.Errorf("a three-field record should have unknown visibility, got %q", old.Visibility)
|
||||
}
|
||||
nw, _ := s.Get("h/o/r/new.md")
|
||||
if nw.Visibility != NotPublic {
|
||||
t.Errorf("visibility = %q, want %q", nw.Visibility, NotPublic)
|
||||
}
|
||||
|
||||
// And unknown must survive a round trip rather than being written as a value.
|
||||
if err := s.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _ := os.ReadFile(p)
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if strings.HasPrefix(line, "h/o/r/old.md") && strings.Count(line, "\t") != 2 {
|
||||
t.Errorf("unknown visibility was written as a field: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsAMalformedRecord(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
p := Path(root)
|
||||
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
os.WriteFile(p, []byte("h/o/r/x.md\thttps://h/x\n"), 0o644)
|
||||
if _, err := Load(root); err == nil {
|
||||
t.Error("a two-field record should be an error, not a silently empty ETag")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Package orient generates a table of contents over what a repository depends
|
||||
// on, for whoever arrives next — a person, or an agent of any make.
|
||||
//
|
||||
// It restates no rule. Every rule it might repeat is owned by a document already
|
||||
// in the working tree, and a copy of a rule is a copy that goes stale: this file
|
||||
// says only what is here, where it came from, and where the rules live.
|
||||
//
|
||||
// The output is byte-deterministic. A generated file that churns produces diffs
|
||||
// nobody reads, and the diff is most of the value — it is how somebody sees that
|
||||
// their dependencies moved.
|
||||
package orient
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.hypertheory-labs.dev/loom/loom-cli/internal/lock"
|
||||
)
|
||||
|
||||
// File is where the orientation lives, relative to the repository root.
|
||||
//
|
||||
// Beside .loom/event-log.md rather than inside .loom/externals/, because
|
||||
// everything in that directory is somebody else's document — which is what makes
|
||||
// "do not edit these" a rule you can state in four words — and because `check`
|
||||
// walks it and would report a generated file as an unlocked external forever.
|
||||
const File = ".loom/orientation.md"
|
||||
|
||||
const preamble = `<!-- Generated by ` + "`loomctl orient`" + `. Safe to overwrite; nobody typed it.
|
||||
Regenerate after adopting a document or reconciling a change. -->
|
||||
|
||||
# What this repository depends on
|
||||
|
||||
Copies of other people's documents are kept under ` + "`.loom/externals/`" + `, at a path
|
||||
that says where each came from. **They are copies: do not edit them.** Anything
|
||||
you want to say about one goes in a file *beside* it, never into it.
|
||||
|
||||
Three moves, and each has a document that owns the rule:
|
||||
|
||||
- **A copy is wrong, or you needed something it does not say** — write it in
|
||||
` + "`<name>.gaps.md`" + ` beside the copy.
|
||||
- **What of ours depends on a copy** — write it in ` + "`<name>.usages.md`" + ` beside it.
|
||||
- **A source changed** — ` + "`loomctl external check`" + ` says so and stages the new copy;
|
||||
` + "`loomctl external apply`" + ` takes it. Neither edits anything on its own.
|
||||
`
|
||||
|
||||
// Generate writes the orientation for the repository at root.
|
||||
func Generate(root, out string) (string, error) {
|
||||
locks, err := lock.Load(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(preamble)
|
||||
|
||||
recs := locks.All() // already ordered by path
|
||||
if len(recs) == 0 {
|
||||
b.WriteString("\nThis repository adopts nothing yet.\n")
|
||||
}
|
||||
for _, r := range recs {
|
||||
fmt.Fprintf(&b, "\n## `%s`\n\n", r.Path)
|
||||
fmt.Fprintf(&b, "- source: %s\n", r.URL)
|
||||
facets := facetsFor(root, r.Path)
|
||||
if u, ok := facets["usages"]; ok {
|
||||
fmt.Fprintf(&b, "- what of ours depends on it: `%s`\n", u)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "- what of ours depends on it: **nothing recorded** — no `.usages.md`\n")
|
||||
}
|
||||
if g, ok := facets["gaps"]; ok {
|
||||
fmt.Fprintf(&b, "- what we expected and did not find: `%s`\n", g)
|
||||
}
|
||||
}
|
||||
|
||||
if dir := filepath.Join(root, ".loom", "published"); isDir(dir) {
|
||||
b.WriteString("\n## This repository publishes\n\n")
|
||||
b.WriteString("See `.loom/published/`. What is handed over for others to depend on is\n")
|
||||
b.WriteString("there; the rest of the repository is not hidden, it is simply not what\n")
|
||||
b.WriteString("anybody depends on.\n")
|
||||
}
|
||||
|
||||
dest := out
|
||||
if dest == "" {
|
||||
dest = filepath.Join(root, filepath.FromSlash(File))
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dest, os.WriteFile(dest, []byte(b.String()), 0o644)
|
||||
}
|
||||
|
||||
// facetsFor finds the files written beside an adopted document.
|
||||
func facetsFor(root, rel string) map[string]string {
|
||||
found := map[string]string{}
|
||||
stem := strings.TrimSuffix(rel, path.Ext(rel))
|
||||
for _, kind := range []string{"usages", "gaps"} {
|
||||
p := stem + "." + kind + ".md"
|
||||
if _, err := os.Stat(filepath.Join(root, lock.Dir, filepath.FromSlash(p))); err == nil {
|
||||
found[kind] = lock.Dir + "/" + p
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func isDir(p string) bool {
|
||||
fi, err := os.Stat(p)
|
||||
return err == nil && fi.IsDir()
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package orient
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.hypertheory-labs.dev/loom/loom-cli/internal/lock"
|
||||
)
|
||||
|
||||
// A generated file that churns produces diffs nobody reads, and the diff is most
|
||||
// of the value.
|
||||
func TestGenerateIsDeterministic(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
locks, err := lock.LoadFile(lock.Path(root))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Inserted out of order on purpose: the output must not depend on it.
|
||||
for _, r := range []lock.Record{
|
||||
{Path: "h/o/zeta/z.md", URL: "https://h/z", ETag: `"3"`},
|
||||
{Path: "h/o/alpha/a.md", URL: "https://h/a", ETag: `"1"`},
|
||||
{Path: "h/o/mid/m.md", URL: "https://h/m", ETag: `"2"`},
|
||||
} {
|
||||
locks.Put(r)
|
||||
}
|
||||
if err := locks.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var first string
|
||||
for i := 0; i < 3; i++ {
|
||||
dest, err := Generate(root, filepath.Join(root, "out.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(dest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if i == 0 {
|
||||
first = string(b)
|
||||
continue
|
||||
}
|
||||
if string(b) != first {
|
||||
t.Fatal("output changed between runs")
|
||||
}
|
||||
}
|
||||
if a, z := strings.Index(first, "alpha"), strings.Index(first, "zeta"); a > z {
|
||||
t.Error("entries are not ordered by path")
|
||||
}
|
||||
if strings.Contains(first, "publishes") {
|
||||
t.Error("claimed the repository publishes with no .loom/published")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaysWhenNothingRecordsADependency(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
locks, _ := lock.LoadFile(lock.Path(root))
|
||||
locks.Put(lock.Record{Path: "h/o/r/doc.md", URL: "https://h/doc", ETag: `"1"`})
|
||||
if err := locks.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dest, err := Generate(root, filepath.Join(root, "out.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := os.ReadFile(dest)
|
||||
if !strings.Contains(string(b), "nothing recorded") {
|
||||
t.Error("a document with no .usages.md should say so — it is a finding, not an omission")
|
||||
}
|
||||
}
|
||||
@@ -9,15 +9,22 @@ package main
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.hypertheory-labs.dev/loom/loom-cli/internal/config"
|
||||
|
||||
"git.hypertheory-labs.dev/loom/loom-cli/internal/external"
|
||||
"git.hypertheory-labs.dev/loom/loom-cli/internal/orient"
|
||||
)
|
||||
|
||||
const usage = `loomctl — fetch what you depend on, and find out when it changed.
|
||||
|
||||
loomctl external list <repo-url> what a repository publishes
|
||||
loomctl external add <url> [--path p] adopt one document and lock it
|
||||
loomctl external list <owner/repo> what a repository publishes
|
||||
loomctl external add <owner/repo> <doc> adopt one document and lock it
|
||||
loomctl external add <url> [--path p] ... or by its full raw URL
|
||||
loomctl external check ask every publisher whether theirs moved
|
||||
loomctl external apply [path...] move a staged polad into place, lock and all
|
||||
|
||||
@@ -27,9 +34,12 @@ become — and somebody decides. Its exits are apply or discard; nothing there
|
||||
drifts into being kept. With no cart open, check says what moved and stages
|
||||
nothing, because opening a round is somebody's act and not a side effect.
|
||||
|
||||
add warns when a document could only be fetched with a credential: adopting is
|
||||
copying, and confidentiality does not travel with the copy. It cannot see who
|
||||
may read the repository the copy lands in, so it reports the half it knows.
|
||||
loomctl grants no access. It reads what your credentials already let you read,
|
||||
and records where the copy came from — which is the part copy-and-paste loses.
|
||||
It warns rather than refuses: adopting from a source less readable than the
|
||||
repository you are adopting into is yours to decide, and the check only tells
|
||||
public from not-public, so it cannot see two repositories private to different
|
||||
people.
|
||||
|
||||
add adopts what is not here yet, and refuses what is already adopted. Given a
|
||||
document that is present but unlocked — fetched by hand before this existed —
|
||||
@@ -41,9 +51,25 @@ Adopted documents live in .loom/externals/<host>/<owner>/<repo>/<name>.md, and
|
||||
their origins in .loom/externals/.locks. The path is for a person to read; the
|
||||
lock is what a machine uses, because the path does not round-trip to a URL.
|
||||
|
||||
Credentials are read-only and per host, in ~/.config/loomctl/config.json or in
|
||||
LOOMCTL_TOKEN_<HOST>. loomctl never writes over the network, so a token it is
|
||||
given should never carry write scope.
|
||||
loomctl orient [--out path] write .loom/orientation.md for whoever arrives next
|
||||
loomctl config which context is current, and from where
|
||||
|
||||
A bare owner/repo is resolved against the current context in
|
||||
~/.config/loomctl/config.json, which holds a host, its flavor and a read-only
|
||||
token. The host's raw-file route lives there so that nobody has to type it, and
|
||||
LOOMCTL_TOKEN_<HOST> overrides the file for one invocation. loomctl never writes
|
||||
over the network, so a token it is given should never carry write scope.
|
||||
|
||||
{
|
||||
"current-context": "hypertheory",
|
||||
"contexts": {
|
||||
"hypertheory": {
|
||||
"host": "git.hypertheory-labs.dev",
|
||||
"flavor": "gitea",
|
||||
"token": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Requires git on PATH, for list only.
|
||||
`
|
||||
@@ -63,6 +89,24 @@ func run(args []string) error {
|
||||
switch args[0] {
|
||||
case "external":
|
||||
return runExternal(args[1:])
|
||||
case "orient":
|
||||
fs := flag.NewFlagSet("orient", flag.ContinueOnError)
|
||||
out := fs.String("out", "", "write here instead of "+orient.File)
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := root()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dest, err := orient.Generate(root, *out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "wrote %s\n", dest)
|
||||
return nil
|
||||
case "config":
|
||||
return showConfig(os.Stdout)
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q\n\n%s", args[0], usage)
|
||||
}
|
||||
@@ -75,9 +119,12 @@ func runExternal(args []string) error {
|
||||
switch args[0] {
|
||||
case "list":
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("usage: loomctl external list <repo-url>")
|
||||
return fmt.Errorf("usage: loomctl external list <owner/repo>")
|
||||
}
|
||||
if strings.Contains(args[1], "://") {
|
||||
return external.List(args[1], os.Stdout)
|
||||
}
|
||||
return external.ListByName(args[1], os.Stdout)
|
||||
|
||||
case "add":
|
||||
fs := flag.NewFlagSet("add", flag.ContinueOnError)
|
||||
@@ -85,14 +132,23 @@ func runExternal(args []string) error {
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() != 1 {
|
||||
return fmt.Errorf("usage: loomctl external add <url> [--path p]")
|
||||
}
|
||||
root, err := root()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch fs.NArg() {
|
||||
case 1:
|
||||
if !strings.Contains(fs.Arg(0), "://") {
|
||||
return fmt.Errorf("adopting by name needs the document too: " +
|
||||
"loomctl external add <owner/repo> <doc>")
|
||||
}
|
||||
return external.Add(root, fs.Arg(0), *path, os.Stdout)
|
||||
case 2:
|
||||
return external.AddByName(root, fs.Arg(0), fs.Arg(1), os.Stdout)
|
||||
default:
|
||||
return fmt.Errorf("usage: loomctl external add <owner/repo> <doc>, " +
|
||||
"or loomctl external add <url> [--path p]")
|
||||
}
|
||||
|
||||
case "check":
|
||||
root, err := root()
|
||||
@@ -113,6 +169,54 @@ func runExternal(args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// showConfig says which context is current and where it came from, so that "my
|
||||
// token is not being used" is answerable without printing the token.
|
||||
func showConfig(out io.Writer) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(out, "config %s\n", config.Path())
|
||||
if len(cfg.Contexts) == 0 {
|
||||
fmt.Fprintln(out, " (none — public repositories still work by full URL)")
|
||||
return nil
|
||||
}
|
||||
names := make([]string, 0, len(cfg.Contexts))
|
||||
for n := range cfg.Contexts {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
c := cfg.Contexts[n]
|
||||
marker := " "
|
||||
if n == cfg.CurrentContext {
|
||||
marker = "*"
|
||||
}
|
||||
flavor := c.Flavor
|
||||
if flavor == "" {
|
||||
flavor = "gitea (default)"
|
||||
}
|
||||
fmt.Fprintf(out, "%s %-14s %-32s %-16s %s\n", marker, n, c.Host, flavor, credential(cfg, c))
|
||||
}
|
||||
if _, err := cfg.Current(); err != nil {
|
||||
fmt.Fprintf(out, "\n%v\n", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func credential(cfg *config.Config, c config.Context) string {
|
||||
if os.Getenv("LOOMCTL_TOKEN") != "" {
|
||||
return "token from LOOMCTL_TOKEN"
|
||||
}
|
||||
if cfg.TokenFor(c.Host) != "" {
|
||||
if c.Token == "" {
|
||||
return "token from the environment"
|
||||
}
|
||||
return "token set"
|
||||
}
|
||||
return "no token (anonymous)"
|
||||
}
|
||||
|
||||
func root() (string, error) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user