A general-purpose editing tool for a Haverstack stack. Create, edit, and organize records of any type from a terminal, using your own editor, against a local SQLite file or a remote stack server. This document describes how it works and why the moving parts are where they are.
Package @haverstack/cli, repo github.com/haverstack/cli. Ships two bins: haverstack
(canonical, consistent with the haverstack-eleventy sibling) and hstack (a shorter
alias for daily use — examples here use it). Standalone, not
part of the core monorepo — same shape and toolchain as @haverstack/eleventy.
The editing counterpart to the library. A stack you can only reach through code is a stack you cannot casually use; the CLI is how a person writes into one without an app being built first.
Its defining constraint is that it is type-agnostic. It knows nothing about notes, articles, sites, or slugs. It knows how to fetch a type's schema, turn it into something editable, and validate what comes back. Every type in a stack — commons, app-defined, or one registered five minutes ago — is editable by the same tool on the day it is registered.
It is also script-composable. Every read command takes --json for piping into
jq; the CLI is a client you can build on, not only one you sit in front of.
- Slugs, permalinks, feeds, site building. Generator concerns. A slug collides only
relative to a page tree and permalink rules, so the uniqueness predicate is undefined
without them — a CLI able to answer it would be a site generator. That is
@haverstack/eleventy's job. - A TUI. The editor is your editor; the file manager is your file manager. (A
--pickfuzzy-selector for association targets is a plausible later addition; see Deferred.) - Serving. That is the stack server's job.
- Being a sync client. It reads and writes on demand. No background daemon, no local mirror.
- Authoring migration functions.
registerMigrationtakes a JS closure over old and new content — app code, not admin data a CLI flag can carry.hstack types defineregisters a schema; moving records across versions with a function stays in app code.
The CLI resolves one Stack per invocation and runs every command against it. Two
backends:
| Target | Adapter | Selected by |
|---|---|---|
| A local SQLite file | @haverstack/adapter-local LocalAdapter |
--stack <path>, $HAVERSTACK_STACK pointing at a path, or a profile whose target is a path |
| A remote stack server | @haverstack/adapter-api APIAdapter |
--stack <name> naming a server profile, or $HAVERSTACK_STACK naming one |
One seam, openStack(target), produces a Stack; everything downstream is written
against StackClient and does not know which backend it has. A configurable default
profile means hstack with no --stack still knows where to go.
- Local —
LocalAdapteryields an unscopedStack. Full trust: no type-level grant checks,includeUnlistedalways allowed,createdAt/updatedAtbackdatable on create. This is the same posture as an embedded single-app stack. - Server —
APIAdapteryields a session scoped to the profile's DID. That DID may be the stack owner or merely a grantee. Writes can come back asStackPermissionError;includeUnlistedis refused to anyone but the owner acting alone;createdAt/updatedAton the wire are dropped for non-owners.
hstack prints the active target and whether it is operating as owner or grantee at the top
of any command that writes, so a permission refusal is never a surprise about which
stack or whose authority was in play.
LocalAdapter.open() takes a process lock on the file (<path>.lock). The CLI holds it
only for the duration of one command — open, act, close — so it never blocks a stack
server or an eleventy build that wants the same file between invocations. This is why
the editing model below has the CLI process exit while your editor is open rather than
hold the stack open across a writing session (see The editing model).
A general-purpose tool talks to more than one stack, so configuration is global, never in the working directory and never in a repository.
$XDG_CONFIG_HOME/haverstack/config.toml # named profiles, defaults, editor prefs
$XDG_STATE_HOME/haverstack/edits/ # per-record locks and working copies
$XDG_DATA_HOME/haverstack/keys/ # DID private keys (JWK), when no OS keychain
(~/.config, ~/.local/state, ~/.local/share respectively when the XDG vars are
unset.)
default = "personal"
editor = "code --new-window" # falls back to $VISUAL, then $EDITOR
explorer = true # open a file manager on the working dir too
[profiles.personal]
url = "https://stack.example.com"
did = "did:key:z6Mk…" # this CLI's identity, not the stack owner's
key = "personal.jwk.json" # how to find the private key (a filename in the keys dir)
expectedOwner = "did:key:z6Mk…" # optional; open() refuses a server reporting anyone else
[profiles.personal.body]
"org.haverstack/article" = "text" # per-type body-field override (see below)
[profiles.scratch]
path = "/home/jen/notes/scratch.db" # a local file, not a URLA profile names either a url (server) or a path (local file). --stack accepts a
profile name, a bare path, or a https:// URL for a one-off server connection with no
stored profile (unauthenticated, for public reads). Resolution order when --stack is
absent: $HAVERSTACK_STACK, then the config's default.
Authentication to a server is the DID challenge–response handshake, not a shared token.
hstack stack add <name> --url <u> generates a did:key Ed25519 keypair, prints the DID
for the stack owner to grant, and stores the private key. APIAdapter.open() runs the
handshake on open and re-runs it when the token expires, so there is no token to manage
by hand. expectedOwner, when set, makes open() refuse a server whose discovery
reports a different owner.
Key custody is the CLI's job — core is explicit that it is an app concern
(identity.md: "not this library's job"). The key field in a profile is the
indirection point: a 0600 JWK file under $XDG_DATA_HOME/haverstack/keys/, named
<profile>.jwk.json. An OS-keychain backend (key = "keychain", via a lazily-loaded
optional dependency) is the planned alternative — adding it changes no config shape and
no code outside keys.ts, so it is deferred rather than designed around.
The key is persisted on first run whether or not a server is reachable yet, because the
asymmetry the spec warns about applies directly: losing it breaks nothing locally but
permanently ends the ability to authenticate to any server as that identity. A private
key round-trips through exportDidPrivateKeyJwk / importDidPrivateKeyJwk, and the
DidCredential handed to APIAdapter is { did, sign } where sign closes over the
imported key via signWithDid. hstack stack rm never deletes the key file — it prints
where it is and leaves removing it to the user.
Local files need no identity: an unscoped Stack names no requester. hstack stack add <name> --path <p> just records the (absolute) path.
An edit in progress is a lock plus a working directory. hstack new / hstack edit
materialize a record, open your editor on it, and exit. You edit — and save — as
freely as you like. hstack commit validates the working copy and writes it back as one
mutate(). hstack discard throws it away.
A synchronous spawn (git commit style) would hold the stack open for the whole session
— hours or days for a long article — blocking any other writer of a local file, and
losing everything if the editor or terminal dies. The stateful model:
- makes frequent saves free. The working copy is a plain file. Saving it fifty times
while drafting produces exactly zero version-history entries; only
hstack commitcallsStack.mutate(). - survives a dead editor. The working copy is on disk under XDG state; reopen it, or
hstack commitit, whenever. - lets you consult the stack mid-edit.
hstack show <other-id>in another terminal works because the CLI isn't holding the file open.
$XDG_STATE_HOME/haverstack/edits/<stack-hash>/<recordId>/
record.md # YAML front matter + body — what your editor opens
.hstack-lock.json # { recordId, typeId, mode, bodyField, baseVersion?, readonly?, startedAt, stack }
<anything else> # files dropped here become attachments on commit (Phase 6)
<stack-hash> is a short SHA-256 of the resolved stack target (a path or name (url)),
so the same record id being edited against two stacks never collides. On hstack new,
<recordId> is minted up front with generateId() and written into the scaffold as
id:, so the edit has a real id from the start.
Locks are per record, not global — editing one record must never block editing another, and referencing record B while writing record A is the common case. Nothing lives in the current directory or a repo; a working copy that ends up committed to git is a layout bug.
| Command | Does |
|---|---|
hstack new <typeId> [--parent <id>] [--id <id>] |
Fetch the type, scaffold record.md, take a lock in mode: "new", launch the editor. |
hstack edit <id> |
Fetch the record + its type, render record.md, snapshot version as baseVersion, take a lock in mode: "edit", launch the editor. Download existing embed attachments into the working dir. |
hstack status |
List every open edit for the active stack: record id, type, mode, age, and whether it is stale (working file gone). |
hstack commit [<id>] |
Parse record.md, validate against the schema, reconcile tags as a set, write via create() (new) or mutate() with ifVersion: baseVersion (edit). On success, release the lock and delete the working dir. <id> is required only when more than one edit is open. |
hstack discard [<id>] |
Delete the working dir and lock. Never touches the stack. --stale sweeps every stale edit. |
hstack new / hstack edit return once the working directory exists and hand the editor
launch to the CLI. A GUI editor is spawned detached, releasing any lock on a local
file immediately — the process exits and you keep working elsewhere until you're ready
to commit. A terminal editor (nano, vim, ...) can't run detached at all — it has no
window of its own, so without a controlling terminal it has nothing to draw into and
just exits — so hstack waits for one instead: not on a fixed name list alone, but only
when this process itself has a real terminal attached (--wait/--no-wait always
override the guess). A script or agent driving hstack as a subprocess never has one,
so it gets the same detached, immediately-returning behavior regardless of $EDITOR,
without needing to know about any flag. Waiting this way still doesn't commit anything —
that's -c / --commit's job, which always waits and commits once the editor exits,
the one-shot git commit feel. A file manager on the working dir opens before the
editor — including before a blocking one — when explorer = true is set in
config.toml (a standing preference) or --explorer is passed for just this call (e.g.
to drop an attachment before committing); off by default either way. Opening it first is
deliberate: opened after, it would only ever appear once you'd already closed the editor
you wanted it open alongside. The editor is config.editor, else $VISUAL, else
$EDITOR; with none set, new/edit just print the file path to open.
parentId moves with the record. Core's mutate() carries a content patch and a
parentId in one fenced write, so editing the parentId line and committing moves the
record — core checks the destination exists and refuses a cycle, surfaced as an ordinary
kept-copy failure if either is wrong. A move on its own is a no-bump write (below);
naming contentPatch alongside it is what puts the whole call back under ifVersion. A root record's rendered
buffer has no live parentId line to edit, so hstack edit always leaves a commented
# parentId: hint in its place — otherwise moving a record would be a capability with no
way to discover it from the file alone. Re-uploading files dropped into the working
directory is Phase 6.
Core (0.33–0.35) settled six operations as no-bump: associate, dissociate,
permissions, reparent, unlist and list. They leave version and updatedAt
exactly where they stand, take no snapshot, and are not fenced by ifVersion — a
set-add/remove composes correctly whatever order two writers land in, so there was never
a race for a precondition to guard. Only a content patch and the whole-record verbs bump.
Two consequences the CLI lives with:
tag/link/perm/attachreport no version. There is none to report, and printing the unchanged one would read as "this wrote at v4" rather than "v4 is where this record still is". They compare the record before and after instead, so a repeat saysalready taggedrather than claiming a write that did not happen.hstack commitreports the version its ownmutate()produced, not a re-read after the tag and attachment reconcile. Those reconcile passes cannot move it.
hstack edit records the record's version as baseVersion. hstack commit passes it as
ifVersion. If the record moved on the stack while you were editing — likelier here than
elsewhere because the window is a whole writing session — mutate() throws
StackVersionConflictError { expectedVersion, actualVersion }. The commit fails, the
working copy is left exactly as it is, and hstack prints the conflict plus how to see what
changed (hstack show <id> --history). Resolving it is manual: re-hstack edit a fresh copy
and reapply, or force past it with hstack commit --force (drops ifVersion,
last-writer-wins). Nothing is ever lost to a rejected write.
An edit whose record.md is gone is stale. hstack status marks it rather than
treating it as active; hstack edit on a record with only a stale lock reclaims it;
hstack discard --stale sweeps every stale edit for the stack. Editor-process liveness is
not a staleness signal — a detached GUI editor's launcher exits the moment it opens the
window, so a dead pid says nothing about whether the edit is still wanted.
On new or edit, the CLI fetches the type (stack.getType(typeId)) and derives the
editing form from its schema.
---
id: 01hx3k9m2p7q # readonly; blank on `new`
type: com.example/task@2
parentId: 01hx3k9m2p7q # native field; ~ for none
tags: [work, urgent] # reconciled as a set on commit
# dueDate: 2026-09-10 # optional field, scaffolded commented-out
title: Ship the CLI # required field, scaffolded present
done: false
_readonly:
version: 4
createdAt: 2026-08-01T10:00:00Z
updatedAt: 2026-09-01T09:00:00Z
entityId: did:key:z6Mk…
associations: # shown for context; edited via `hstack link` / `hstack tag`
- { kind: relationship, label: blocks, target: { scope: record, recordId: 01h… } }
permissions: # shown for context; edited via `hstack perm`
- { kind: permission, label: read, grantee: { scope: entity, entityId: did:key:z6Mk… } }
- { kind: anyone, label: read }
---
Body text goes here — the schema's one `text` field.
- Every non-body field becomes a front-matter key.
string/number/boolean/date/record-ref/file-refround-trip as YAML scalars;arrayandobjectfields as nested YAML. - Required fields are scaffolded present; optional fields commented-out. Discoverable
without being noisy —
hstack new --minimalscaffolds only required fields, andhstack editshows only fields that are set (plus--allto surface the rest). _readonlyis exactly that. Associations, permissions,version, and authorship are shown sorecord.mdis a faithful snapshot.commitcompares the block against whateditrendered: an unchanged block is dropped from the write, a present but altered block is refused with a pointer tohstack link/perm/tag, and a block deleted wholesale is fine — removing the snapshot changes nothing.
In order:
- Exactly one field of kind
text— that is the body. - More than one — require
--body <field>, or a per-type default inconfig.toml. - None — the record is front matter only, no body section.
This is the one convention the tool imposes on schemas it did not write. It holds for
every commons text type (note, article, message, post, page each have exactly
one text field) and degrades honestly rather than guessing. The body field's value
comes only from the body section, never a front-matter key of the same name. An empty
body for a required text field is treated as the omission it looks like — a
the body is empty error — even though core would accept "".
id, type, parentId, tags, and _readonly are the CLI's own front-matter keys. A
schema that declares a content field with one of these names (tags is the only
plausible one) can't round-trip through the file: scaffold and show skip it with a
# note: line, and commit leaves it untouched. Editing such a field needs the raw API.
The parsed front matter plus body is checked against the schema before any write —
mirroring core's own validateContent so the message names the field, working the same
local or remote. Core's write-path validation stays authoritative; this is the
pre-flight.
- required fields present (recursively, into
objectproperties andarrayitems). - a field the schema does not declare is rejected, at every depth — matching core's
own stance (a stray key is a typo, stale data, or a native field that landed in
content by mistake). A field declared
open: trueis the deliberate exception: its interior is unvalidated, though it is still held to being the array or object the schema says it is, and field-name characters are still checked inside it. datefields against the ISO shape core pins (a regex, thenDate.parseas a calendar check) — not bareDate.parse.file-refagainst SHA-256 hex; scalar kind mismatches (numberwhere astringwas declared, …) by path.array/objectstructurally, recursively, with indexed paths (emails[1].value).- reserved content keys (
__proto__,constructor,prototype) and field names containing. [ ] $ " * #rejected, matching core.
A validation failure re-opens the editor on the same buffer with the errors
prepended as comments, rather than discarding the edit. The lock and working copy stay
put until a commit succeeds or you hstack discard.
Following git's plumbing/porcelain line: free text is a fine interface for content and native scalars, and a bad one for a Crockford-32 record id or a DID. So the split is by how typeable the value is, not by mechanism.
tags:— a YAML list, mapped totagassociations on commit and reconciled as a set: tags added to the list associate, tags removed dissociate. Cheap to type, cheap to diff.parentId— a native field, not an association; a plain scalar key.
Everything whose value is an opaque identifier or a discriminated union. Each noun's
subcommands are verb-first (tag add <id> <label>, not tag <id> add <label>) —
consistent with stack add/types show rather than the earlier sketch:
hstack tag add|rm <id> <label>
hstack link add|rm <id> --label <l> --to <target>
hstack perm add|rm <id> --to <target> [--read] [--write]
hstack perm ls <id>
hstack grant add|rm <typeId> --to <target> <action>...
hstack grant ls [--type <typeId>] [--to <target>]
Typing a discriminated union as YAML in a text file is precisely what a text editor is bad at; a command with validation and, later, tab-completion is better even before any picker exists. All three name their other end the same way — see Naming a target below.
link, perm and grant all take --to <target>, one grammar parsed in one place
(src/target.ts) and narrowed per command:
--to value |
Resolves to | Accepted by |
|---|---|---|
anyone |
{ kind: 'anyone', label: 'read' } |
perm |
authenticated |
{ kind: 'authenticated' } |
grant |
did:key:z6Mk… |
the entity arm — sugar for entity: |
perm grant link |
entity:<did> |
the entity arm | perm grant link |
group:<id>/<role> |
the group arm; member, admin, or any when listing |
perm grant |
record:<id> |
{ scope: 'record', recordId } |
link |
record:<id>@<stackUrl> |
…in another stack | link |
external:<ns>/<id> |
{ scope: 'external', ns, id } |
link |
This vocabulary is the CLI's own, and deliberately wider than any single core union.
Core keeps three apart on purpose — a permission's grantee "is its own shape, not a
RelationshipTarget, which has no role and no group scope", and anyone is
a kind rather than a grantee so no dropped field can produce world-read. That split is
right for the data model and wrong for a person, who is naming Alice either way. So the
CLI parses one grammar and each command narrows to the arms it accepts. The narrowing is
where core's distinctions are enforced; the grammar is where the human convenience lives.
Two rules keep the two from blurring:
Every entry point returns one command's narrow type. parsePermissionTarget(),
parseGrantTarget(), parseGrantQuery() and parseLinkTarget() are what the commands
and the package's consumers call; the wide union is module-private and never escapes.
The table above is therefore a type, not a convention — parseLinkTarget() cannot
return a grantee, because RelationshipTarget has nowhere to put one. A single shared
parser still backs all four, so there is one grammar to keep correct rather than four
that can drift.
A target from the wrong tier is refused and told what to say instead, never converted. Mixing the tiers is the mistake with real consequences, so each command names the confusion it actually expects:
| Written | Refused with |
|---|---|
grant … --to anyone |
a grant cannot reach anonymous requesters — did you mean authenticated? |
perm … --to authenticated |
a record permission has no authenticated tier; anyone is the nearest and is wider |
link … --to group:X/member |
to link to the group's record, use record:X |
perm/grant … --to record:X |
to name a group, use group:X/<member|admin> |
link … --to anyone/authenticated |
an access tier is not something a record can point at |
The asymmetry in the first two rows is deliberate. anyone is the wider tier — it
reaches anonymous requesters, authenticated reaches only DID holders — so grant
suggesting authenticated narrows and is safe to offer, while perm may not offer
anyone back as a synonym. It names it and says which way it moves, leaving the
widening a choice rather than an autocomplete.
Three rules keep the grammar honest:
- Exactly one shape is inferred: a leading
did:is an entity. A DID is the one identifier every command takes, andentity:did:key:…reads badly on the most frequent call. Everything else names its scheme, and an unknown one is an error rather than a guess — core'sExternalTargetcarries an explicitnsfor the same reason. external:nests its namespace rather than sharing the top-level scheme slot.nsis open and user-chosen, so letting it compete withgroup:/record:would reserve words out of a namespace the CLI does not own.formatTarget()isparseTarget()'s inverse, soperm lsandgrant lsprint targets unelided in the same grammar their commands accept. A listing row is a command argument; copy-paste works, and the smoke test pins it.
The single seam is the point. --pick (below, deferred) resolves a partial filter to an
id and substitutes it into the invoking command — against three flag shapes that would be
three substitution paths and three sets of rules about which flag it may fill.
perm writes one element at a time. Record permissions are associations: the ACL is
two kinds over the same table — { kind: 'permission', label: 'read'|'write', grantee },
whose grantee is { scope: 'entity', entityId } or { scope: 'group', groupId, role },
plus { kind: 'anyone', label: 'read' } for world-read spelled affirmatively. Core's
grantAccess(id, el) / revokeAccess(id, el) add and withdraw exactly one, and perm
calls those rather than the permissions change-set key. The key replaces the whole set,
so between the read it would need and the write, whatever a second admin granted is gone;
a per-element write is the spelling that survives two people sharing one record at once.
An element's identity is its bit plus its grantee, and a role is required on a group —
member is the wider set, admin the narrower, and the two are different elements, so
group:<id> with no role is a parse error rather than a guess. anyone carries read
and nothing else; --write beside it is refused rather than written and then bounced by
core. perm add --read --write grants read first and perm rm withdraws write first,
because core's cross-element invariant is that no write lands in a set whose grantee
cannot read it — the CLI does not re-derive that rule, it just does not fight the one
ordering that satisfies it, and lets core's message teach the rest. perm rm naming
neither bit withdraws the target's access entirely; naming one withdraws just that one.
perm ls prints the whole ACL, which is otherwise only visible in show --json and the
_readonly block.
grant actions are core's GrantAction set — create, read-own, read-any,
update-own, update-any, delete-own, delete-any. Its --to is authenticated
(any entity holding a DID — the tier below anyone, which also reaches anonymous
requesters; the two never share a word), a DID, or a group at a role. grant ls takes
the same --to as a query, widened by the listing-only group:<id>/any; a DID listing
answers coverage (grants naming them, their groups, and every authenticated grant), so
it is not a preview of what grant rm would withdraw. Core enforces the action dependency (a
-any/-own mutate action needs a matching-scope read action in the same grant) and
names the missing one in its own error; the CLI does not re-derive that rule client-side,
since a copy could disagree with the answer that actually governs the write. revoke()
returns what it withdrew, so grant rm reports a count rather than a silent success.
Grants are _grant records under the hood and work through either backend.
- Files dropped in the working directory become
attachmentassociations oncommit, labelembed(the commons convention for files referenced from body text). This is the whole media workflow for an editing session: the working dir is already open in a file manager next to the editor.hstack editdownloads a record's existing embeds into the working dir first, so it's a faithful starting point either way. hstack attach add <id> --label <l> --file <p>/attach rm <id> --label <l> --file-id <sha256>for attaching outside an edit session, or with a non-embedlabel.add's mimeType is guessed from the extension (core's owninferContentTypeFromFilename, the same table a server uses when serving a download), falling back toapplication/octet-stream.
On commit, files in the working dir are reconciled against the record's current embed
attachments by content, not filename: a file's sha256 is its fileId, so a file
already present as an embed is skipped outright — no re-upload, no wasted write. A new
hash is uploaded (putAttachment) and associated; an embedded hash no file in the
directory still holds is dissociated (never deleted — that is collectAttachmentGarbage's
job, on its own grace period, not commit's). This runs for new as much as edit: a
file dropped into a not-yet-committed working directory is embedded on the record's first
write. A read or write failure on one file is a warning appended to the commit message,
never a blocked commit — the same posture downloadEmbeds already took.
The association also records attachmentRecordId — the specific _attachment record
putAttachment() just created — so a display name (in _readonly, and the filename
downloadEmbeds writes into the working dir) resolves through core's
resolveReferencedAttachment() rather than always picking the fileId's globally-earliest
upload. This matters because content is deduplicated but metadata isn't: two records can
reference byte-identical content uploaded under different filenames, and without this
each would show the other's name. Best-effort like everything else here — a stale or
missing pointer just falls back to the same resolution resolveReferencedAttachment()
already does with none.
Core gates reference creation on read access to the target: a relationship to a record
in this stack, a parentId, and an attachment association each require that the writer
can see what they point at. Against a server as a grantee this can fail at commit or at
link. The CLI does not reword that refusal — a missing target and an unreadable one
are deliberately indistinguishable (the anti-oracle property access-control.md documents:
telling them apart would let a grantee learn something a plain permission error does
not), so the honest thing is to show the same permission-denied message core already
gives, not to invent a more specific one the server never promised.
| Command | Purpose |
|---|---|
hstack stack add <name> (--url <u> [--expected-owner <did>] | --path <p>) |
Create a profile; generate + store a key for a server profile |
hstack stack ls / hstack stack use <name> / hstack stack rm <name> |
Manage profiles and the default |
hstack types |
List registered types (id, name, schemaHash) |
hstack types show <typeId> |
Print a type's schema |
hstack types define <schema.json> |
Register a type from { id, name, schema, migratesFrom? } |
hstack ls <typeId> | --base <baseId> |
List records of a type — --parent <id>/--root, --tag <l>…, --limit N |
hstack show <id> |
Print a record — --history for version history |
hstack new <typeId> |
Scaffold and open a new record — --parent, --id, --minimal, --body <field> |
hstack edit <id> |
Open an existing record — --all, --body <field>, -c/--commit |
hstack status |
List open edits for the active profile |
hstack commit [<id>] |
Validate and write back — --force to drop the ifVersion fence |
hstack discard [<id>] |
Abandon a working copy — --stale to sweep stale locks |
hstack rm <id> |
Soft-delete — --hard to purge (owner-only on a server), reporting what it referenced |
hstack restore <id> |
Undelete |
hstack versions <id> |
Version history |
hstack tag add|rm <id> <label> |
Add or remove a tag |
hstack link add|rm <id> --label <l> (...) |
Add or remove a relationship (above) |
hstack perm add|rm <id> (...) / hstack perm ls <id> |
Grant or withdraw one element of a record's ACL; list the whole of it (above) |
hstack grant add|rm <typeId> (...) <action>... / hstack grant ls |
Type-level grants (above) |
hstack attach add|rm <id> --label <l> (...) |
Attach or detach a file outside an edit session (above) |
Every read command takes --json. Every listing command loops the cursor to
exhaustion or honours an explicit --limit — cursor === null is the only
end-of-results signal, an empty page is not the end, and total is null through a
server, so there is no count to check against. A command that returned one page and
implied it was the whole set would be a truncation bug.
The CLI defines none. It reads GET /types (or the local equivalent) and works with
whatever it finds. hstack types define registers a type from a schema file — the way a
stack gets bootstrapped without writing code — calling stack.defineType(id, name, schema, { migratesFrom }).
Registering an identical schema (same schemaHash and name) returns before any write,
so re-running a define script is cheap. An illegal schema change on an existing id
throws StackSchemaDriftError; the CLI does not catch it or re-derive its own message —
core's already names every violation and states the remedy (a new version, …@n+1, plus,
in app code, a migration) — so it propagates verbatim rather than the CLI making the user
go read about drift. Additive-in-place evolution (new optional fields only) is accepted
without a version bump, matching core.
Migration functions stay out of scope: registerMigration takes a JS closure, and
commitMigration / migrateAll are owner-acting-alone operations best driven from the
owning app.
--pickfor association targets. A short fuzzy-selector (backed bystack.query()) that resolves a partial filter to an id and substitutes it into the invokinglink/perm/grantcommand's--to. Additive — it fills one slot with a string the grammar already accepts — and the natural place to grow toward a fuller interactive mode if flags prove clunky. Not a v1 blocker; revisit once it's clear which commands get used with unfamiliar ids often enough.- Watch / live status. The CLI reads and writes on demand; a
hstack watchoverstack.subscribe(server only —LocalAdapterhas nosubscribeChanges) is possible but out of scope for an editing tool. - Deep nested-object editing. YAML front matter is fine for a menu's item list and
unpleasant for anything deeper. The ceiling is worth knowing before promising every
schema edits equally well; a
$EDITOR-on-a-JSON-fragment escape hatch for one field is the likely answer if it bites. - Bulk operations.
hstack rm/hstack tagover a query result. Scriptable today viahstack ls --json | jq | xargs; a native--from-stdinis a convenience, not a capability.
The package's own tests use MemoryAdapter for speed and a temp LocalAdapter file
where adapter-specific behavior matters; tests/server-integration.test.ts runs the
same commands against a real, listening @haverstack/server instance for the
server-only paths (permission refusals, includeUnlisted, ifVersion conflicts).
scripts/smoke.mjs spawns the actual built dist/cli.js binary end to end, so a
packaging or argv-parsing mistake the in-process suite can't see still fails CI.