Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
108 commits
Select commit Hold shift + click to select a range
19709dc
feat(deploy): two-phase stage/activate for deploy_component
dawsontoth Jul 17, 2026
909f384
fix(deploy): staging parent for npm pack cwd, symlink-safe aside, sib…
dawsontoth Jul 17, 2026
cb2020e
test(deploy): op-level coverage for stage_component, activate_compone…
dawsontoth Jul 17, 2026
52718f6
test(deploy): update integration phase assertions to two-phase names
dawsontoth Jul 17, 2026
64ad02b
test(deploy): rewrite op-level tests as real-module tests (no sinon/r…
dawsontoth Jul 17, 2026
01477ba
Merge main into claude/deploy-component-two-phase-94969a
dawsontoth Jul 17, 2026
018fe90
Merge main into claude/deploy-component-two-phase-94969a
dawsontoth Jul 20, 2026
26de01c
fix(types): cast commitResolution to Promise<void> at recordCommitLat…
dawsontoth Jul 20, 2026
6e4269c
feat(deploy): revert_component + retained previous version, CLI commands
dawsontoth Jul 20, 2026
722158b
fix(deploy): revert_on_failure must skip peers that never activated
dawsontoth Jul 20, 2026
2ac508f
fix(deploy): exclude this node from revert_on_failure point-to-point …
dawsontoth Jul 20, 2026
baca6b7
test(deploy): extract + cover revert_on_failure node targeting
dawsontoth Jul 20, 2026
488d172
fix(deploy): validate deployment_id charset; validate staged build du…
dawsontoth Jul 20, 2026
43b21aa
Merge main into claude/deploy-component-two-phase-94969a
dawsontoth Jul 20, 2026
d80e695
refactor(deploy): fold stage/activate into deploy_component (internal…
dawsontoth Jul 21, 2026
4197f1c
feat(deploy): bound staged-build retention per component (evict-on-st…
dawsontoth Jul 21, 2026
9f645bc
fix(cli): `harper activate` must carry a deployment_id (fail fast, no…
dawsontoth Jul 21, 2026
3c19c3b
Merge branch 'main' of https://github.com/HarperFast/harper into clau…
dawsontoth Jul 21, 2026
b0e33e3
test(deploy): reset process-wide restart buffer after two-phase op tests
dawsontoth Jul 21, 2026
c907eb2
fix(deploy): two-phase deploy marks restart-required for new components
dawsontoth Jul 21, 2026
1ec722b
test(deploy): assert restart-required directly on every two-phase leg
dawsontoth Jul 21, 2026
911d617
style(test): prettier line-wrap for deployPhaseOperations assertions
dawsontoth Jul 21, 2026
5db986a
Merge branch 'main' into claude/deploy-component-two-phase-94969a
dawsontoth Jul 22, 2026
7504b55
Merge branch 'main' into claude/deploy-component-two-phase-94969a
dawsontoth Jul 22, 2026
758047c
fix(deploy): markDeploymentTerminal must patch, not mutate the fetche…
dawsontoth Jul 22, 2026
299557b
Merge branch 'main' into claude/deploy-component-two-phase-94969a
dawsontoth Jul 23, 2026
c7b0e3f
Merge branch 'main' into claude/deploy-component-two-phase-94969a
dawsontoth Jul 28, 2026
3ebced7
fix(deploy): gate peer activation failures on the activate-existing path
dawsontoth Jul 28, 2026
842061a
Merge branch 'main' + add count-based payload retention (default 1)
dawsontoth Jul 29, 2026
525dfd7
fix(deploy): persist root config on activate-by-id; restart the rever…
dawsontoth Jul 30, 2026
eb861b9
Merge branch 'main' into claude/deploy-component-two-phase-94969a
dawsontoth Jul 30, 2026
764ce67
test(deploy): cover package-identifier recovery on activate-by-id end…
dawsontoth Jul 30, 2026
2b189cc
Restore component directories after failed preparation
kriszyp Aug 3, 2026
befbd16
Handle concurrent component recovery during startup
kriszyp Aug 4, 2026
9f4918d
Bound preparation waits without racing recovery
kriszyp Aug 4, 2026
ee64f6a
Wait for live component preparation during recovery
kriszyp Aug 4, 2026
a215341
Defer busy component recovery off startup path
kriszyp Aug 4, 2026
bd9c1fd
Harden deferred recovery state handling
kriszyp Aug 4, 2026
0e74190
Finalize deferred component readiness
kriszyp Aug 4, 2026
c6ea280
Deduplicate deferred component readiness
kriszyp Aug 4, 2026
302ba4c
Gracefully settle sibling startup recovery
kriszyp Aug 4, 2026
1f91dfe
Distinguish recovery lock ownership
kriszyp Aug 4, 2026
b11822e
test(components): preserve first-deploy absence
kriszyp Aug 4, 2026
4f46a43
fix(components): harden interrupted recovery discovery
kriszyp Aug 4, 2026
d6acffc
fix(components): guard deferred ready hooks
kriszyp Aug 4, 2026
1a6fa31
Preserve retained component replacements
kriszyp Aug 7, 2026
96d90f3
Avoid retiring an absent replacement
kriszyp Aug 7, 2026
8afdfb7
Ready deferred component modules once
kriszyp Aug 7, 2026
cec013d
Await shared component readiness
kriszyp Aug 7, 2026
58fe680
Make two-phase component deploys crash-safe and fail closed
heskew Aug 10, 2026
e459bac
Merge PR #2135 (heskew): crash-safe, fail-closed two-phase deploys
dawsontoth Aug 12, 2026
49178e9
Merge #2066 (kris/deploy-peer-rollback) into the two-phase deploy branch
dawsontoth Aug 12, 2026
70e24b4
feat(deploy): restore revert_component as an addressed, idempotent ro…
dawsontoth Aug 12, 2026
07fc921
fix(deploy): compare package metadata across the two-phase swap for t…
dawsontoth Aug 12, 2026
ed3294f
fix(deploy): use lstat, not access, to detect an occupied extraction …
dawsontoth Aug 12, 2026
fdc6682
style: prettier formatting for the new revert regression tests
dawsontoth Aug 12, 2026
4d2a854
docs(deploy): make DESIGN.md match the code again
dawsontoth Aug 12, 2026
1497fed
test(deploy): cover revert_component at the operation level
dawsontoth Aug 12, 2026
4d1f739
test(deploy): assert the restart gate on the swap primitive, not thro…
dawsontoth Aug 12, 2026
9430ad0
test(cli): cover the harper revert verb and its required target
dawsontoth Aug 12, 2026
9eb3a41
fix(deploy): compensate and recover an interrupted revert swap
dawsontoth Aug 12, 2026
cc4e720
fix(deploy): park a displaced directory-package tree aside instead of…
dawsontoth Aug 12, 2026
f60feec
fix(deploy): fail closed on startup reconciliation, and finish an int…
dawsontoth Aug 14, 2026
ed1f8eb
test(deploy): stop racing the async aside sweep when reading parked e…
dawsontoth Aug 14, 2026
f6d2291
fix(deploy): re-point dependency links the activation swap invalidates
dawsontoth Aug 14, 2026
1938914
fix(deploy): repair dependency links inside the activation transactio…
dawsontoth Aug 14, 2026
dea4788
Make rollback placeholders movable
kriszyp Aug 17, 2026
66cd942
Keep placeholder repair identity-gated
kriszyp Aug 17, 2026
dcbc094
Complete interrupted component recovery
kriszyp Aug 19, 2026
44d91ed
Resolve component recovery review feedback
kriszyp Aug 19, 2026
807352e
Close live-writer rollback race
kriszyp Aug 19, 2026
8c70677
Preserve placeholder on mode failure
kriszyp Aug 19, 2026
b3f510c
Recover pre-chmod rollback placeholders
kriszyp Aug 19, 2026
d411900
Retire losing recovery candidates and unblock rollback retries
kriszyp Aug 19, 2026
a99785c
Pin mode-000 directory placeholder adoption
kriszyp Aug 19, 2026
2488435
Scope deferred readiness to each component load
kriszyp Aug 20, 2026
1074309
Bound startup component recovery waits
kriszyp Aug 20, 2026
40fc6fa
Pin deferred recovery lock renewal
kriszyp Aug 20, 2026
fb77b51
Preserve dangling component symlinks on rollback
kriszyp Aug 20, 2026
88cd258
Contain dangling component load failures
kriszyp Aug 20, 2026
2defe84
Merge updated #2066 base into the two-phase deploy branch
dawsontoth Aug 21, 2026
8664805
fix(deploy): fence revert persistence, contain reconciliation, and co…
dawsontoth Aug 21, 2026
c866591
fix(deploy): make the pre-swap link repair reversible
dawsontoth Aug 21, 2026
f1cf79a
test(deploy): cover the reconciliation attribution guard
dawsontoth Aug 21, 2026
b7d6e0f
fix(deploy): pair the revert config commit with a rollback across eve…
dawsontoth Aug 21, 2026
f911f54
fix(deploy): serialize persistent-state writes and fail startup close…
dawsontoth Aug 21, 2026
7742810
fix(deploy): hold the component lock across startup activation-artifa…
dawsontoth Aug 21, 2026
d1e9995
fix(deploy): settle crash-stranded in-flight deployments so their pay…
dawsontoth Aug 21, 2026
21f319a
fix(deploy): stop treating uncertainty as absence, and recover the no…
dawsontoth Aug 21, 2026
63a713e
fix(deploy): keep recovery and retention from destroying releases the…
dawsontoth Aug 21, 2026
20c6390
chore(deploy): drop review-provenance comments and decorative dividers
dawsontoth Aug 21, 2026
fb03ee5
fix(deploy): compensate a partial persistent commit, and stop rejecti…
dawsontoth Aug 21, 2026
093b7e4
fix(deploy): remove a dropped component's persisted state before dest…
dawsontoth Aug 21, 2026
b3951ce
fix(deploy): apply the live-symlink rule to revert recovery, and stop…
dawsontoth Aug 21, 2026
5ecc7d9
fix(deploy): keep a recovered displaced release addressable, and repo…
dawsontoth Aug 21, 2026
b13467e
fix(deploy): prove a parked backup belongs to the live release before…
dawsontoth Aug 21, 2026
b533ba5
fix(deploy): record the revert's commit as a fact, and stop losing re…
dawsontoth Aug 21, 2026
1a4cfc5
Merge main into the two-phase deploy branch (#2066 now squash-merged)
dawsontoth Aug 24, 2026
a56c34f
feat(deploy): the origin owns the deployment row, and separated phase…
dawsontoth Aug 24, 2026
4bac451
fix(deploy): restore peer crash evidence, and stop the untracked fall…
dawsontoth Aug 24, 2026
e818f37
fix(deploy): make the one-shot escape hatch actually safe, and classi…
dawsontoth Aug 24, 2026
c19e026
fix(deploy): send peers the buffered payload, not the stream ingest a…
dawsontoth Aug 24, 2026
12ab618
refactor(deploy)!: drop the peer coordination protocol; keep the per-…
dawsontoth Aug 24, 2026
ee2234a
fix(deploy): clean up what the protocol split orphaned
dawsontoth Aug 24, 2026
29c86f6
fix(deploy): trust local activation evidence over the row, and stop v…
dawsontoth Aug 24, 2026
c6834cb
fix(deploy): make revert's precondition failures client errors, and s…
dawsontoth Aug 25, 2026
f3d1a32
fix(deploy): stop a dropped component outliving its mount, and keep r…
dawsontoth Aug 25, 2026
080d85a
refactor(deploy): send #2301's code with #2301, restore the coverage …
dawsontoth Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,35 @@ A package-manager timeout must not release this lock while npm descendants are s

Boot's `harper-application-lock.json` records an application configuration only after preparation fulfills. Recording at queue time would make a failed install look complete and suppress its retry on the next boot.

## Component deploys build off to the side and swap atomically

`deploy_component` prepares the candidate under `.deploy-staging/<deployment UUID>/<project>`, runs
extract + `npm install` there, validates that it loads _where that check runs at all_ (it is a no-op on
the main thread, which is where the operations API deploys — see "Staged deploy" below), and only then
renames it into the live path —
committing root config and `harper-application-lock.json` in the same compensating transaction. The
live component keeps serving through the slow, failure-prone work (git clone, registry install), and
go-live is one atomic rename. A fetch or install failure leaves the running component untouched
instead of half-replaced in place.

This is **per node**. The operation replicates as a whole, exactly as the single-phase deploy always
did, and each node performs its own staged build. There is deliberately no cluster-wide barrier here:
nothing orders activation across nodes, so two deploys originated concurrently on different nodes can
still end with different versions live. That ordering guarantee — a leader or a monotonic activation
epoch — is tracked in #2294, and byte-identical package resolution across nodes in #2295. Both are
protocol additions that need real multi-node verification, so they are deliberately not attempted
here.

The deployment row carries the activation specification (package/install settings, routing, credential
references, `force`). It is not a coordination channel between nodes; it exists so the deploy is
observable, so the payload has a durable home, and — the load-bearing part — so startup
reconciliation can reconcile root config for an activation that was interrupted mid-swap. A row
without `activation_spec` cannot be recovered, which is why it is written before the build starts.

Startup reconciliation is the recovery record: it settles interrupted extractions, finishes or undoes
interrupted reverts, rolls an interrupted activation forward, and fails a component closed rather than
loading a live tree whose durable configuration disagrees with it.

## Peer-side deploy_component payload read: retryable blob stalls and `Readable.from()` cancellation

`readPayloadBlobWithRetry` (`components/deploymentRecorder.ts`) wraps the peer's read of a replicated `hdb_deployment` row's `payload_blob` so a transient 503 `BlobReadError` (`BLOB_UNAVAILABLE_STATUS`, `resources/blob.ts`) — content bytes not arriving within `blobReadTimeout`, e.g. a parked blob send on the origin — retries instead of failing the whole deploy. Two non-obvious constraints shaped the design:
Expand Down Expand Up @@ -475,6 +504,121 @@ this fix doesn't attempt to solve. `deploy_component`/`package_component` still
declared entry points (`jsResource`/`graphqlSchema`) survived extraction — a truncation from some
other future cause would still report success silently; that's a deferred, separate fix.

## Staged deploy: build aside, then swap (`components/Application.ts`, `components/operations.js`)

`deploy_component` builds the incoming version — download/`npm pack` (incl. a git clone), extract,
`npm install` — into a hidden staging directory, then atomically renames it into the live component
path.

**The pre-swap load check does not run everywhere, and that is not new.** `loadValidateComponent`
returns immediately on the main thread, and the operations API executes `deploy_component` there — so
on a node whose deploy runs on the main thread, a candidate that installs cleanly but throws at load
still reaches the live path. It runs where the deploy executes on a worker (e.g. the op-API worker).
The staged build bounds the _install_, not the load. The live component keeps serving throughout, and a fetch or install
failure leaves it untouched rather than half-replaced in place. The request/response contract is
unchanged; only the SSE phase names differ (`stage`/`activate` vs the old `prepare`/`replicate`).

Each node does this for itself: the operation replicates as a whole, as it always did. There is no
cluster-wide barrier and no private peer operation — ordering activation across nodes (#2294) and
guaranteeing every node staged the same bytes (#2295) are protocol additions that need real
multi-node verification, and are deliberately out of scope here.

**Why staging lives under the components root.** Go-live is `rename(stagingDir, liveDir)`, atomic only
when both share a filesystem. `os.tmpdir()` is frequently a different mount → `EXDEV` → a slow
recursive copy at exactly the moment an instant swap is wanted. So staging is a hidden directory under
the components root: same volume, dot-prefixed so the loader ignores it, and not the watched base of
any component's watcher, so building there fires no restart-on-change events.

**Reversibility: retained previous + `revert_component`.** `activateStagedApplication` does not discard
the tree its swap displaced — it retains it as `.deploy-previous/<name>`, evicting the older one so
exactly one previous is kept per component (a bounded one extra copy). `revert_component` swaps the live
directory with that retained previous via three same-filesystem renames through a hidden holding path,
cluster-wide, and fetches nothing: no package resolution, no secret decryption, no artifact download, no
install. That is the point — the rollback operators actually need is the bad rollout that just happened,
and every node already has those bytes.

**It is addressed, not toggled.** `to_deployment_id` is required and names the version the caller expects
live afterwards. If that version is already live the call is a no-op success; if it matches the retained
previous, the swap happens and the displaced tree becomes the new retained previous. Anything else is
refused, naming what the component can actually revert to. A bare "swap to the other one" toggle is
unsafe under ordinary request retries — a caller that loses the response and retries would flip the
rejected release back in — which is why the target is mandatory. Reaching an older version is a redeploy,
not a revert.

**The swap carries persistent state with it.** Each retained tree has a sidecar manifest recording the
deployment that produced it and the root-config entry it was activated with, and revert applies that
entry to both the root config and `harper-application-lock.json`
(`createApplicationConfigTransaction`, shared with activation). A null entry means REMOVE it: reverting
away from a `package` deploy has to drop the package reference, or `installApplications()` would
reinstall the reverted-away version over the restored directory on the next cold start and silently undo
the rollback. A config-write failure compensates by swapping the directory back. The previous copy and
its manifest are per-node (each node retains its own outgoing tree during its own activate), so a
replicated revert has a local rollback source on every node.

**One contract on `.deploy-aside`.** The directory has a single protocol, shared with the in-place
extraction transaction: an `.in-progress-<ts>-<pid>-<uuid>` directory with no matching
`.retired-<...>` marker is a ROLLBACK RECORD, and `recoverInterruptedComponentExtractions` restores the
newest such directory OVER the live component path at startup. Anything else there is residue and is
swept. A tree that is already known to be garbage when it is parked — the evicted two-deploys-ago
retained-previous — therefore carries a distinct `.discarded-` prefix, because a crash between parking
and sweeping would otherwise make startup recovery resurrect an ancient version over the current one.
The three startup passes read disjoint directories — `.deploy-aside` for an interrupted in-place
preparation, `.deploy-previous` for an interrupted revert, and `.deploy-staging`/`.deploy-activating`
for an interrupted two-phase activation — and the two directory repairs run before the staged
reconciliation so it decides roll-forward against a settled live directory.

**An interrupted revert self-heals.** The three-way swap compensates in process (each rename is undone
if a later one fails, so an exceptional I/O error leaves the component serving what it was serving),
but compensation cannot cover the process dying between renames — and after the first rename the
component has NO live directory. So the holding path is named `.reverting-<component>-<uuid>`, and
`recoverInterruptedReverts` restores it at startup into whichever slot is empty: the live path when the
swap had not yet placed the reverted-to version (the revert is undone and can be retried), or the
retained-previous path when the swap completed and only the retain step was lost. With both slots
occupied the swap finished and the holding tree is residue, so it is discarded.

**Staged-build retention.** A successful deploy consumes its staged build immediately (activation
renames it live), so nothing normally accumulates. What does accumulate is the residue of deploys that
never reached activation — a failed install, a crash between stage and swap — each leaving
`.deploy-staging/<deploymentId>/<name>` behind. `stageApplication` bounds this: after a successful
stage it evicts the oldest not-yet-activated staged builds for that component beyond
`deployment_stagingRetention_maxCount` (default 5, `pruneStagedBuilds`), always keeping the just-staged
one and the newest N−1 by mtime. Eviction is best-effort (`allSettled`, trace-logged) but awaited so
the count is settled when the stage returns. Retention is deliberately count-only and automatic:
`hdb_deployment` rows stay as the audit trail (payload blobs already self-reclaim by size,
`deployment_payloadRetention_maxSize`), and no `delete_deployment` op was added — eviction-on-stage
keeps the surface at zero new operations. Nothing addresses a staged directory by id any more, so
eviction has no user-visible consequence beyond reclaiming disk; activating a previously staged
deployment moves to #2301 with the rest of the coordination protocol.

**Payload retention.** Two independent bounds apply to the tarballs stored in `hdb_deployment`'s
`payload_blob`, and they answer different questions:

- `deployment_payloadRetention_maxSize` (default 10 MiB) — reclaims _this_ deploy's payload right after
it succeeds, if the tarball was large. Bounds the size of any single retained payload.
- `deployment_payloadRetention_maxCount` (default **1**, `pruneProjectPayloads`) — keeps at most N
stored payloads _per project_, newest first, dropping the `payload_blob` of the rest after a
successful deploy. Bounds how many payloads accumulate over time, which is what actually caps disk.

Only rows that still hold a payload count toward `maxCount`, so the cap reads literally as "at most N
stored payloads per project." Rows are never deleted — pruning nulls the blob and nothing else, so the audit
trail and `get_deployment` stay intact; only `get_deployment_payload` stops working for pruned
deployments (`payload_blob_present: false`). Automatic pruning does NOT append `payload_dropped` to the
rows it prunes: `event_log` is append-only and adding to it is a read-copy-write, which would lose a
concurrent writer's entry, so `payload_blob_present: false` is what records the drop there. The
deploying operation still emits `payload_dropped` on its own progress stream (and so into its own
row's `event_log`), and the explicit `delete_deployment_payload` does append it to the row it targets —
that one is a single operator-driven write with no concurrent writer to lose. A
non-terminal deployment is counted but never dropped: its blob may still be the replication channel
peers are installing from (the same guard `delete_deployment_payload` uses). Pruning is best-effort and
off the deploy's critical path — a prune failure is logged, never fatal — and is skipped entirely when a
peer failed, since the older payloads are the retry artifact in that case.

The default of 1 is deliberately conservative rather than a redeploy-window convenience: instances on
small quotas (free tier is 5 GB total) must not have N copies of a large app payload quietly competing
with the customer's own data. Operators who want a wider redeploy-by-reference window raise it
explicitly; 0 retains none. Note that an explicit `delete_deployment_payload` (harper#1893) forfeits
redeployability for that deployment the same way an automatic prune does.

## RocksDB backup/restore: the restore lock + marker protocol (`dataLayer/restoreMarker.ts`, `dataLayer/rocksdbBackup.ts`)

The `restore_backup` operation restores a user database on a live server by closing it across all
Expand Down
49 changes: 46 additions & 3 deletions bin/cliOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,30 @@ import { initConfig, getConfigPath } from '../config/configUtils.ts';
// rather than being restated per caller (it also keeps `components/` off the CLI's import graph).
import { deriveGitSecretName, directoryProjectName, normalizeGitHost } from '../utility/componentNames.ts';

const OP_ALIASES = { deploy: 'deploy_component', package: 'package_component' };
// Plain name aliases. `revert` is deliberately NOT here — it lives in OP_VERB_PROPS below so it can
// carry the `_cliVerb` marker its missing-target guard keys on, and buildRequest checks OP_ALIASES
// first, so an entry here would shadow that.
const OP_ALIASES = {
deploy: 'deploy_component',
package: 'package_component',
};

const OP_VERB_PROPS: Record<string, Record<string, unknown>> = {
// `harper revert` uploads nothing: the version it activates is already on disk. `_cliVerb` is a
// CLI-internal marker (stripped before the request is sent) that drives the missing-target guard below.
revert: { operation: 'revert_component', _cliVerb: 'revert' },
};

// The operation has no notion of which verb invoked it, so requirements that belong to the verb are
// enforced here. Returns an error message, or null when the request is fine.
function verbRequirementError(req: any): string | null {
// revert_component requires its target so a retry can't toggle the rejected release back in. Caught
// here too, so the CLI names the flag instead of surfacing a raw validation error.
if (req._cliVerb === 'revert' && !req.to_deployment_id) {
Comment thread
dawsontoth marked this conversation as resolved.
return '`harper revert` requires the deployment you want live again — usage: harper revert project=<name> to_deployment_id=<id> (list_deployments reports the id)';
}
return null;
}

// Shown for any local-instance connection failure (missing pid, missing/stale domain
// socket, or a refused/ENOENT connect against it) — they're all the same user-facing
Expand All @@ -36,7 +59,7 @@ const LOCAL_NOT_RUNNING_MESSAGE = 'Harper is not running. Use `harperdb run` (or
// deploy completes. Add an operation here only after wiring its server-side
// SSE_PROGRESS_OPERATIONS entry — otherwise the server returns the buffered JSON path and
// the SSE parser sees no events.
const SSE_OPERATIONS = new Set(['deploy_component']);
const SSE_OPERATIONS = new Set(['deploy_component', 'revert_component']);

// The fields that decide *where* an operation connects and *as whom* — see transportContext().
const CONNECTION_FIELDS = ['target', 'auth_username', 'auth_password', 'rejectUnauthorized'];
Expand Down Expand Up @@ -304,6 +327,7 @@ export {
buildRequest,
redactCredentials,
refreshExpiredOperationToken,
verbRequirementError,
transportContext,
resolveGitTarget,
resolveCredentialHost,
Expand Down Expand Up @@ -570,7 +594,14 @@ export function prepareDeployByRef(req: any): void {
process.stderr.write(`Deploying "${req.project}" by reference: ${req.package}\n`);
}

// `harper revert` uploads nothing, so it has no packaging step — but it still needs the CWD project
// default every other deploy-family verb gets, or it fails server-side on a missing `project`.
const prepareRevert = async (req) => {
req.project ||= directoryProjectName(process.cwd());
};

const PREPARE_OPERATION: any = {
revert_component: prepareRevert,
deploy_component: async (req) => {
if (req.package) {
return;
Expand Down Expand Up @@ -619,6 +650,9 @@ function buildRequest(): any {
for (const arg of process.argv.slice(2)) {
if (OP_ALIASES.hasOwnProperty(arg)) {
req.operation = OP_ALIASES[arg];
} else if (OP_VERB_PROPS.hasOwnProperty(arg)) {
// Sugar verb (stage/activate) → deploy_component + preset props (e.g. activate:false).
Object.assign(req, OP_VERB_PROPS[arg]);
} else if (arg.includes('=')) {
let [first, ...rest] = arg.split('=');
let restStr: any = rest.join('=');
Expand Down Expand Up @@ -871,13 +905,22 @@ export async function resolveRequestOptions(req: any): Promise<{ options: any; t
async function cliOperations(req: any, skipResponseLog = false) {
require('dotenv').config();

// Enforce CLI-verb requirements (e.g. `harper revert` needs a to_deployment_id) before connecting or
// packaging, so a mistake fails fast instead of building + uploading a fresh deploy from the CWD.
const verbError = verbRequirementError(req);
if (verbError) {
console.error(verbError);
process.exit(1);
}

// Resolve target/auth inside the try so a credential or connection error (e.g. an incomplete
// `auth_username=`/`auth_password=` pair, which resolveRequestOptions throws on) is mapped to the
// same console.error + process.exit(1) as every other failure below, rather than escaping as an
// unhandled rejection. `target` is declared out here so the catch can still reference it.
let options: any, target: any;
try {
({ options, target } = await resolveRequestOptions(req));
delete req._cliVerb;
await PREPARE_OPERATION[req.operation]?.(req);
// Streaming deploy (multipart upload + SSE progress) only works against >= 5.1 servers.
// When deploying to a remote target, probe its version first and downgrade to the
Expand Down Expand Up @@ -913,7 +956,7 @@ async function cliOperations(req: any, skipResponseLog = false) {
// One renderer owns the (future) upload bar and the SSE event rendering for a
// multipart deploy. Created here so the upload-stream tap and the SSE consumer
// below share the same instance.
const renderer = req._multipart ? new DeployRenderer({ uploadTotal: req._uploadSizeEstimate ?? 0 }) : null;
const renderer = useSse ? new DeployRenderer({ uploadTotal: req._uploadSizeEstimate ?? 0 }) : null;
let body;
if (req._multipart) {
// Create the package stream here — after the renderer exists — so we can pass
Expand Down
Loading
Loading