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`
|
## 2026-09-07 — the design lives here and not in a spec `osprey`
|
||||||
|
|
||||||
**Decided:** *this log is the artifact of `osprey`.* **No specification document
|
**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
|
location.* **If anybody writes code that parses it back into a URL, the path
|
||||||
should stop looking like one.**
|
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`
|
## 2026-09-07 — `404` is unresolved, and that is no longer provisional `osprey`
|
||||||
|
|
||||||
**Decided:** *`check` reports a `404` as unresolvable — the document was withdrawn
|
**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
|
next token*, **and because if a read scope is not enough, that is the only argument
|
||||||
for keeping ssh.**
|
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`
|
## 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
|
**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
|
unit.* **If two presences ever work from different machines, the cart has nowhere
|
||||||
to live**, *and everything above stops being true at once.*
|
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`
|
## 2026-09-07 — declined for now: reference-only adoption `quince`
|
||||||
|
|
||||||
**`externals` offers two ways out of the confidentiality rule.** *We implement
|
**`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
|
*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
|
somebody a rule and not the way out of it is how a guardrail becomes something
|
||||||
people route around.***
|
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.
|
# loomctl locks — one record per adopted document.
|
||||||
# path<TAB>url<TAB>etag The url is resolved: a short form would follow
|
# path<TAB>url<TAB>etag[<TAB>visibility]
|
||||||
# whatever the default branch is at the time you ask.
|
# The url is resolved: a short form would follow whatever the default branch
|
||||||
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"
|
# is at the time you ask. visibility is what the source could be read as when
|
||||||
git.hypertheory-labs.dev/loom/annotating/annotating.md https://git.hypertheory-labs.dev/loom/annotating/raw/branch/main/.loom/published/annotating.md "9b1f7e6ca92f2339b2d433686c27845362944bdb"
|
# it was fetched, because that is checked once and the copy is durable.
|
||||||
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/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/bedrock/publication.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/publication.md "eb0cb63629a36b056f215dfbe24567d1918cec38"
|
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/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/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/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/publication.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/publication.md "c0d9231aa8eed3041c60b72da5735585457fbc90" public
|
||||||
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/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/cart/cart.md https://git.hypertheory-labs.dev/loom/cart/raw/branch/main/.loom/published/cart.md "49fc852bdd280293f0f5e0050034e3b89ff7255e"
|
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/externals/externals.md https://git.hypertheory-labs.dev/loom/externals/raw/branch/main/.loom/published/externals.md "a7586eb52caf275d9bcedbbd8042c43e5aaad0b9"
|
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
|
**`ls .loom/published/` is *what have we committed to?*** — and it is the question
|
||||||
to ask before changing anything.
|
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
|
## What is published is a document
|
||||||
|
|
||||||
**Prose, in whatever form its readers need.** *If a repository organises its
|
**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
|
you already rejected — because the rejection lived somewhere that did not
|
||||||
survive.*
|
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
|
## Where it lives
|
||||||
|
|
||||||
`.loom/event-log.md`. **A stream, so a file.**
|
`.loom/event-log.md`. **A stream, so a file.**
|
||||||
|
|||||||
@@ -60,13 +60,16 @@ what they came for.*
|
|||||||
|
|
||||||
## Look at one instead of reading this
|
## Look at one instead of reading this
|
||||||
|
|
||||||
**There is a worked example — six documents, one gap, no decomposition — and it
|
**[`jeffry/homelab-cluster`](https://git.hypertheory-labs.dev/jeffry/homelab-cluster)** —
|
||||||
is private.**
|
*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
|
**It was private, and being unable to link to it is what made the split obvious.**
|
||||||
published, and **this page will not link you to something you cannot fetch.***
|
*The operational tree is a version inventory and a target list; the pages telling
|
||||||
**If you have access, ask for it by name; if you do not, the two questions at the
|
somebody what to decide are neither.* **So the manifests moved to a private
|
||||||
top are the whole of it.**
|
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
|
> **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.
|
> 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
|
presence's daily* — **but "move it" and "reject it" are different acts with
|
||||||
different costs**, *and picking one was ours to invent.*
|
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**,
|
> **Before writing a rule for third files, count how many arrive any other way.**
|
||||||
*where every earlier conversion left them in git.*
|
> *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
|
*One instance is not a sample. **We would rather leave this open than freeze a
|
||||||
at conversion the cart is the only copy.* **Two rules now both push toward loss** —
|
rule out of a single case**, which is the same reason `v2` did not answer it.*
|
||||||
*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.***
|
|
||||||
|
|
||||||
## Whether an unanswered `Affirm` and a fallback are the same silence
|
## Whether an unanswered `Affirm` and a fallback are the same silence
|
||||||
|
|
||||||
|
|||||||
+29
-1
@@ -1,6 +1,6 @@
|
|||||||
# Agreement — the cart
|
# 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.**
|
**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.
|
> **Pinning a defect in place lets you be exact without having understood.
|
||||||
> Restating surfaces the misunderstanding that pinning hides.**
|
> 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
|
## Conversion emits two things
|
||||||
|
|
||||||
**The artifact** — the code, the decision, the change — **and the declines.**
|
**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
|
*Both documents are loom's. **Recorded here because the conflict is visible from
|
||||||
inside an adopter and not from inside either document.***
|
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*
|
- **where it was fetched from** — *resolved, as above*
|
||||||
- **the publisher's `ETag`** — *verbatim*
|
- **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
|
**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.*
|
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
|
> asserts your copy is the one being served, which is the thing you were going to
|
||||||
> check.
|
> 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
|
## Freshness is a conditional request
|
||||||
|
|
||||||
**Locked on the publisher's `ETag`, verbatim — never a hash you compute.** *A
|
**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
|
# loom-cli
|
||||||
|
|
||||||
**Not built.** *A small tool for the operations a person should not do by hand:
|
**`loomctl` fetches the documents this repository depends on, and tells you when
|
||||||
fetch a document you depend on, and find out when it changed.*
|
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
|
go build -o loomctl .
|
||||||
you can see which parts are load-bearing and which were guesses. *Everything
|
./loomctl --help
|
||||||
tagged `osprey` was decided in one round.*
|
```
|
||||||
|
|
||||||
**Three commands.** *`pull` adopts a document and writes its lock; `check` asks
|
`check` only reads. Nothing here writes over the network, so a credential it is
|
||||||
every publisher whether their copy has moved; `reachable` asks whether somebody
|
given never needs write scope.
|
||||||
who is not you can fetch what you published.* **None of them repairs anything.**
|
|
||||||
|
|
||||||
**`bedrock` and `externals`, under [`.loom/externals/`](.loom/externals/), are not
|
**Why it is the way it is:** [`.loom/event-log.md`](.loom/event-log.md). Every
|
||||||
discardable.** *Read them as given — accommodating them is what makes this a loom
|
entry says what was decided and the belief that could turn out to be false.
|
||||||
tool rather than some other thing.*
|
|
||||||
|
**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.
|
// 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
|
// The config is not only a secret. It is how you talk to a host at all — which
|
||||||
// why it is keyed by host rather than being a single token. It lives in the
|
// is why it is a set of named contexts with one current, and why a host's URL
|
||||||
// user's home directory and never in a repository — see .loom/event-log.md,
|
// shapes live here rather than in what a person types. Nobody should have to
|
||||||
// "decided by fallback: where the credential lives".
|
// 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
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -16,15 +19,19 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Host is what we know about one git host.
|
// Context is one named way of talking to one host.
|
||||||
type Host struct {
|
type Context struct {
|
||||||
// Token is a read-only personal access token. It must not carry write
|
Host string `json:"host"`
|
||||||
// scope: loomctl never writes over the network.
|
// 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"`
|
Token string `json:"token,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
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.
|
// 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")
|
return filepath.Join(home, ".config", "loomctl", "config.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads the config. A missing file is not an error: everything loomctl does
|
// Load reads the config. A missing file is not an error: everything loomctl
|
||||||
// against a public repository works with no credential at all.
|
// does against a public repository works with no credential and no context.
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
c := &Config{Hosts: map[string]Host{}}
|
c := &Config{Contexts: map[string]Context{}}
|
||||||
p := Path()
|
p := Path()
|
||||||
if p == "" {
|
if p == "" {
|
||||||
return c, nil
|
return c, nil
|
||||||
@@ -57,16 +64,31 @@ func Load() (*Config, error) {
|
|||||||
if err := json.Unmarshal(b, c); err != nil {
|
if err := json.Unmarshal(b, c); err != nil {
|
||||||
return nil, fmt.Errorf("parsing %s: %w", p, err)
|
return nil, fmt.Errorf("parsing %s: %w", p, err)
|
||||||
}
|
}
|
||||||
if c.Hosts == nil {
|
if c.Contexts == nil {
|
||||||
c.Hosts = map[string]Host{}
|
c.Contexts = map[string]Context{}
|
||||||
}
|
}
|
||||||
return c, nil
|
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.
|
// 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
|
// An environment variable wins over the file, so a token can be supplied for
|
||||||
// invocation without ever being written to disk.
|
// one invocation without ever being written to disk.
|
||||||
func (c *Config) TokenFor(host string) string {
|
func (c *Config) TokenFor(host string) string {
|
||||||
if t := os.Getenv("LOOMCTL_TOKEN_" + envKey(host)); t != "" {
|
if t := os.Getenv("LOOMCTL_TOKEN_" + envKey(host)); t != "" {
|
||||||
return t
|
return t
|
||||||
@@ -74,11 +96,48 @@ func (c *Config) TokenFor(host string) string {
|
|||||||
if t := os.Getenv("LOOMCTL_TOKEN"); t != "" {
|
if t := os.Getenv("LOOMCTL_TOKEN"); t != "" {
|
||||||
return 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 {
|
func envKey(host string) string {
|
||||||
r := strings.NewReplacer(".", "_", "-", "_", ":", "_")
|
r := strings.NewReplacer(".", "_", "-", "_", ":", "_")
|
||||||
return strings.ToUpper(r.Replace(host))
|
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 {
|
if body == nil {
|
||||||
return fmt.Errorf("%s: unexpected 304 for a document we do not have", raw)
|
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
|
// 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
|
// 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
|
// Identical, so the assertion a lock makes — this local copy is the one
|
||||||
// being served — is verified rather than assumed.
|
// 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 {
|
if err := locks.Save(); err != nil {
|
||||||
return err
|
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 {
|
if err := os.WriteFile(dest, body.data, 0o644); err != nil {
|
||||||
return err
|
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 {
|
if err := locks.Save(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(out, "adopted %s\n", rel)
|
fmt.Fprintf(out, "adopted %s\n", rel)
|
||||||
fmt.Fprintf(out, " from %s\n", body.url)
|
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 == "" {
|
if etag == "" {
|
||||||
fmt.Fprintf(out, " etag (none served — check cannot ask conditionally)\n")
|
fmt.Fprintf(out, " etag (none served — check cannot ask conditionally)\n")
|
||||||
} else {
|
} 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
|
// 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
|
// 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.
|
// 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)
|
req, err := http.NewRequest(http.MethodHead, raw, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return ""
|
||||||
}
|
|
||||||
if cfg.TokenFor(req.URL.Host) == "" {
|
|
||||||
return // no credential was used, so the fetch was already anonymous
|
|
||||||
}
|
}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return ""
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode == http.StatusOK {
|
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
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprintf(out, " WARN this needed a credential — anonymously it is %s.\n", resp.Status)
|
public, known := selfVisibility(root)
|
||||||
fmt.Fprintf(out, " Confidentiality does not travel with the copy: this now lives in\n")
|
if known && !public {
|
||||||
fmt.Fprintf(out, " .loom/externals/ and is readable by anyone who can read THIS\n")
|
fmt.Fprintf(out, " NOTE private source, and this repository is not public either.\n")
|
||||||
fmt.Fprintf(out, " repository, which I cannot see. Do not adopt from a source less\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, " 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, " 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, " 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")
|
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 {
|
type fetched struct {
|
||||||
data []byte
|
data []byte
|
||||||
url string
|
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
|
return &fetched{data: b, url: resp.Request.URL.String()}, resp.Header.Get("ETag"), nil
|
||||||
case http.StatusNotFound:
|
case http.StatusNotFound:
|
||||||
return nil, "", fmt.Errorf("404 unresolved: %s was withdrawn, or this credential cannot see it — "+
|
return nil, "", &NotFoundError{URL: raw}
|
||||||
"over HTTP these are the same response", raw)
|
|
||||||
default:
|
default:
|
||||||
return nil, "", fmt.Errorf("%s: %s", resp.Status, raw)
|
return nil, "", fmt.Errorf("%s: %s", resp.Status, raw)
|
||||||
}
|
}
|
||||||
@@ -289,6 +343,8 @@ func Check(root string, out io.Writer) error {
|
|||||||
staged += did
|
staged += did
|
||||||
results = append(results, st)
|
results = append(results, st)
|
||||||
}
|
}
|
||||||
|
auditPrinted := &strings.Builder{}
|
||||||
|
auditExposure(root, locks, auditPrinted)
|
||||||
if err := locks.Save(); err != nil {
|
if err := locks.Save(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -309,6 +365,7 @@ func Check(root string, out io.Writer) error {
|
|||||||
if staged > 0 {
|
if staged > 0 {
|
||||||
fmt.Fprintf(out, "\n%d staged in %s — apply or discard; nothing here drifts into being kept.\n", staged, PoladDir)
|
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
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
-6
@@ -25,8 +25,10 @@ const Dir = ".loom/externals"
|
|||||||
const File = ".locks"
|
const File = ".locks"
|
||||||
|
|
||||||
const header = "# loomctl locks — one record per adopted document.\n" +
|
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" +
|
"# path<TAB>url<TAB>etag[<TAB>visibility]\n" +
|
||||||
"# whatever the default branch is at the time you ask.\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.
|
// Record is one adopted document.
|
||||||
type Record struct {
|
type Record struct {
|
||||||
@@ -38,8 +40,28 @@ type Record struct {
|
|||||||
URL string
|
URL string
|
||||||
// ETag is the publisher's, verbatim, including its quotes.
|
// ETag is the publisher's, verbatim, including its quotes.
|
||||||
ETag string
|
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.
|
// Set is every lock, keyed by path.
|
||||||
type Set struct {
|
type Set struct {
|
||||||
file string
|
file string
|
||||||
@@ -75,10 +97,14 @@ func LoadFile(file string) (*Set, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
parts := strings.Split(line, "\t")
|
parts := strings.Split(line, "\t")
|
||||||
if len(parts) != 3 {
|
if len(parts) != 3 && len(parts) != 4 {
|
||||||
return nil, fmt.Errorf("%s:%d: want 3 tab-separated fields, got %d", file, n, len(parts))
|
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()
|
return s, sc.Err()
|
||||||
}
|
}
|
||||||
@@ -112,7 +138,11 @@ func (s *Set) Save() error {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(header)
|
b.WriteString(header)
|
||||||
for _, r := range s.All() {
|
for _, r := range s.All() {
|
||||||
fmt.Fprintf(&b, "%s\t%s\t%s\n", r.Path, r.URL, r.ETag)
|
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-*")
|
tmp, err := os.CreateTemp(filepath.Dir(p), ".locks-*")
|
||||||
if err != nil {
|
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 (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"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/external"
|
||||||
|
"git.hypertheory-labs.dev/loom/loom-cli/internal/orient"
|
||||||
)
|
)
|
||||||
|
|
||||||
const usage = `loomctl — fetch what you depend on, and find out when it changed.
|
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 list <owner/repo> what a repository publishes
|
||||||
loomctl external add <url> [--path p] adopt one document and lock it
|
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 check ask every publisher whether theirs moved
|
||||||
loomctl external apply [path...] move a staged polad into place, lock and all
|
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
|
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.
|
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
|
loomctl grants no access. It reads what your credentials already let you read,
|
||||||
copying, and confidentiality does not travel with the copy. It cannot see who
|
and records where the copy came from — which is the part copy-and-paste loses.
|
||||||
may read the repository the copy lands in, so it reports the half it knows.
|
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
|
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 —
|
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
|
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.
|
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 orient [--out path] write .loom/orientation.md for whoever arrives next
|
||||||
LOOMCTL_TOKEN_<HOST>. loomctl never writes over the network, so a token it is
|
loomctl config which context is current, and from where
|
||||||
given should never carry write scope.
|
|
||||||
|
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.
|
Requires git on PATH, for list only.
|
||||||
`
|
`
|
||||||
@@ -63,6 +89,24 @@ func run(args []string) error {
|
|||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "external":
|
case "external":
|
||||||
return runExternal(args[1:])
|
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:
|
default:
|
||||||
return fmt.Errorf("unknown command %q\n\n%s", args[0], usage)
|
return fmt.Errorf("unknown command %q\n\n%s", args[0], usage)
|
||||||
}
|
}
|
||||||
@@ -75,9 +119,12 @@ func runExternal(args []string) error {
|
|||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "list":
|
case "list":
|
||||||
if len(args) != 2 {
|
if len(args) != 2 {
|
||||||
return fmt.Errorf("usage: loomctl external list <repo-url>")
|
return fmt.Errorf("usage: loomctl external list <owner/repo>")
|
||||||
}
|
}
|
||||||
return external.List(args[1], os.Stdout)
|
if strings.Contains(args[1], "://") {
|
||||||
|
return external.List(args[1], os.Stdout)
|
||||||
|
}
|
||||||
|
return external.ListByName(args[1], os.Stdout)
|
||||||
|
|
||||||
case "add":
|
case "add":
|
||||||
fs := flag.NewFlagSet("add", flag.ContinueOnError)
|
fs := flag.NewFlagSet("add", flag.ContinueOnError)
|
||||||
@@ -85,14 +132,23 @@ func runExternal(args []string) error {
|
|||||||
if err := fs.Parse(args[1:]); err != nil {
|
if err := fs.Parse(args[1:]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if fs.NArg() != 1 {
|
|
||||||
return fmt.Errorf("usage: loomctl external add <url> [--path p]")
|
|
||||||
}
|
|
||||||
root, err := root()
|
root, err := root()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return external.Add(root, fs.Arg(0), *path, os.Stdout)
|
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":
|
case "check":
|
||||||
root, err := root()
|
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) {
|
func root() (string, error) {
|
||||||
wd, err := os.Getwd()
|
wd, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user