From 119cc079ccb1a625b962ce2a7fcd9b0d58f1c126 Mon Sep 17 00:00:00 2001 From: Aditya Dhar <74172325+ProJedi1234@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:08:28 -0400 Subject: [PATCH] feat: publish an image or a video with `stele attach` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server has stored attachments since it learned to, and its skill document already tells agents to run this command — so until now the live contract named a command that did not parse. That is the same failure `--ttl` had, in the other direction: last time the document was stale, this time the client was. The request is a `publish` with a binary content type, because on the server an attachment is a page whose body is bytes. What earns it a command of its own is the answer, not the request. An attachment has two URLs — the bytes under `/static/`, and a viewer page about the file — and mixing them up fails silently, because an `` pointed at the viewer renders nothing and both answer 200. So stdout is the bytes, which is the value `src=$(stele attach chart.png)` captures and the one a page-writing agent needs; the viewer goes to stderr under the deadline, for the human who wants to send someone a link. `--json` names both and calls neither of them `url`. That key means "the viewer" on every other command, so whichever meaning it took here would be wrong half the time and wrong silently — a caller reaching for `.url` would get a working URL of the wrong kind. Absent, they get nothing, which is a failure they can see. An unrecognised extension is refused before the upload rather than guessed at. `publish` may fall through to `text/html` because that is what it is for; the same shrug on a JPEG spends the upload and comes back as a 415 naming a type nobody chose. `--filename` defaults to the file's own basename and is omitted rather than refused when that name is one the server would reject — the caller never typed it, and the server's fallback is a working download where a 400 about an unpassed parameter is not. An explicit `--filename` travels untouched. Exit 6 and its two messages widen to cover an attachment. The status is the same and the code stays one, because the caller's next move has the same shape either way; what differs is the advice, and 413 now carries both halves rather than telling someone with an oversized video to drop their inline images. 0.4.0, in this commit: the server version-gates on the User-Agent, and a capability its skill references should be a version an operator can require. Co-Authored-By: Claude Opus 5 --- README.md | 32 ++-- Sources/SteleKit/Inputs.swift | 7 + Sources/SteleKit/SteleError.swift | 15 +- Sources/SteleKit/Version.swift | 2 +- Sources/stele/Exit.swift | 9 +- Sources/stele/PageCommands.swift | 222 +++++++++++++++++++++++++- Sources/stele/Stele.swift | 1 + Tests/SteleKitTests/InputsTests.swift | 10 ++ scripts/integration-smoke.sh | 100 ++++++++++-- 9 files changed, 371 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 835f0ea..c4cff8a 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,11 @@ $ stele publish report.html --slug q3-report --ttl never https://stele.example.com/q3-report kept until deleted +$ stele attach chart.png --ttl never +https://stele.example.com/static/plain-amber-heron +kept until deleted +page about it: https://stele.example.com/plain-amber-heron + $ stele update q3-report report.html https://stele.example.com/q3-report kept until deleted @@ -48,6 +53,13 @@ document. The deadline under the URL is on stderr for that reason, and it is pri because the server's default is *ephemeral* — a page published with no `--ttl` is eventually unpublished, and a caller handed only a URL would find that out when the link broke. +`stele attach` is the one command whose stdout is not the URL the server answered with. An +attachment has two: the bytes, under `/static/`, and a viewer page about the file. The bytes are +what a page embeds and the viewer is what you send a person, and mixing them up fails silently — +an `` pointed at the viewer renders nothing, and both answer `200`. So the bytes go to +stdout, where `src=$(stele attach chart.png)` captures them, and the viewer goes to stderr +beneath the deadline. `--json` names both and calls neither of them `url`. + ## Commands | Command | Who runs it | Does | @@ -56,6 +68,7 @@ unpublished, and a caller handed only a URL would find that out when the link br | `stele auth status` | agent or human | Host, client name, scopes, expiry — never the token. | | `stele auth logout` | human | Forgets the local credential. Does not revoke it. | | `stele publish [--slug ] [--ttl ]` | agent | `POST /pages`. Prints the URL on stdout and the page's deadline on stderr. | +| `stele attach [--slug ] [--ttl ] [--filename ]` | agent | `POST /pages` with a binary type. Publishes an image, video or PDF. Prints the URL of the *bytes* on stdout — the one that goes in an `` — and the viewer's under the deadline on stderr. | | `stele update ` | agent | `PUT /pages/:slug`. Never creates, and never changes the deadline. | | `stele amend [--slug ] [--ttl ]` | agent | `PATCH /pages/:slug`. Renames a page, moves its deadline, or both, without touching a byte of it. A rename is a hard move — the old URL starts 404ing at once. | | `stele delete ` | agent | `DELETE /pages/:slug`. Permanent and immediate — the slug is freed and returns to the pool, so every link to it breaks. Prints nothing on stdout; the confirmation is on stderr. | @@ -206,7 +219,7 @@ table. | 3 | the server rejected the credential — ask the user to log in again | | 4 | valid credential, insufficient scope — an operator has to run this | | 5 | that slug is taken — ask for a different `--slug` | -| 6 | the page is too large or the wrong type | +| 6 | the file is too large or a type the server will not store | | 7 | no such page or client | | 8 | the CLI is too old — reinstall it and retry once | | 9 | could not reach the server — retryable | @@ -402,7 +415,7 @@ mint the first client. Arguments may also arrive as `STELE_SMOKE_HOST`, `STELE_S | `--psql ` | optional | A command that reaches the server's database, e.g. `docker exec stele-postgres psql -U stele -d stele_smoke`. Only the attribution check needs it, because no route reports who wrote a page — without it that one check prints `skip`. | It walks the whole documented lifecycle — mint, log in, `auth status`, publish, fetch the bytes -back and compare them, update, amend, `--expires-in` there *and* back, expiry actually enforced, +back and compare them, attach, update, amend, `--expires-in` there *and* back, expiry enforced, a publish-only credential answered `200` by whoami and `403` by the admin routes, revocation that really stops working, the exit-code vocabulary, the version gate, and the byte-identity of every `404`. It prints each check, stops at the first failure, and exits non-zero. @@ -424,13 +437,14 @@ Two things it does not do, stated because a canary you trust wrongly is worse th refuses a pipe; the script asserts the *refusal* — which is the load-bearing custody rule — and then seeds the credential file at `0600` in the documented format for the rest of the run. The prompt, the echo-off read and the verify-before-write are not covered. -- **It leaves litter.** The server has a delete route but this CLI ships no command that reaches - it, so each run leaves eight pages and three - credential rows behind; it revokes every credential it mints, but a revoked row is still a row. - Five of those pages expire on their own — the lifetime checks publish several deliberately — - but three were published or amended to `--ttl never` and will still be there next year. The - footer names those three, since they are the only ones a human ever has to clean up. Run it - against a throwaway deployment, never against production. +- **It leaves litter.** Each run leaves ten pages and three credential rows behind. The pages it + deletes it deletes on purpose, to prove the verb works; the rest are the evidence the later + checks read, so they outlive the run. Credentials cannot be cleaned up at all — it revokes + every one it mints, but a revoked row is still a row. Seven of those pages expire on their own + — the lifetime checks publish several deliberately — but three were published or amended to + `--ttl never` and will still be there next year. The footer names those three, since they are + the only ones a human ever has to clean up. Run it against a throwaway deployment, never + against production. It also moves `~/.config/stele/credentials.json` aside and restores it on every exit path, including a failed check — see the `HOME` note under Configuration for why it cannot simply use a diff --git a/Sources/SteleKit/Inputs.swift b/Sources/SteleKit/Inputs.swift index 3344d22..5cdd723 100644 --- a/Sources/SteleKit/Inputs.swift +++ b/Sources/SteleKit/Inputs.swift @@ -59,6 +59,13 @@ public enum ContentType { /// What `stele attach --content-type` offers at TAB. public static var knownAttachments: [String] { Set(attachmentsByExtension.values).sorted() } + /// The same table read the other way, for the message a refused file earns. + /// + /// Extensions rather than types, because they are what the person is holding. Told that a + /// deployment takes `image/png`, someone looking at `diagram.graffle` still has to work out + /// what to do; told the list is `png, jpg, …`, they already know. + public static var knownAttachmentExtensions: [String] { attachmentsByExtension.keys.sorted() } + /// The type to upload a file as, or nil when the extension says nothing. /// /// Optional where `inferred` is not, and that difference is the whole point. `text/html` is diff --git a/Sources/SteleKit/SteleError.swift b/Sources/SteleKit/SteleError.swift index a85c05c..64539d4 100644 --- a/Sources/SteleKit/SteleError.swift +++ b/Sources/SteleKit/SteleError.swift @@ -95,16 +95,23 @@ public enum SteleError: Error, Equatable, CustomStringConvertible { name, or retire the existing one with `stele admin clients revoke ` and \ mint again — rotating a credential is revoke-then-mint under the same name. """ + // Two limits behind one status, and the advice differs by which was hit. A page's is + // small and is usually blown by something that did not need to be inline; an + // attachment's is far larger and blowing it means the file is genuinely too big. This + // side cannot tell them apart — the status is the same and the command is not in scope + // here — so both remedies are named rather than the wrong one being guessed at. case .pageTooLarge(let detail): return """ - the page is over the server's size limit\(Self.suffix(detail)) Drop inline \ - images and link them instead — the limit is on the HTML, and a data: URI \ - counts against it. + the file is over the server's size limit\(Self.suffix(detail)) For a page, \ + publish images with `stele attach` and link them instead of inlining them — a \ + data: URI counts against the HTML. For an attachment, it is over this \ + deployment's ceiling, which is a much larger one. """ case .unsupportedContentType(let detail): return """ the server will not store that content type\(Self.suffix(detail)) Publish one \ - of the types it lists, or leave the type to the CLI. + of the types it lists, or leave the type to the CLI — pages and attachments \ + accept different ones. """ case .upgradeRequired(let detail): return """ diff --git a/Sources/SteleKit/Version.swift b/Sources/SteleKit/Version.swift index 39f664b..320797d 100644 --- a/Sources/SteleKit/Version.swift +++ b/Sources/SteleKit/Version.swift @@ -8,7 +8,7 @@ public enum SteleVersion { /// Bumped by hand. Nothing derives it from git: a release built from a tarball or an /// unclean tree must still report a version the server can compare. - public static let current = "0.3.0" + public static let current = "0.4.0" /// Sent on every request. The `stele-cli/` product token is what the server matches on. public static var userAgent: String { "stele-cli/\(current)" } diff --git a/Sources/stele/Exit.swift b/Sources/stele/Exit.swift index 542602f..e24a3fb 100644 --- a/Sources/stele/Exit.swift +++ b/Sources/stele/Exit.swift @@ -40,8 +40,11 @@ enum Exit { /// which is why the message carries the route's own advice and this table does not repeat it. static let slugTaken: Int32 = 5 - /// `413` / `415` — the page itself is the problem. Retrying is pointless; the input changes - /// or nothing does. + /// `413` / `415` — the file itself is the problem, whether it was a page or an attachment. + /// Retrying is pointless; the input changes or nothing does. The two are one code because + /// the caller's next move is the same shape either way, and `SteleError` carries the half + /// that differs: a page over the limit usually has something inline that `stele attach` + /// should have published, and an attachment over it is simply too big for the deployment. static let pageRejected: Int32 = 6 /// `404` — an update to a slug that does not exist, an amendment or a deletion aimed at one @@ -70,7 +73,7 @@ enum Exit { (credentialRejected, "the server rejected the credential — ask the user to log in again"), (forbidden, "valid credential, insufficient scope — an operator has to run this"), (slugTaken, "that slug is taken — ask for a different --slug"), - (pageRejected, "the page is too large or the wrong type"), + (pageRejected, "the file is too large or a type the server will not store"), (notFound, "no such page or client"), (upgradeRequired, "the CLI is too old — reinstall it and retry once"), (unreachable, "could not reach the server — retryable"), diff --git a/Sources/stele/PageCommands.swift b/Sources/stele/PageCommands.swift index 4c5c8d2..edfc814 100644 --- a/Sources/stele/PageCommands.swift +++ b/Sources/stele/PageCommands.swift @@ -60,7 +60,10 @@ enum PageIO { /// /// Absolute, never "in 7 days", for the reason `Format.moment` already gives: the follow-up /// question to a relative date is always "so what date is that". - private static func lifetime(_ location: PageLocation) -> String { + /// + /// Not private, because `attach` prints its own stdout line and this one underneath it. One + /// spelling of a deadline across every command that has one to report. + static func lifetime(_ location: PageLocation) -> String { guard let expiry = location.expiresAt else { return "kept until deleted" } return "expires \(Format.moment(expiry))" } @@ -146,6 +149,223 @@ struct PublishCommand: SteleCommand { } } +/// Publishes bytes — an image, a video, a PDF — rather than a page. +/// +/// The same route as `publish`, because on the server an attachment *is* a page whose body is +/// bytes. What makes this a separate command is not the request but the answer: an attachment +/// has two URLs, and which one a caller gets handed decides whether the page they write works. +struct AttachCommand: SteleCommand { + static let configuration = CommandConfiguration( + commandName: "attach", + abstract: "Publish an image, video or file. Prints the URL of the bytes.", + discussion: """ + For the things a page links to rather than the page itself. Publish the file first, \ + then put the URL you get back into the page you write: + + src=$(stele attach screenshot.png) + + Prints nothing but that URL on stdout, so the capture above works. It is \ + deliberately the URL of the *bytes* — the one that belongs in an or a \ +