Documentation
¶
Overview ¶
Package flow holds the interactive, CLI-mode renderer and the deploy sequence that ties pre-flight, packing, upload and log streaming together into what a person running `curious deploy` sees.
The sequence is here now and `curious deploy` runs the whole of it: the archive is packed, uploaded and built, the log of that build is rendered, the built deploy is published, and the address it answers at is printed. Deploy hands its caller a Handoff so that the archive it left on the machine has an owner, not because the run is unfinished.
Why the stream is a narrator and not an authority ¶
The event stream's terminating event carries the BUILDER's own claim. Output validation runs after it, and can still refuse what the build produced — so a client that treated that event as the settled truth would tell somebody their build succeeded and then report a failure at the next step, which is a description of the wrong event. This package renders what the stream said, stops only on a build the server says failed, and leaves the rest of the question to the call that can actually answer it.
This is where the DECISIONS about a result are made rather than where the result is produced — what a warning costs, what a hard stop costs, and what to do when there is nobody to ask — which is the split that lets the agent-facing surface be a second renderer instead of a second set of checks.
Why the sequence lives here and not in the command ¶
The order the steps run in is a product decision with reasons — local truths before global state, the capacity check beside the login it gates, one walk feeding three readers — and every one of those reasons is testable without a terminal, a real endpoint or a subprocess. In the command it would be reachable only by running the binary, which is how an ordering rule comes to have no row that can see it.
Why the login is a state machine and not a function ¶
One rule shapes it: a wrong or expired code never restarts the flow. Every obvious implementation breaks that rule the same way — the verify call fails, the function returns the error, and the caller begins again at "what is your email?" — so somebody who mistyped one digit is asked to retype their address while the code they were sent is still valid and now out of reach. Writing it with the states named is what makes the rule survive the error paths, because every recovery has to name the state it goes to, and no failure edge names the first one.
The second thing shaping it is what the server will not say. The step that sends a code answers identically whether it sent one, silently declined, was over a send budget or was in a cooldown, so this client is blind by design and its copy has to be honest about a state it cannot observe: where a code would go, and what to do when nothing arrives — never that one is on its way.
The last line does not claim the site is reachable ¶
An address begins answering a little after the deploy that owns it is published — observed once at about half a minute, which is one measurement rather than an upper bound. So the closing narration says the deploy was published, says the address may take up to about a minute to start answering, and says what to do about a placeholder page in the meantime. It does not poll the address and does not sleep: a delay is a guess, a guess long enough to be safe costs more than the wait it hides, and asking one location whether it is serving yet is not the claim being made. Machinery that genuinely waits belongs where every surface inherits it rather than in this one command.
Which stream a line goes to is decided by where it CAME FROM ¶
Server-originated content — the build's log lines and the diagnostics the server sends alongside them — goes to STDOUT, so that a redirected log is the log the server wrote. Everything this client says about the run — that a connection dropped and is being retried, that nothing has moved for a while, how the run ended — is narration and goes to STDERR, where it can be watched without landing in the file somebody is keeping.
The test is provenance, not shape: an error event is rendered through a sentence of this client's own, and still goes to stdout, because the server writes that same text into the same log it writes the build output into. Splitting them would produce a saved log missing the line that explains the rest of it. A phase marker is the other way round — the server sends it, but it is progress this client narrates rather than content the log keeps, so it goes to stderr.
The distinguishing question, when a new kind of line appears: WOULD THE SERVER'S OWN LOG HAVE THIS LINE IN IT? If yes, stdout. If it only exists because this client is running, stderr.
Index ¶
- Variables
- func CapacityGate(ctx context.Context, deps CapacityDeps) error
- func Login(ctx context.Context, deps LoginDeps) error
- func PublishedURL(subdomain string) string
- func RenderPreflight(p Prompter, report check.Report, elapsed time.Duration) error
- func UnauthenticatedClient(endpoint string) (*api.Client, error)
- type Authenticator
- type CapacityAPI
- type CapacityDeps
- type CapacityPrompter
- type DeployDeps
- type DeployProgress
- type DeployPrompter
- type Handoff
- type LoginDeps
- type LoginPrompter
- type LoginRefusal
- type NoLogin
- type Outcome
- type Prompter
- type StoredLogin
- type StreamReport
- type StreamReportDeps
- type StreamTraceEvent
- type StreamTraceKind
- type TokenWriter
- type VerifyRequest
- type WaitlistJoiner
- type WaitlistOffer
Constants ¶
This section is empty.
Variables ¶
var ErrEndpointUnusable = errors.New(
"the login flow needs the API endpoint this run is talking to, in a form " +
"that can be compared with the endpoint stored beside a saved token")
ErrEndpointUnusable is what Login returns when the endpoint it was handed cannot be compared with the one recorded beside a stored token.
It is a sentinel because the caller's response to it differs from every other ending here: it is a wiring mistake in this program or a mis-set variable in the environment, not something the person at the terminal did. It deliberately carries no copy of the offending value — a base URL can carry a password, and a refusal is not the place to find that out.
var ErrNoLogin = errors.New("this machine holds no login for the endpoint this run is talking to")
ErrNoLogin marks a run that holds no token for the endpoint it is talking to.
IT IS A SENTINEL BECAUSE THE ACTION IS THE CALLER'S TO NAME. The FACT is the same everywhere — there is no usable credential on this machine for this endpoint — and what to do about it is not: the command walks the person through a login as part of the run it was already doing, and an agent has two separate calls to make and has to be told which. A failure built here would have to guess which surface was asking, and would be wrong for one of them.
It says THAT there is no login and nothing about why. The why, where there is one, rides on the type below — and nothing branches on it, which is the config package's own contract for that value: it is for a person to read, and a caller that must behave differently for different causes branches on a field instead.
Functions ¶
func CapacityGate ¶
func CapacityGate(ctx context.Context, deps CapacityDeps) error
CapacityGate is the step that runs immediately before the login it gates: it asks whether curious.pub has room for a new account today, and when the answer is no it offers the waitlist and stops the run.
It returns nil to mean CONTINUE. Every other ending is an error carrying the copy a person reads, and the closed-door endings are marked with ui.ServerClosed so the run costs ui.ExitServerClosed.
Why it runs here and not earlier ¶
The daily cap counts new ACCOUNTS, and an account is spent at the login step. So the gate is worth running exactly when a login is going to be needed — with a usable token already stored, a run spends none of the day's capacity, and stopping it would be refusing work that can land. That is what HaveToken decides, and it decides it before any request is made.
Why it does not "assume open and continue" ¶
A check that cannot be made STOPS the run. The alternative walks somebody through a login and a pack that may have nowhere to go, which is the one thing this step exists to prevent — so a network failure is an ending here rather than a shrug.
func Login ¶
Login runs the email-and-code login and returns nil ONLY when a token has been stored.
The rule, made structural ¶
No failure edge points back at stateAskEmail. Every naive implementation of this flow violates it: the verify call returns an error, the function returns that error, and the caller starts again at "what is your email?" — so somebody who mistypes one digit is asked to retype their address, and the code they were sent is still valid and now unreachable. Writing this as an explicit machine, with the transitions named, is what makes the rule survive contact with the error paths, because every recovery has to name the state it goes to.
The address is asked for once. After that the only ways out are a stored token, a stop, or the person cancelling.
func PublishedURL ¶
PublishedURL composes the address a published deploy answers at, out of the LABEL the server sent and the domain compiled in above.
THE FUNCTION IS THE EXPORT AND THE CONSTANT IS NOT, which is the whole shape of this being exported at all. A second surface needs the same address from the same label, and there are two ways to give it one: hand out the domain, or hand out the composition. The first puts a copy of the domain in every package that ever prints an address and leaves each of them to remember the dot, the scheme and the escaping — and the three arguments below are exactly the kind of reasoning that does not survive being retyped. The second keeps one site where all of it happens, so a caller cannot get any of it wrong without deleting a call.
The consequence is checkable rather than merely intended: no package outside this one can spell the domain at all, and a guard asserts that the agent-facing surface does not.
IT IS BUILT THROUGH net/url RATHER THAN BY CONCATENATION, and that is not a style choice. Written as a "https://" literal joined to the label, the scheme and its separator are a compiled-in URL fragment sitting in a source file — which this module refuses, and refuses for a reason that applies here as much as anywhere: a host a reader cannot find by grepping for one declaration is the shape a phone-home takes, and a scheme fragment beside a domain constant is that host written in two pieces. Assembling the value in the type the standard library has for the job produces the identical string with no such fragment anywhere.
MEASURED RATHER THAN ASSUMED, because a claim about a guard made without running the guard is how this decision was got wrong before it was got right. Written as the concatenation, the URL guard reds twice over one line — once for a compiled-in URL that is not a named constant, and once for a host being assembled out of scheme fragments. Written this way it is silent, and the value in the binary is the same.
IT ALSO ESCAPES, which the concatenation would not. The label is the SERVER's and this string is printed for somebody to click; url.URL encodes the host on its way out, so a label carrying anything a host may not carry arrives visibly escaped rather than as something else. That is why nothing downstream escapes it a second time: there is no byte left for a terminal to obey, and a call that cannot fire is an assertion with no reachable path to failure.
func RenderPreflight ¶
RenderPreflight shows a pre-flight result to a person and returns what the deploy should do about it.
THE ENGINE PRODUCES FINDINGS; THIS DECIDES WHAT BLOCKING MEANS. The same warning is a question here and a non-blocking note on the result an agent reads, so the decision cannot live inside a check — and with the split in the right place the agent-facing surface is a second renderer rather than a second set of checks.
The returns are the program's existing vocabulary rather than a new one, so the exit code is decided in the single place that decides every other exit code:
- nil — nothing found, or the user agreed to continue.
- a failure carrying the copy — hard stops. Non-zero, no prompt.
- the cancellation sentinel — the user declined. Exit 0: they made a choice and the tool obeyed, and a non-zero code would make every wrapper script treat that as a fault.
- the no-terminal sentinel — warnings pending with nobody to ask. Neither continue nor abort: both would be the program deciding something it was not asked to decide.
func UnauthenticatedClient ¶
UnauthenticatedClient builds the client the calls that take no bearer token use, for an endpoint this run resolved.
IT EXISTS FOR ITS REFUSAL rather than for its one line of construction. An API address this client will not dial has a message written for it here, and that message echoes NEITHER the value NOR the underlying error — because a base URL can carry a username and a password, and the one refusal that cannot redact is the parse failure, which quotes the entire string it was handed. A caller that built the client itself and returned that error would put a password into whatever it renders to.
Every construction of a client in this program now goes through a function that owns that refusal, so there is nowhere left to get it wrong.
Types ¶
type Authenticator ¶
type Authenticator interface {
AuthStart(ctx context.Context, req wire.AuthStartRequest) (*wire.AuthStartResponse, error)
AuthVerify(ctx context.Context, req wire.AuthVerifyRequest) (*wire.AuthVerifyResponse, error)
}
Authenticator is the slice of the API client this flow needs. Neither call takes a bearer token: the second one RETURNS the token every later call will spend.
type CapacityAPI ¶
type CapacityAPI interface {
WaitlistJoiner
Capacity(ctx context.Context) (*wire.CapacityResponse, error)
}
CapacityAPI is what the gate itself needs: the check, and the call the offer makes when the answer is no.
type CapacityDeps ¶
type CapacityDeps struct {
// Prompt is the terminal. Required.
Prompt CapacityPrompter
// API is the client. Required.
API CapacityAPI
// HaveToken reports whether this run already holds a usable token —
// that is, whether a login is going to be needed at all.
//
// It is an ARGUMENT rather than something this gate looks up, and
// the reason is that the answer has already been worked out by the
// time anything gets here: the deploy sequence reads the stored
// configuration before it makes any network call, and a second read
// would be a second chance to disagree with the first about which
// file was read and what was in it.
//
// Its zero value runs the gate, which is the safe direction: a
// caller that forgets to set it spends one unauthenticated request
// and gets a correct answer, where the opposite default would skip
// the check for everybody.
HaveToken bool
// Now is the clock. Optional; it is also where the RENDERING ZONE
// comes from — see reopensPhrase, which shows a reset time in the
// location the clock's own value carries. time.Now returns a time in
// the machine's local zone, so production renders local without this
// package ever naming a zone.
Now func() time.Time
}
CapacityDeps is everything CapacityGate needs from outside itself.
type CapacityPrompter ¶
type CapacityPrompter interface {
Step(format string, args ...any)
Email(prompt string) (string, error)
Confirm(question string, defaultYes bool) (bool, error)
}
CapacityPrompter is the slice of the terminal the closed-door path needs: narration, an address, and a yes-or-no question. Both consumers of it are on that path — the offer below, and the gate that hands off to it.
It is declared HERE, by the consumer, rather than exported by the package that implements it — the same arrangement Prompter and LoginPrompter already make. It is deliberately NOT LoginPrompter with one method dropped: nothing here asks for a line of free text, and a seam that asked for one would be describing a terminal rather than this flow's needs.
type DeployDeps ¶
type DeployDeps struct {
// Dir is the directory to deploy, as the user typed it. Empty means
// the current one, which is what a bare `curious deploy` asks for.
Dir string
// Prompt is the terminal. Required.
Prompt DeployPrompter
// APIURL is an explicit endpoint override, or empty to resolve the
// one this run would use anyway. It goes through the same resolver
// every other caller uses, so a stored token's endpoint and the
// endpoint actually dialled are answers to one question.
APIURL string
// TempDir is the directory the archive's working directory is made
// in; empty means the system's own. It is a parameter for the reason
// the packer's is: the archive is a file on a real volume, and a
// suite that owns the directory can answer "was anything left
// behind" rather than guessing at a path.
TempDir string
// FS is the filesystem the walk and the packer read the project
// through. Optional; the real one is used without it.
//
// IT EXISTS SO THE TRAVERSAL CAN BE COUNTED. "One walk, three
// readers" is a rule about how many times the tree is read, and from
// outside a sequence that walked three times and one that walked once
// produce identical output — so without something that counts, the
// rule has no row that can see it. The pre-flight engine keeps its
// own seam for its own reasons and this deliberately does not borrow
// it: the two interfaces are different shapes, and widening either to
// serve the other adds a method for one caller.
FS pack.FS
// Interrupts installs the Ctrl-C handler and returns the function
// that removes it again, with the tidy-up this run needs on that
// path. Optional; without one a run simply gets the default signal
// behaviour, which ends the process and leaves the archive.
//
// IT IS A SEAM RATHER THAN A DIRECT CALL because the handler ends
// the process, so nothing in a test can observe the real one having
// been installed by installing it. What a row can see through here is
// that this sequence hands over a tidy-up at all, and that the
// function it hands over removes what the run put on the machine.
Interrupts func(cleanup func()) (stop func())
// Now is the clock. Optional.
Now func() time.Time
// Progress is where the build's phases and output are reported to a
// caller that is not a terminal. Optional; without one the run says
// nothing anywhere except through Prompt, which is what the command
// passes and what every existing row measures.
//
// IT IS A FIELD ON THE SEQUENCE RATHER THAN A METHOD ON THE PROMPTER,
// and the reason is the types. A prompter's two methods take a format
// and arguments, so a phase reaching a caller through one arrives as
// a sentence with the value inside it — and the caller that needs
// this is the one that must not parse sentences.
Progress DeployProgress
// UploadStallTimeout is how long the upload waits for the next byte
// before giving up. Optional; without one the upload's own constant
// applies.
//
// IT IS A SEAM FOR THE SAME REASON Now IS. The behaviour it governs
// is "a slow upload must survive and a stopped one must not", and
// the only way to see either at the real thirty seconds is to spend
// thirty seconds. Injecting it lets a row prove both in
// milliseconds. It is the one constant arriving by argument, not a
// second constant: nothing here chooses a different number, and
// production passes none at all.
UploadStallTimeout time.Duration
// StreamStallTimeout is how long the build log waits for the next
// byte before deciding the connection has stopped talking, and
// StreamReconnectStep is the step of the delay schedule between
// attempts to pick it up again. Both optional; without them the
// stream's own constants apply.
//
// THEY ARE SEAMS FOR THE REASON UploadStallTimeout IS, and they are
// its NEIGHBOURS RATHER THAN ITS REUSE. The upload's window bounds a
// body being pushed at an object store; this one bounds a silence on
// a connection whose far end sends a keep-alive on a published
// interval. The two are the same shape and answer different
// questions, so each is chosen where it is used.
StreamStallTimeout time.Duration
StreamReconnectStep time.Duration
// ConnectTimeout, TLSHandshakeTimeout and ResponseHeaderTimeout
// override the three bounds on opening a connection, for every client
// this deploy builds. Optional, all three together or none; without
// them the timing registry's values apply, which is what production
// passes. They are seams for the reason StreamStallTimeout is: a row
// proving a server that never answers is refused by the header bound
// cannot wait the thirty seconds that bound is.
ConnectTimeout time.Duration
TLSHandshakeTimeout time.Duration
ResponseHeaderTimeout time.Duration
// StreamTrace, when set, is told what the build-log reader did and
// when: each ask for the stream, each response, each read that moved
// bytes, and the watchdog firing. It is a ROW'S INSTRUMENT, for
// telling a starved reader from a watchdog that fired while bytes
// kept arriving, and nothing in production sets it; a guard fails if
// anything does. See StreamTraceEvent.
StreamTrace func(StreamTraceEvent)
// PublishRetryInterval is the pause between two asks when the server
// says the deploy is not ready yet. Optional; without one the
// publish's own constant applies.
//
// IT IS A SEAM FOR THE REASON THE OTHERS ARE, and it moves the PACE
// alone. What bounds the asking is a window measured on Now, so a
// row drives the race by advancing the clock and this only keeps it
// from spending real seconds doing so.
PublishRetryInterval time.Duration
// UploadTransport is the transport the archive's body travels over.
// Optional; without one the upload takes the API client's, which is
// what production gets and what uploadTransport below returns.
//
// IT IS A SEAM FOR A REASON THE OTHERS ARE NOT, and the reason is
// worth stating because nothing about the shipped behaviour needs
// it. UploadStallTimeout exists so a row can see a thirty-second
// window in milliseconds; this one exists so a row can see the same
// window over a socket whose BUFFERS ARE KNOWN. The quantity the
// upload's stall window is a margin over — the time for the kernel's
// send buffer to free space — is not a property of this program at
// all: both kernels grow a connection's buffers as it carries
// traffic, and a margin over an autotuned quantity is a margin over
// a number nobody chose. Measured rather than assumed: the same
// probe over a warm connection reported 594 ms where a fresh one
// reported 434 ms. A test cannot pin a socket it never sees, and
// before this field the only transport the upload could use was one
// built out of reach inside this function.
//
// A SEAM THAT CAN SILENTLY CHANGE WHAT SHIPS IS WORSE THAN NO SEAM,
// so what production gets when this is empty is asserted by a row of
// its own rather than left to reading — see uploadTransport.
UploadTransport *http.Transport
}
DeployDeps is everything Deploy needs from outside itself.
type DeployProgress ¶
DeployProgress is where a run says what it is doing WHILE it is doing it, for a caller that is not a terminal.
IT IS NOT A SECOND RENDERER. The terminal already learns all of this, as prose, through the prompter — and a caller that is not a terminal would have to parse that prose back into a phase and a line, which is exactly what this project refuses to do everywhere else. What arrives here are the two values the stream carried, in the types the contract declares them in.
THE PHASE IS THE CONTRACT'S OWN TYPE rather than a string, so a phase this build predates travels through unchanged and a vocabulary cannot grow a second spelling on the way past.
BOTH HALVES, EVERY TIME. A phase event supplies the phase and carries the most recent line with it; a log line supplies the line and carries the phase it arrived under. Either may be empty — a build that has said nothing yet has no last line, and a line that arrives before any phase event has no phase — and an empty half is the honest answer rather than an omission.
A REPORT IS MADE ONLY FOR SOMETHING THE READER HAS NOT ALREADY BEEN SHOWN. The event stream replays from the beginning on every reconnection, and a channel that spoke for each replayed event would narrate one build several times over.
type DeployPrompter ¶
type DeployPrompter interface {
Step(format string, args ...any)
Result(format string, args ...any)
Line(prompt string) (string, error)
Email(prompt string) (string, error)
Confirm(question string, defaultYes bool) (bool, error)
}
DeployPrompter is the slice of the terminal the whole sequence needs, which is the union of what its steps need: narration, the user's own build output, a line of text, an address, and a yes-or-no question.
It is declared HERE, by the consumer, for the same reason every other seam in this package is. That it happens to share four methods with LoginPrompter is arithmetic rather than design — this is the union of five steps' requirements and that is one step's — and reusing the name would mean a step that later stopped needing a method silently narrowing what the sequence asks for.
Result is the odd one and it is here because of the stream split: it writes to STDOUT, and the only thing that belongs there is the build's own output. Everything this program says about that output is narration and goes to stderr through Step, so a redirected stdout collects the build log and nothing else.
type Handoff ¶
type Handoff struct {
// ArchivePath is the packed project on this machine.
ArchivePath string
// SHA256 is the archive's digest, computed as it was written.
SHA256 string
// Bytes is the archive's size on disk — the number the wire request
// declares, which is the packed one rather than the source total.
Bytes int64
// Entries is how many files went into the archive.
Entries int
// Client is the API client carrying this run's bearer token.
Client *api.Client
// Outcome is what the run produced. See the type above for why it
// lives here rather than beside this one.
Outcome Outcome
// contains filtered or unexported fields
}
Handoff is what a completed run leaves in the caller's hands: the archive to release, what was measured on the way, and what the run produced.
IT IS AN INTERNAL TYPE RATHER THAN A WIRE ONE, because it is a different thing rather than a nicer spelling of one. The wire request carries a single field — the packed size — and this carries six, four of which the server never sees: where the archive is on this machine, what it hashes to, how many entries went into it, and the client that sent it. Reshaping a wire type into a roomier internal one is how two copies of a contract drift; this is not that, because there is no second copy of anything the contract defines.
THE ARCHIVE OUTLIVES Deploy AND THE CALLER OWNS IT from the moment this is returned. Release is how it gives it back, and a caller that defers Release the moment it has a Handoff has covered every ordinary exit path in one line.
func Deploy ¶
func Deploy(ctx context.Context, deps DeployDeps) (*Handoff, error)
Deploy runs the whole of `curious deploy`: everything local, then the create, the upload, the build, and the address the build answers at.
The order, and why it is the order ¶
- Resolve the directory.
- Load the stored token. No network call.
- Walk the project, ONCE.
- Pre-flight and 5. the local limits, over that one walk.
- Only with no usable token: the capacity check, then the login.
- Pack.
- Create the deploy.
- Upload the archive.
- Start the build.
- Render the build log.
- Publish, and print the address.
LOCAL TRUTHS BEFORE GLOBAL STATE, and the consequence is the sentence worth keeping: A PROJECT THAT CANNOT DEPLOY MAKES ZERO NETWORK CALLS. Steps 3 to 5 are free, local and instant, so nothing on the network runs until they have passed. Written the other way round — login first, as the obvious reading of "authenticate, then work" suggests — it is wrong twice over: a first-timer standing in the wrong directory is walked through email verification, spending one of the sends the server allows in an hour and a real email, before being told there is no package.json; and an unattended run with a fresh config has no token, so it dies at the email prompt with a message about needing a terminal instead of the one naming what is wrong with the project.
THE CAPACITY CHECK STAYS IMMEDIATELY BEFORE THE LOGIN IT GATES. That adjacency is the one that matters, because the daily cap counts ACCOUNTS and an account is spent at the verify step — so a returning user with a usable token reaches neither call, and the pair moved down the sequence together rather than separately.
ONE WALK, THREE READERS. The file list feeds the scan for hard-coded development URLs, the limits, and the packer. Walking again for any of them would let one of them disagree with the list the user was shown and consented to, which is the one difference nobody would think to look for.
Nothing is written except the config file on a successful login, and the archive — which the caller removes with Release.
type LoginDeps ¶
type LoginDeps struct {
// Prompt is the terminal. Required.
Prompt LoginPrompter
// Auth is the API client. Required.
Auth Authenticator
// Endpoint is the base URL THIS FLOW CALLED — the value the client
// was constructed with, never one read back from a config file and
// never a second resolution of the same question.
//
// It is an argument rather than something this flow looks up because
// the store cannot check it: a writer can prove a URL is
// canonicalisable, and it cannot prove a token came from that URL,
// because it has no issuer identity to compare against. The only
// code that knows which endpoint issued this token is this flow, at
// the moment it makes the call.
Endpoint string
// Save stores the pair. Optional: the default loads the existing
// configuration first and saves onto the value it returned.
Save TokenWriter
// Offer is the waitlist hand-off. Optional; without one a closed
// capacity stops the run with this flow's own copy.
Offer WaitlistOffer
// Now is the clock a retry time is rendered against. Optional.
Now func() time.Time
}
LoginDeps is everything Login needs from outside itself.
type LoginPrompter ¶
type LoginPrompter interface {
Step(format string, args ...any)
Line(prompt string) (string, error)
Email(prompt string) (string, error)
Confirm(question string, defaultYes bool) (bool, error)
}
LoginPrompter is the slice of the terminal this flow needs: narration, a line of text, an address, and a yes-or-no question.
It is declared HERE, by the consumer, rather than exported by the package that implements it. The real terminal type satisfies it without knowing this package exists, and a test can state what this flow does — one address prompt across a run with three wrong codes — without arranging a terminal, which is not a thing a test can portably do on all three platforms this ships to.
A PROMPT ERROR IS RETURNED UNCHANGED, and that leaves one thing this seam's to keep: ui.ServerClosed is the flow's to mint, never an implementation's. The third exit code means a door that is shut, and the routing below only marks a stop with it holding a capacity or maintenance response. A prompter that wrapped its own error in it would exit 3 with nothing behind it, and nothing here would notice.
type LoginRefusal ¶
type LoginRefusal struct {
// Recoverable reports whether the same step, given different input,
// can still end in a stored token.
Recoverable bool
// Said is what the FAR END said, verbatim, and it is the only thing
// that knows which of the several reasons a code can be refused
// actually happened — the server answers one byte-identical refusal
// for a wrong code, an expired one, a lost race, a cooldown and an
// unknown identity. Set only on a recoverable refusal, and empty
// when the failure was a transport one with no envelope behind it.
Said string
// Failure is this program's own copy for a refusal that ends the
// run, ready to render. Set only when Recoverable is false.
Failure error
// Code is the error code the server sent, or empty when there was no
// envelope at all. It is carried because a caller counting
// CONSECUTIVE wrong codes has to be able to tell a wrong code from a
// dropped connection, and the message cannot answer that.
Code wire.ErrorCode
}
LoginRefusal is what one login call's failure means to a caller that cannot ask a question and try again in place.
Why the two exported calls hand back this rather than an error ¶
A login has exactly one recoverable failure and a handful of terminal ones, and the difference decides what a caller DOES: the wrong or expired code is answered by asking for another code, and everything else is answered by stopping and showing what happened. Returned as a bare error those two are one value, and the only way back to the distinction is matching on a message — which is what this program refuses to do with prose everywhere else.
ONLY ONE OF Said AND Failure IS EVER MEANINGFUL, and which one is decided by Recoverable. That is the shape classify already had internally; this is it exported, because the decision it encodes is the thing both surfaces have to share.
func LoginStart ¶
func LoginStart(ctx context.Context, deps LoginDeps, email string) *LoginRefusal
LoginStart asks the server to send a login code to email, and reports what happened if it would not.
IT IS THE FIRST STEP OF Login BELOW, LIFTED OUT WHOLE, and that is the point of it existing rather than a convenience. An agent logging in has the same two steps to take and no terminal to take them at, so it cannot run the machine — and the alternative to this is a second implementation of which refusals stop a login and what each one says, in a package that would have no way to notice the day the first one changed.
A success PROMISES NOTHING ABOUT AN EMAIL. The endpoint answers identically whether it sent a code, declined, was over a send budget or was in a cooldown, so what a nil return means is that the request was accepted — which is all either surface may tell anybody.
func LoginVerify ¶
func LoginVerify(ctx context.Context, deps LoginDeps, req VerifyRequest) *LoginRefusal
LoginVerify submits a code and STORES THE TOKEN, returning nil only when a token has been written.
THE STORE IS PART OF THIS STEP rather than the caller's to remember, which is the same reason Login itself returns nil only for a stored token: a verify that succeeded and was never recorded leaves a credential in memory, a user who believes they are logged in, and a next run that asks for their address again. One caller forgetting is all it takes, and there are two callers now.
The token never leaves this function. It is not returned, not logged, and not rendered — every surface's answer to "did this work" is the absence of a refusal.
type NoLogin ¶
type NoLogin struct {
// Reason is the configuration's own explanation, written for a
// person and passed along as it was written. It is EMPTY on a first
// run, where there is nothing to explain and the sentinel already
// says everything true — so a caller renders it only when it is
// there rather than filling the space.
Reason string
}
NoLogin is that sentinel with the configuration's own explanation attached.
IT IS A TYPE AND NOT A FORMATTED MESSAGE, and the difference is the only reason it exists. The reason is a thing a caller RENDERS — a surface writes its own headline and puts the explanation under it — and carried inside a sentence the only way back to it is trimming a prefix off text this package owns. That works, until the day somebody rewords the sentinel or changes the separator, and then it silently stops working: the caller renders its headline, drops the half that says what actually happened, and nothing anywhere goes red.
errors.Is still finds the sentinel, so a caller that only needs to know THAT there is no login never has to know this type exists.
type Outcome ¶
type Outcome struct {
// DeployID is the record the server created for this archive, and
// the handle every later call in the sequence names. NOTHING
// PERSISTS IT: it belongs to this run, and a create whose upload
// never starts leaves a record the server discards on its own.
DeployID string
// Subdomain is the LABEL the publish returned — the left-hand part
// alone, with no domain and no scheme. The server holds the label
// and has no representation of the base domain, so the address is
// composed from this by PublishedURL and never assembled twice.
Subdomain string
// ExpiresAt is when the server says this deploy stops answering.
// Zero when the server sent nothing.
ExpiresAt time.Time
// Preflight is the validated report's findings, in report order.
Preflight []check.Finding
}
Outcome is what a completed run PRODUCED, as a value rather than as something printed.
IT IS A GO SURFACE AND NOT A CHANGE TO THE COMMAND. The CLI still writes the address to stdout and everything it says about the address to stderr, and that is asserted byte for byte by the transcript row next door rather than left to this sentence. What this adds is a caller that is not a terminal: an agent-facing surface renders these same facts as fields, and the alternative — parsing them back out of the prose a person reads — is exactly what this project refuses to do with prose everywhere else.
IT IS A FIELD ON Handoff RATHER THAN A SECOND RETURN VALUE, and the reason is what the two spellings would each claim. Deploy returns a Handoff on success and nil on every failure, so an Outcome returned beside it would be nil in exactly the same cases and never in any other: two results whose presence is ONE fact, in a signature that says you can have either without the other. And a caller wanting the outcome holds the Handoff regardless, because it has an archive to release. So there is no call this shape makes awkward, and the command's own call site does not move at all.
DeployID LIVES HERE AND NOWHERE ELSE. It was a field of Handoff, with no reader; it is one home rather than two, which is the whole of why it moved instead of being copied.
EVERY FINDING, NOT THE ADVISORIES. What a warning costs — whether it stops a run, whether it is worth asking about, whether it is worth showing at all — is a decision the surface rendering it makes, and this package is where those decisions live rather than a second set of checks. Filtering here would make this type the third opinion on a question two renderers already answer differently on purpose: the terminal asks about a warning and an agent cannot be asked. So the report's own list is handed over whole, in report order, as the fresh slice Findings already returns per call.
NOTHING IS COMPUTED. ExpiresAt is the instant the server sent, carried as it arrived; the zero value means the server sent none, which is a thing it is entitled to do and not a reason to invent one.
type Prompter ¶
type Prompter interface {
Step(format string, args ...any)
Confirm(question string, defaultYes bool) (bool, error)
}
Prompter is the slice of the terminal this renderer needs: a line of narration, and one question.
It is declared HERE, by the consumer, rather than exported by the package that implements it. The real terminal type satisfies it without knowing this package exists, and a test can state what it means — three warnings produce exactly one prompt — without arranging a terminal, which is not a thing a test can portably do on all three platforms this ships to.
type StoredLogin ¶
type StoredLogin struct {
// Client carries the stored token and is ready for an authenticated
// call. The token is not exposed beside it, and that is not
// decoration: a field holding one is a field something eventually
// renders.
Client *api.Client
// Warnings are things worth telling the caller that do not stop
// anything — a token file other accounts can read is the standing
// example. They are the configuration's own words, passed along
// rather than reworded, and a surface that shows them shows them as
// they are.
Warnings []string
}
StoredLogin is what this machine holds for one endpoint.
func OpenStoredLogin ¶
func OpenStoredLogin(endpoint string) (StoredLogin, error)
OpenStoredLogin loads the login this machine holds for endpoint and returns a client ready to spend it.
It is the same two steps the deploy sequence takes, and that is the point ¶
Which config file is read, which endpoint a stored token has to have been issued against, and what an unusable endpoint costs are three decisions with one right answer each, and the deploy sequence already makes all three. A second surface making them again would be free to read a different file or accept a token issued somewhere else, and the symptom would be a deploy that works from the terminal and not from an agent — or worse, the reverse.
WHAT IT DOES NOT DO IS LOG ANYBODY IN. A run with no usable token comes back as ErrNoLogin carrying the configuration's own explanation, because the way out of that differs by surface and nothing here knows which one is asking.
type StreamReport ¶
type StreamReport struct {
// Reported is whether the stream reached its terminal event. Only
// then does Status mean anything.
Reported bool
// Status is the DeployStatus the terminal event carried. Zero when
// the stream did not reach one.
Status wire.DeployStatus
// Phase is the last phase the stream named, which is as much as it
// said about where a build that has not finished had got to. Zero
// when the stream named none.
Phase wire.Phase
// Log is the most recent lines of build output, oldest first and
// bounded by recentStreamLines.
Log []string
// Errors are the diagnostics the stream carried, in the order it
// carried them.
//
// THEY ARE NOT AN ENDING. The contract's own grammar says the error
// event explains and never terminates — a failed build still ends
// with its terminal event — so these sit beside the status rather
// than in place of it, and a report can carry both, either, or
// neither.
Errors []wire.Error
}
StreamReport is everything ONE READING of a deploy's event stream said, as values.
It reports what the stream SAID, and never more than that ¶
There is no endpoint that answers a deploy's current state: the /v1 surface has a create, a start, a publish and this stream, and nothing that reads a record back. So the only thing in reach is the log, and a live tail is not a state read.
Reported is the whole of that distinction. A stream that has not reached its terminal event has not stated a status, and a report that filled Status in anyway — with the phase it last saw, with "building", with anything — would be this client inventing the one fact its caller asked for. The honest answer is that nothing has been reported yet, and it is a state of its own rather than a placeholder.
WHAT THIS CANNOT TELL YOU, stated because the alternative is a reader assuming otherwise: a stream that stopped short because the build is still running and one that stopped short because the connection went away are the same report. That is not a gap in this type — the two are the same event from here, the caller's next action is the same for both (ask again), and a field guessing between them would be the invention this whole shape exists to refuse.
func ReadDeployStream ¶
func ReadDeployStream(ctx context.Context, deps StreamReportDeps) (StreamReport, error)
ReadDeployStream reads a deploy's event stream to its end and reports what it said.
It is a second READER, not a second parser ¶
The framing, the field names, the blank-line dispatch and the comment frames are the protocol's, and they are read by scanEvents, which the build log's own renderer uses too. What differs here is only what happens to a dispatched event: that one renders as it goes and this one collects. Two parsers for one stream is how two halves of a client come to disagree about what the server sent.
It bounds a SILENCE, because nothing else does ¶
The connection this opens carries no deadline of its own — a total one cannot express "is this making progress", and a build may legitimately take as long as it takes. What it does have is a far end that sends a keep-alive on a published interval, so a silence longer than several of those is a connection that has stopped talking.
THAT BOUND IS LOAD-BEARING HERE IN A WAY IT IS NOT FOR THE RENDERER, and the difference is the host. The build log is read by a command that ends when it ends; this is read by a server that dispatches one call at a time on one goroutine, so a read that never returns takes every later call with it — the listing, the ping, every other tool — and the client cannot cancel, because a cancellation is a message that server is no longer reading. A relay that dies without closing its socket produces exactly that, and it is the condition the window exists for.
The window is the build log's own. That is not a constant carried here because it looked similar: it is the SAME quantity at the same site — a silence on this stream, against that server's keep-alive — so the argument that chose the number is the argument that applies.
A STALL ENDS THE READING RATHER THAN FAILING IT, which is the same answer every other early ending gets here: what the stream did say is reported, and Reported stays false.
It does not reconnect, and it does not de-duplicate ¶
Both belong to the renderer and neither belongs here. Reconnecting exists so a person watching a build does not lose the window onto it; this call is one question with one answer, and a caller that wants to ask again can ask again. De-duplication exists because a replay would print lines somebody has already read — and this reads ONE connection, so there is nothing to have read twice.
A stream that ends early is a RESULT rather than an error ¶
The returned error is for a stream that could not be opened at all: an unknown deploy, a refused token, a server that would not answer. Once bytes are arriving, every way the reading can stop short — the build is still running and the caller gave up waiting, the relay went away, the history had been evicted — produces a report with Reported false, which is exactly what that field is for. Reporting those as errors would throw away the phase and the output the stream did deliver, which is the whole of what a caller can act on.
type StreamReportDeps ¶
type StreamReportDeps struct {
// StallTimeout is how long the reading waits for the next byte
// before deciding the connection has stopped talking. Zero means the
// build log's own window, which is what production passes; it is
// injected for the reason every other window in this package is, so
// a row can prove both halves of the rule in milliseconds instead of
// in minutes.
StallTimeout time.Duration
// Events opens the connection. It is a function rather than a client
// for the reason the build log's own is: what a caller wants opened
// is one deploy's stream, and the id belongs to whoever is asking
// rather than to this step.
Events func(ctx context.Context) (io.ReadCloser, error)
}
StreamReportDeps is everything ReadDeployStream needs from outside itself.
type StreamTraceEvent ¶ added in v0.1.6
type StreamTraceEvent struct {
Kind StreamTraceKind
At time.Time
Bytes int
}
StreamTraceEvent is one thing the build-log reader did, with the moment it did it: asked for the stream, received the response, read bytes off it, or gave up waiting for the next byte.
IT EXISTS FOR A ROW, NOT FOR A USER. A stall row that reddens can see its own server's timeline, but not this reader's, and only this reader's can tell apart a reader that was never scheduled while bytes waited from a watchdog that fired although bytes kept arriving. The first is the machine; the second is a defect. So the reader can report its own timeline to a row that asks, and to nobody else: production wiring never sets the hook, and a guard fails if it ever does.
The hook is called from two goroutines, the reader and the watchdog's timer, so whatever receives it must be safe for concurrent use.
type StreamTraceKind ¶ added in v0.1.6
type StreamTraceKind string
StreamTraceKind names what a StreamTraceEvent records.
const ( // StreamTraceOpen is the moment before the stream is asked for, // which is also the moment the watchdog is armed. StreamTraceOpen StreamTraceKind = "open" // StreamTraceOpened is the response arriving, headers and all. StreamTraceOpened StreamTraceKind = "opened" // StreamTraceRead is a read that moved bytes, which pushes the // watchdog back. StreamTraceRead StreamTraceKind = "read" // StreamTraceStalled is the watchdog firing. StreamTraceStalled StreamTraceKind = "stalled" )
type TokenWriter ¶
TokenWriter stores the token together with the endpoint it was issued against. The pair is validated together by whoever implements this, so a token with no endpoint or an endpoint with no token cannot be written at all.
ITS ERROR TEXT REACHES THE USER VERBATIM — writeFailure prints it. So an error from this seam must never carry the token. The shipped writer does not, and that is why this is a sentence rather than a defect: the type accepts any error and the renderer trusts it.
type VerifyRequest ¶
type VerifyRequest struct {
Email string
Code string
// MarketingOptIn records whether the person at the keyboard asked
// for product news. It defaults to false, it changes nothing about a
// deploy, and it must never be set without having asked them.
MarketingOptIn bool
}
VerifyRequest is what one code submission carries. It is a struct rather than three arguments because the third is a CONSENT, and a bare bool at a call site is the shape somebody sets to true without noticing what they have agreed to on a person's behalf.
type WaitlistJoiner ¶
type WaitlistJoiner interface {
Waitlist(ctx context.Context, req wire.WaitlistRequest) (*wire.WaitlistResponse, error)
}
WaitlistJoiner is the slice of the API client the waitlist offer needs. It is separate from CapacityAPI below because the offer is reachable from a path that never checks capacity at all — the account cap can close at the login step, after this gate said yes — and a seam asking for a call its caller cannot make is a seam nobody can wire.
type WaitlistOffer ¶
WaitlistOffer is the hand-off for a run that met a closed account cap.
Account capacity is spent at the verify step, so it can close between whatever checked it and this call — a genuine race rather than a mistake. The reset time is passed IN because the response that carried it is in hand here: an offer that had to ask a second time would be making a call to learn something it was already told.
It takes the run's context because an offer joins a waitlist, and joining one is a call. Giving it the context now costs a parameter; giving it later means changing a signature inside the change that wires the real offer up, which is the change least able to afford an unrelated edit.
It returns the error that ends the run. What the offer SAYS is not this flow's business; that it stops is.
WHAT AN IMPLEMENTATION OWES THE RUN, said here because nothing enforces it: it must RETURN. A blocking offer blocks the login; the context handed in is the only cancellation there is, and it works only if the callback honours it; a panic is not contained here and ends the process. Deliberately no machinery — a recover() would swallow a programming error in code this package does not own, and a deadline would be this flow inventing one for a call whose cost it cannot know.
func NewWaitlistOffer ¶
func NewWaitlistOffer(p CapacityPrompter, join WaitlistJoiner, now func() time.Time) WaitlistOffer
NewWaitlistOffer builds the hand-off the login flow calls when the account cap closes at the moment an account would be spent.
It satisfies WaitlistOffer, whose doc states what an implementation owes the run: it RETURNS, it honours the context it is handed, and it does not panic. Everything below is a prompt, one HTTP call made with that context, and string building — there is no loop without a bound and nothing that blocks on anything else.
THE OFFER TAKES THE ADDRESS RATHER THAN ASKING FOR ONE. The login path already has one — the person typed it to log in — and asking again would be this program forgetting something it was told a moment ago. The gate above has none, so it supplies a prompt instead; that is the only difference between the two callers, and it is one argument.