diff --git a/.sdd/ui-bundle-upload/plan.md b/.sdd/ui-bundle-upload/plan.md new file mode 100644 index 0000000..97b755a --- /dev/null +++ b/.sdd/ui-bundle-upload/plan.md @@ -0,0 +1,211 @@ +# Implementation Plan -- sf ui-bundle upload Command + +Companion to `spec.md` (this directory) — see that doc for the full requirements/AC/design; this plan sequences the work. + +--- + +## 1. Readiness Gate + +Confirm/unblock before writing any code. + +### No hard blockers — Phase 1 can start immediately + +`--use-salesforce-pages` is the confirmed flag name (no short flag character — avoids `-p` collision with `dev`'s `--port`, see spec §2.4), and the required-boolean shape (`Flags.boolean({ required: true })`) is confirmed, settled design. Neither gates any phase. Caveat: per spec §2.4/§2.5, the AC6 transport is now resolved (multipart `bundle`), so this flag is no longer transport-contingent — but there is still no corresponding server-side field (PR #118209 did NOT add a `usePages`/`useSalesforcePages` field), so it remains a CLI-side concept only; the flag-to-server-field mapping is a separate, still-open matter (see §5 Risk Callouts row 4). + +The command ships in developer-preview state (`public static readonly state = 'preview';` on the command class, spec §2.4). This is a settled, no-blocker decision: `sf-plugins-core` emits the runtime preview warning on every invocation and oclif prints `This command is in preview.` in `--help`. It surfaces as concrete work in Phase 1 Step 1.3 (class declaration) and Phase 3 (snapshot/README/COMMANDS must reflect the preview state). + +The two bundle-source flags — `--zip-file` (`Flags.file`) and the new `--bundle-dir` (`Flags.directory`, char `-d`) — are declared as an oclif `exactlyOne: ['zip-file', 'bundle-dir']` group; neither is a standalone `required: true` flag. The exactly-one relationship and the SDR-backed compression of a `--bundle-dir` source (§2.2 item 6, §2.4, §2.6, REQ-302) are settled, in-scope design; the one soft dependency is that `@salesforce/source-deploy-retrieve` is a NEW runtime dependency that must be added to `package.json` (Phase 1 Step 1.0 below). The exact SDR zip API (its `ZipWriter` / zip-stream utility) is resolved at implementation time — the spec fixes only the library and its zip utility as the mechanism, not the call signature. + +Phase 4 Tier 2 NUTs (real-org calls) target the merged Core Connect API endpoint `POST /services/data/v62.0/connect/ui-bundle/deployments` (spec §2.5), which ultimately drives the server-side `UIBundleCrud.create(UIBundleSource)`. The contract is grounded in merged Core source on feature branch `p/salesforce-pages/262-develop` (API v62.0, `minVersion = 262`) — not yet on main, so still subject to change before GA — and the endpoint must be reachable on the target org before Tier 2 can pass. Per spec §2.5's Known Limitations, bundle payload validation (format/size/content-type/metadata-type) and citizen-dev permission enforcement (403) are **not yet implemented** server-side — today the endpoint accepts any binary payload without rejecting it — and it returns `202 Accepted` with `{ "jobId": "", "status": "Queued" }`; a rejection would therefore surface as a synchronous HTTP 4xx (spec §3.2) once validation lands, while a job-level `Failed` status is not something a single POST can observe. This is why Phase 2's `Failed`-branch handling (Step 2.6 below) is defensive-only, not an expected path (spec §3.1 case 5). The transport (AC6) is now resolved — multipart `bundle` binary part, locked per PR #118209 (spec §2.5) — so the CLI's existing multipart design matches the locked server contract. Two caveats documented in spec §2.5 apply to Tier 2 readiness: (a) PR #118209 is open/not-merged upstream, so the lock is authoritative-intent, not yet live on the branch; (b) the server doesn't yet persist the bundle bytes (FFX_BLOB byte-write is a TODO), so even once merged, an end-to-end upload isn't fully functional server-side yet. These are external/upstream blockers on true end-to-end success, not on the CLI's own correctness. + +### Resolved during spec update — no longer tracked here + +- **Open Question 1 — failure-example polling language.** Previously tracked below as a soft/parallel-track item. **Now resolved** — grounded in the merged Core contract (spec §2.5): `POST /services/data/v62.0/connect/ui-bundle/deployments` is async-only and does not return a synchronous `Failed` status, since the `202 Accepted` response representation is fixed at `{ "jobId": "", "status": "Queued" }` — real processing happens after the response in the downstream job. See spec §3.1 case 5, §3.2, §2.6 (the human failure block is marked defensive). `upload.ts`'s output-formatting code (Phase 2 Step 2.6) and spec §2.6's failure example both stand as written — kept for defensive completeness, not because the branch is an expected/normal outcome. + +**Gate exit criterion:** None — there are no hard blockers, so Phase 1 starts immediately. Open Question 1 is resolved (above), so there's nothing left to chase or transcribe into the PR description on its account at Phase 5 Step 5.6. Everything else in this section can proceed under the spec's documented assumption. + +--- + +## 2. Non-Goals Compliance Checklist (REQ-301, 303, 304, 305) + +The spec's §7 Out of Scope (REQ-301, 303, 304, 305) are satisfied _by omission_ — no phase below builds any of these — but each is called out explicitly here so absence-of-a-feature is a verified, deliberate outcome rather than an accidental gap. **REQ-302 is no longer a non-goal** — spec §7's callout moves it into scope (`--bundle-dir` + SDR auto-compression); it is now tracked as real implementation work in Phase 1 Step 1.0/1.3 and Phase 2 Step 2.3, not verified-as-absent here. + +| # | Non-goal | How compliance is verified | +| ----------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| REQ-301 | No `sf ui-bundle status` / `GET /connect/ui-bundle/deployments/{jobId}` polling command | No such command file exists under `src/commands/ui-bundle/` after this plan completes — confirm at Phase 5 Step 5.5 by checking `src/commands/ui-bundle/` contains only `dev.ts` and `upload.ts`. Deferred to Dreamforce+. | +| REQ-303 | No `--wait` flag or client-side polling loop inside `upload` | Phase 1 Step 1.3's flags object has no `wait` flag; Phase 2 Step 2.5 issues exactly one `connection.request()` call with no loop/retry wrapper. Already covered explicitly in Phase 2's table. | +| REQ-304 | `--use-salesforce-pages` stays required-boolean and Pages-only (not optional, no generic-upload semantics) | Phase 1 Step 1.3 defines `'use-salesforce-pages': Flags.boolean({ ..., required: true })` with no `default` — settled design (§1 above), not an open question. This row exists so the Non-Goal itself (not just the adopted shape) has an explicit trace point. Dreamforce+ (REQ-304's own boundary) flips this to optional once generic uploads exist — a separate, still-valid forward-looking note. | +| REQ-305 | No shared-library extraction — implementation lives entirely inside `plugin-ui-bundle-dev` | All new code in Phases 1-4 lands under this repo's `src/`, `messages/`, `schemas/`, `test/` — no new package is created, no code is published to or imported from an external shared library. Confirm at Phase 5 Step 5.5 by checking `package.json` gained no new internal `@salesforce/*` upload-specific dependency. Note: adding `@salesforce/source-deploy-retrieve` for compression (REQ-302, Step 1.0) is a runtime-dependency addition, **not** a shared-library extraction — the two are distinct and this row is unaffected by it. | +| ~~REQ-302~~ | **Reversed — now in scope** (`--bundle-dir` + SDR auto-compression) | No longer a non-goal (spec §7 callout). Verified _present_, not absent: Phase 1 Step 1.0 adds SDR to `package.json`, Step 1.3 adds the `--bundle-dir` flag + `exactlyOne` group, Phase 2 Step 2.3 branches on source type and compresses a directory. Tests cover the compression path (Phase 4 Steps 4.1/4.3). Kept in this table as a struck-through row so the reversal is explicit rather than a silently dropped requirement. | + +None of the remaining non-goals (301/303/304/305) require a dedicated implementation task; they require a dedicated verification glance at Phase 5 Step 5.5 (folded into the non-regression diff pass) so "we didn't build it" is confirmed rather than assumed. + +--- + +## 3. Phased Task Breakdown + +### Phase 1 — Scaffolding (command class + types + messages) + +**Entry criteria:** None — the Readiness Gate has no hard blockers, so Phase 1 can start immediately. + +**Exit criteria:** `upload.ts` compiles (even with a stubbed/unimplemented body), `UiBundleUploadResult` type exists and type-checks, message file loads without `Messages.loadMessages` throwing, and `@salesforce/source-deploy-retrieve` resolves as an installed dependency (`node -e "require.resolve('@salesforce/source-deploy-retrieve')"` exits 0). + +| Step | File | Action | Spec ref | +| ---- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | +| 1.0 | `package.json` (+ `yarn.lock`) | Add `@salesforce/source-deploy-retrieve` to the `dependencies` block — confirmed **absent** today (deps are `@inquirer/select`, `@oclif/core`, `@salesforce/core`, `@salesforce/kit`, `@salesforce/sf-plugins-core`, `@salesforce/ui-bundle`, `chokidar`, `form-data`, `http-proxy`, `micromatch`, `open`, `path-to-regexp`). Use the same `^`-caret range convention as the sibling `@salesforce/*` deps (exact minor version resolved at install time). Run `yarn install` to update `yarn.lock`. This is the **one intended exception** to the §5.2 zero-diff-on-existing rule (spec §2.2 item 8, §2.4, §5.2). Falsifiable: `node -e "require.resolve('@salesforce/source-deploy-retrieve')"` exits 0 and `git diff package.json` shows exactly one added dependency line plus lockfile churn, nothing else in the file. | spec §2.4 (REQ-302) | +| 1.1 | `src/config/types.ts` | Append `UiBundleUploadResult` type directly below existing `UiBundleDevResult` export (confirm exact current line span with a fresh read before editing, since line numbers age). Shape: `{ jobId: string; status: 'Queued' \| 'InProgress' \| 'Succeeded' \| 'Failed'; message?: string }`. The existing `UiBundleDevResult` export must show zero diff — this row is also covered by the spec §5.2 Non-Regression Checklist. | spec §2.3 AC4 (REQ-113) | +| 1.2 | `messages/ui-bundle.upload.md` | Create with `# summary`, `# description`, `# flags.zip-file.summary/.description`, `# flags.bundle-dir.summary/.description`, `# flags.use-salesforce-pages.summary/.description`, `# examples`, plus `# error.*` keys for the 3 CLI-side error names. Model structure on `messages/ui-bundle.dev.md`'s key list (confirmed 40+ keys) but only include upload-relevant keys — do not copy `dev`'s `info.*`/`warning.*` runtime keys verbatim, they're dev-server-specific. **Must also define message keys for ALL customer-facing output the command emits** (spec §6.3): the empty-bundle-dir error message (currently inlined at `compressDirectory`'s empty-dir check, line 50 in upload.ts), the compression-failure error message (line 57), and the `Failed`-status human failure block — the "Upload failed" line + its `Job ID:`/`Message:` labels (lines 144-145). Note: the message file already carries unused `# error.*` keys (`error.upload-failed`, `error.auth-failed`, `error.network-failed`, `error.validation-failed`) that the code doesn't currently reference — the guideline's intent is that authored output routes through such keys rather than duplicating strings inline. Flag names are settled (`--zip-file`/`-z`, `--bundle-dir`/`-d`, `--use-salesforce-pages` no short char) — no gating on this step. | spec §2.4, §3.2, §6.3 | +| 1.3 | `src/commands/ui-bundle/upload.ts` | Create command class `UiBundleUpload extends SfCommand`. Declare `public static readonly state = 'preview';` so the command ships developer-preview — `sf-plugins-core` emits the runtime warning on every invocation and oclif prints `This command is in preview.` in `--help` (spec §2.4). Apache-2.0 header (copy verbatim from `src/index.ts`'s 15-line block — confirmed exact text in scouting). Messages boilerplate mirroring `dev.ts:29-30` exactly, with bundle key `'ui-bundle.upload'`. Static `flags` object: `'zip-file': Flags.file({ summary: ..., description: ..., char: 'z', exists: true, exactlyOne: ['zip-file', 'bundle-dir'] })` — **no longer `required: true`**; `'bundle-dir': Flags.directory({ summary: ..., description: ..., char: 'd', exists: true, exactlyOne: ['zip-file', 'bundle-dir'] })`; `'use-salesforce-pages': Flags.boolean({ summary: ..., description: ..., required: true })` (no `char` — no short flag; always invoked as the full `--use-salesforce-pages`, avoiding `-p` collision with `dev`'s `--port`); `'target-org': Flags.requiredOrg()`. Leave `run()` body as a stub (`throw new Error('not implemented')` or similar) for now — implementation is Phase 2. Flag names, `exactlyOne` relationship, required-boolean shape, and preview state are all settled design — no gating on this step. | spec §2.4 | +| 1.4 | `src/commands/ui-bundle/upload.ts` + `messages/ui-bundle.upload.md` | Add the `--bundle-name` flag declaration to `upload.ts`'s static `flags` object: `'bundle-name': Flags.string({ summary: messages.getMessage('flags.bundle-name.summary'), description: messages.getMessage('flags.bundle-name.description') })` (no `char`, optional). Add the two corresponding message keys to `messages/ui-bundle.upload.md`: `# flags.bundle-name.summary` ("Name to associate with the uploaded UI Bundle.") and `# flags.bundle-name.description` ("A human-readable name for the UI Bundle. If not specified, defaults to the base name of --bundle-dir or --zip-file, with any .zip extension removed."). | spec §2.4 (REQ-116) | +| 1.5 | `src/commands/ui-bundle/upload.ts` | Add the `--api-version` flag declaration to the static `flags` object: `'api-version': Flags.orgApiVersion()` (no options, no summary/description override — the factory supplies its own). Add the module-level constant above the command class: `const MINIMUM_SUPPORTED_API_VERSION = 67;`. | spec §2.4 (REQ-117) | + +**Note on flag pattern fidelity:** the scout found `dev.ts`'s `open` flag uses `default: false` instead of `required`, and `target-org` has no inline `summary`/`description`/`char` at all. Do **not** copy those two deviations into `upload.ts` — `--use-salesforce-pages` is `required: true` per REQ-104, and the two bundle-source flags (`zip-file`, `bundle-dir`) are governed by their `exactlyOne` group (REQ-102/102b) rather than a standalone `required: true`, so the `default: false` deviation doesn't apply here; `target-org` correctly stays a bare `Flags.requiredOrg()` call with no local wiring, matching `dev.ts:73`. Char assignments: `zip-file` → `z`, `bundle-dir` → `d` (both free — `dev` uses `b/n/o/p/u`, so no collision, confirmed spec §2.4), and `--use-salesforce-pages` has no `char` at all, so `upload` never binds anything to `-p`. + +### Phase 2 — Core logic (flag validation is free via oclif; API call + response mapping + error handling) + +**Entry criteria:** Phase 1 exit criteria met; `tsc -p . --pretty --incremental` (the `compile` script) succeeds on the stubbed command. + +**Exit criteria:** `upload.ts`'s `run()` fully implemented; manual smoke run against a stub or real org for **both** source paths — `bin/dev.js ui-bundle upload -z --use-salesforce-pages -o ` and `bin/dev.js ui-bundle upload -d --use-salesforce-pages -o ` — produces one of the two output shapes; all of AC1/AC2/AC3 logic paths are code-complete (tests come in Phase 4). "Fully implemented" here means logic-complete against the **locked** multipart `bundle` transport contract (AC6 resolved, spec §2.5). Two external caveats sit alongside the "locked" claim (spec §2.5): (a) the resolving PR #118209 is open/not-merged upstream, so the lock is authoritative-intent, not yet live on the branch; (b) the server doesn't yet persist the bundle bytes (FFX_BLOB byte-write is a TODO), so even once merged, an end-to-end upload isn't fully functional server-side yet. These are external/upstream blockers on true end-to-end success, not on the CLI's own correctness or on Phase 2's exit criteria. + +| Step | Action | Spec ref | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | +| 2.1 | Implement connection resolution: `const orgConnection = flags['target-org'].getConnection(flags['api-version']);` — passing the resolved `--api-version` value (which may be explicit, org-config-derived, or `undefined`) into `getConnection()` so the connection uses the right API version. Previously this always passed `undefined`. | spec §2.3 AC1 (REQ-105, REQ-117) | +| 2.2 | Implement the unconditional `--api-version` floor check immediately after connection resolution (Step 2.1), before any zip staging or network call: `const apiVersion = parseInt(orgConnection.getApiVersion(), 10); if (apiVersion < MINIMUM_SUPPORTED_API_VERSION) { throw messages.createError('error.uiBundleUploadApiVersionError', [orgConnection.getApiVersion(), String(MINIMUM_SUPPORTED_API_VERSION)]); }`. The check reads the connection's own resolved `getApiVersion()` value — not the raw `flags['api-version']` input — so it fires the same way whether that resolved version came from an explicit flag, the target-org's config default, or auto-negotiation with the org. No `metadata`/`setFromDefault` special-casing. Add the new message key to `messages/ui-bundle.upload.md`: `# error.uiBundleUploadApiVersionError` → "Resolved API version %s isn't supported by this command; --api-version must be %s or later." (two tokens: resolved version, then `"67"`). The derived error name is `UiBundleUploadApiVersionError`. | spec §2.4 (REQ-117) | +| 2.3 | Implement bundle-source staging as a two-way branch on which of the `exactlyOne` flags was supplied, both converging on a single in-memory/temp **zip** used as the `bundle` multipart part. **`--zip-file` path:** read `flags['zip-file']` as-is (already validated to exist by `Flags.file({ exists: true })` — REQ-103); no re-compression, no content validation (REQ-112). **`--bundle-dir` path:** compress the directory (already validated to exist by `Flags.directory({ exists: true })` — REQ-103) to a zip via `@salesforce/source-deploy-retrieve`'s zip capability (its `ZipWriter` / zip-stream utility) before the POST — producing a zip in memory or a temp file (REQ-302). `collectFiles` (or the helper that walks the directory before feeding SDR's `ZipWriter`) now skips any entry whose name starts with `.` (files and directories, at every recursion depth) — so `.env`, `.DS_Store`, `.git/`, `assets/.hidden`, etc. never reach the zip, while sibling non-dotfile files are still included (REQ-114). The **exact SDR API call signature is resolved at implementation time** — the spec fixes only the library and its zip utility as the mechanism, not the call shape; do not over-specify it here. Still no client-side content validation on either path (REQ-112). The two error paths in this step — empty-bundle-dir and compression-failure — **must resolve their message strings via `messages.getMessage()` from the keys added in Step 1.2**, NOT inlined as string literals (spec §6.3). `collectFiles` resolves symlinked files and symlinked directories to their target rather than skipping them: it walks entry names via plain `readdirSync(root)` (no `withFileTypes`) and calls `statSync(full)` on each — `statSync` follows symlinks by default (unlike `lstatSync`), so a symlinked file reports `isFile() === true` and a symlinked directory reports `isDirectory() === true`, and both are bundled/recursed exactly like real files/directories (REQ-115). A dangling/broken symlink makes `statSync` throw `ENOENT`, which is left to propagate rather than being caught — a deliberate, minimal-scope choice. Falsifiable: the `--bundle-dir` unit test (Step 4.1) asserts the multipart `bundle` part is a zip identical in shape to the `--zip-file` path, and a `--bundle-dir` fixture containing dotfiles/dot-directories (Step 4.1's new test case, consuming the dotfile fixture from Step 4.3) shows zero dotfile entries in the resulting zip's entry list while sibling files are present; a separate `--bundle-dir` fixture containing a symlinked file and a symlinked directory (Step 4.1's symlink test case) shows both the symlinked file's target content and the symlinked directory's nested contents present in the resulting zip's entry list; and `grep 'The bundle source directory is empty' src/commands/ui-bundle/upload.ts` → zero matches, `grep 'Failed to compress the bundle source directory' src/commands/ui-bundle/upload.ts` → zero matches (the moved literals are gone). | spec §2.3 AC1 (REQ-102, REQ-103), AC3 (REQ-112), AC7 (REQ-114), AC8 (REQ-115), §2.2 item 6/10, §2.6 (REQ-302), §6.3 | +| 2.4 | Implement `bundleName` default derivation and wire it into the `deployRequest` JSON part. Reuse the already-computed `zipFilename` local (set to `${basename(bundleDir)}.zip` for `--bundle-dir` or `basename(zipFile)` for `--zip-file`), apply a case-insensitive `.zip` strip (`zipFilename.replace(/\.zip$/i, '')`), and fall back to the unstripped name if stripping leaves an empty string (a `--zip-file` literally named `.zip`/`.ZIP`): `const strippedZipFilename = zipFilename.replace(/\.zip$/i, ''); const bundleName = flags['bundle-name'] ?? (strippedZipFilename | | zipFilename);`. Pass this into `deployRequest`'s `requestedName`field:`form.append('deployRequest', JSON.stringify({ requestedName: bundleName }), { contentType: 'application/json' });`. Previously `deployRequest`was sent as an empty`{}`. | spec §2.4 (REQ-116) | +| 2.5 | Implement `connection.request()` call to `POST /services/data/v62.0/connect/ui-bundle/deployments` with a `multipart/form-data` body carrying the two REQUIRED parts the contract specifies (spec §2.5): a `deployRequest` part (`application/json`, now `{requestedName: bundleName}` per Step 2.4) and a `bundle` part (binary, the zip payload from either branch of 2.3, matching the server-side `@ConnectParameter(name = "bundle", type = ParameterType.Binary, minVersion = 262)` declaration) + a `pages: flags['use-salesforce-pages']` form field. **The multipart `bundle` transport itself is the LOCKED design** — AC6 resolved per spec §2.5, confirmed by PR #118209 which also removed `contentReference`. Two caveats (spec §2.5): (a) PR #118209 is open/not-merged upstream, so the lock is authoritative-intent, not yet live on the branch; (b) the server doesn't yet persist the bundle bytes (FFX_BLOB byte-write is a TODO). These are external blockers on end-to-end success, not on the CLI's correctness. `--use-salesforce-pages` still has no corresponding server-side field (PR #118209 did NOT add one), so the `pages` form field has no server-side home — that's a separate open item (spec §2.4). Exactly one call per invocation either way, no retry/poll loop, no `--wait` flag (REQ-101, REQ-303). | spec §2.3 AC1 (REQ-101), §2.5, §7 (REQ-303) | +| 2.6 | Map response → `UiBundleUploadResult`. On `Queued` (the only response shape the merged Core contract actually documents — the `202 Accepted` representation is fixed at `{ "jobId": "", "status": "Queued" }`, spec §2.5): print human success block (spec §2.6) or return object for `--json` (REQ-106, REQ-107). On synchronous `Failed` — **defensive handling for a response shape not expected in practice**, since `POST` is async-only and the merged contract never returns a job-shaped `Failed` body from the `POST` itself (spec §2.6 failure block marked defensive, §3.1 case 5, §3.2; see §1 above): print human failure block to **stderr** (REQ-108) or return object with `message` verbatim for `--json` (REQ-109) — this is a normal returned result, not a thrown error (server responded successfully, it just rejected the bundle). The `Failed`-status human block — the "Upload failed" line and its `Job ID:`/`Message:` labels — **must resolve via `messages.getMessage()` from the keys added in Step 1.2**, NOT inlined as string literals (spec §6.3). Implement the branch for completeness and AC2 coverage, not because it's a live path today. Note the §2.6 human success block is path-dependent: with `--bundle-dir` it prints a `Packaging bundle source... done` step (the real SDR pass), with `--zip-file` that step is a trivial no-op — both then print the identical `Staging and initiating upload... done` step. Falsifiable: `grep 'Upload failed' src/commands/ui-bundle/upload.ts` → zero matches, `grep 'Job ID:' src/commands/ui-bundle/upload.ts` → zero matches (the moved literals are gone — the `Failed` block lives in the message file), and the `Failed`-status case in the unit test (Step 4.1) confirms the output still renders correctly from the message file. | spec §2.3 AC2 (REQ-106–109), §6.3 | +| 2.7 | Wire CLI-side error handling via `messages.createError()`: throw `messages.createError('error.uiBundleUploadAuthError', [message])` on connection/auth failure, `messages.createError('error.uiBundleUploadNetworkError', [message])` on no-HTTP-response network failure, `messages.createError('error.uiBundleUploadValidationError', [message])` on server-side rejection (an HTTP error with an `errorCode`). The derived error `name`s (`UiBundleUploadAuthError`, `UiBundleUploadNetworkError`, `UiBundleUploadValidationError`) are UNCHANGED — the mechanism shifts from inline `new SfError(message, name)` to `messages.createError(key, [message])`, but the observable error names stay the same. The message keys were already added in Step 1.2: `# error.uiBundleUploadAuthError`, `# error.uiBundleUploadNetworkError`, `# error.uiBundleUploadValidationError` — each wraps the server/framework text as a single `%s` token. The `SfError` import is no longer needed (it's unreferenced after this change). | spec §2.3 AC3 (REQ-110) | +| 2.8 | Surface server error messages verbatim — no truncation/re-interpretation, whether from an HTTP error body or a `Failed`-status `message` field. **Server/framework-supplied messages are pass-through** (spec §6.3 carve-out (a)): a caught `error.message` from the org connection, an HTTP error body, or any externally-sourced error text is relayed as-is — it is NOT a hardcoded literal and is out of scope for the §6.3 rule. | spec §2.3 AC3 (REQ-111), §6.3 | + +**Comment style for `upload.ts`:** every code comment written in Phase 2 (2.1–2.8) follows spec §6.1 Code Comment Guidelines: + +- Implementation-focused — explain what the code does or the technical reason for a choice, never the business rationale/user story. +- One short line, not a paragraph. +- No requirement/AC/spec-ID citations (no `REQ-112`, `AC2`, `§2.5`, etc.) — state the reasoning directly instead. + +**Output message style for `upload.ts`:** all customer-facing output messages — success text, info lines, or thrown `SfError` message strings — are defined in `messages/ui-bundle.upload.md` and referenced via `messages.getMessage()` (spec §6.3), never inlined as string literals. Two carve-outs: (a) server/framework messages surfaced verbatim (a caught `error.message`) are pass-through, not authored strings (Step 2.8); (b) the `SfError` name/error-code second argument (e.g. `'UiBundleUploadValidationError'`) stays inline as a machine identifier, not customer-facing prose. + +**Non-regression checkpoint 1** (see §6 below) — run here, after Step 2.8, before touching any packaging file. + +### Phase 3 — Packaging (schema + snapshot + docs) + +**Entry criteria:** Phase 2 exit criteria met; `run()` is feature-complete and manually verified against at least one success and one failure case. + +**Exit criteria:** `schemas/ui__bundle-upload.json` validates the shape from 2.4; `command-snapshot.json` has 2 array elements; `README.md`/`COMMANDS.md` both mention `upload` without disturbing `dev`'s sections. + +| Step | Action | Spec ref | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| 3.1 | Create `schemas/ui__bundle-upload.json` — draft-07, `$ref` → `definitions.UiBundleUploadResult`, properties `jobId`/`status`/`message`, `required: ["jobId", "status"]` (message is optional per the type), `additionalProperties: false`. Mirror `schemas/ui__bundle-dev.json`'s exact structural pattern (confirmed 23-line reference file). Flag naming convention is settled — no gating on this step. | spec §2.3 AC4 + §5.2 | +| 3.2 | Regenerate `command-snapshot.json` by running `node --loader ts-node/esm --no-warnings=ExperimentalWarning "./bin/dev.js" snapshot:generate` (from `@oclif/plugin-command-snapshot`, the same package that backs the `test:deprecation-policy` wireit step's `snapshot:compare` command — confirmed via `package.json`'s script definitions and the plugin's own `commands/snapshot/generate.js`/`compare.js` source). Default output path is `./command-snapshot.json`. **Do not** rely on `yarn test` to regenerate the file: `test:deprecation-policy` only runs `snapshot:compare`, which is read-only — it logs a diff and sets a non-zero exit code on drift, it never calls `write()`. `snapshot:generate` is the correct, existing-pattern command, directly analogous to the `schema:generate`/`schema:compare` pair backing `test:json-schema`. **Then run `npx prettier --write command-snapshot.json`** — the generator emits 4-space indentation with no trailing newline, which diverges from the repo's 2-space prettier config; skipping this leaves an unnecessary formatting diff (a real prior gotcha). After running, diff the file: confirm the existing `ui-bundle:dev` element is byte-for-byte identical (`flagChars: ["b","n","o","p","u"]`, `flags: [...]` — confirmed exact array in scouting) and a **second** element for `ui-bundle:upload` was appended (not the first replaced). Expected new element: `command: "ui-bundle:upload"`, `flagChars` includes both `z` (from `--zip-file`) and `d` (from `--bundle-dir`) plus the base-flag char `o` (from `--target-org`, inherited the same way `dev`'s `o` is) — so `flagChars: ["d","o","z"]`; `flags` includes `use-salesforce-pages`, `bundle-dir`, `flags-dir`, `json`, `target-org`, `zip-file`. `--use-salesforce-pages` has no `char`, so `p` must **not** appear in `upload`'s `flagChars`. Verify against the actual generated output rather than hand-writing this file. Snapshot also reflects the preview `state` on the command. | spec §5.2 Non-Regression Checklist | +| 3.3 | Append `### sf ui-bundle upload` subsection to `README.md`, inserted at the end of the existing `sf ui-bundle dev` subsection and before the `` marker (re-confirm exact line numbers with a fresh read — they age). The generated block must reflect the current contract (spec §2.4): the `This command is in preview.` banner, the `(-z \| -d )` exactly-one usage group, both `-z, --zip-file` and `-d, --bundle-dir` flags, and `--use-salesforce-pages`. Do not touch the Features/Quick Start/Documentation prose or the existing `dev` subsection. | spec §5.2 (README non-regression + REQ-209) | +| 3.4 | Regenerate `COMMANDS.md` via `oclif readme` (the `version` script, per scouting) so a new TOC line + `## \`sf ui-bundle upload\``section is inserted between the existing``/``markers (re-confirm exact marker line numbers with a fresh read). The generated`upload`section must carry the preview banner, the`(-z \| -d)`exactly-one usage, both bundle-source flags, and`--use-salesforce-pages`. Do not hand-edit. | spec §5.2 (REQ-212) | + +**Non-regression checkpoint 2** — run here, immediately after 3.2 (the snapshot regen), since this is the single highest-risk step for silently mutating the existing `dev` array element. + +**Non-regression checkpoint 3** — run here, immediately after 3.4, before Phase 4 begins. 3.3 (a hand-edit at a specific line-number insertion point) and 3.4 (a full regeneration of a shared, partially-generated doc via `oclif readme`) are both risky edits to shared docs, and without a checkpoint here a bad insertion or an `oclif readme` clobber of the existing `dev` section would go undetected through all of Phase 4's test-writing work — the next checkpoint otherwise wouldn't fire until end of Phase 4. Run `git diff README.md COMMANDS.md` and confirm the diff is purely additive (new `upload` content only, zero changes to existing `dev` lines). + +### Phase 4 — Tests + +**Entry criteria:** Phase 3 exit criteria met; `upload.ts` and all packaging artifacts exist and compile/lint clean in isolation. + +**Exit criteria:** `upload.test.ts` green; `upload.nut.ts` Tier 1 green unconditionally, Tier 2 green when `TESTKIT_AUTH_URL` is set (or throws the mandated error when unset — not silently skipped). + +| Step | Action | Spec ref | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | +| 4.1 | Write `test/commands/ui-bundle/upload.test.ts` — Mocha + Chai + Sinon + `TestContext` from `@salesforce/core/testSetup`, `.js`-suffixed relative imports (ESM/NodeNext style), matching `dev.test.ts`'s exact import block (confirmed lines 17-20). Cover: **neither `--zip-file` nor `--bundle-dir`** → `FailedFlagValidationError` (`Exactly one of the following must be provided: --zip-file, --bundle-dir`), no network call (REQ-102); **both `--zip-file` and `--bundle-dir`** → `FailedFlagValidationError` (`--zip-file cannot also be provided when using --bundle-dir`, or the symmetric message depending on parse order — assert on the stable prefix, not the exact wording), no network call (REQ-102b); missing `--use-salesforce-pages` → `FailedFlagValidationError` (`Missing required flag use-salesforce-pages`) (REQ-104); missing `--target-org` with no default → `NoDefaultEnvError`, asserted the same way `dev.nut.ts:58` does it (`expect(result.jsonOutput?.name).to.equal('NoDefaultEnvError')` — note it's the _string_ `'NoDefaultEnvError'`, not an imported constant) but as a **unit** test equivalent, not a NUT (REQ-105); non-existent `--zip-file` path (`Flags.file` validation error); non-existent / not-a-directory `--bundle-dir` path (`Flags.directory` validation error); **`--bundle-dir` compression path** — a `--bundle-dir` pointing at a real directory fixture (Step 4.2) is compressed via `@salesforce/source-deploy-retrieve` and the multipart `bundle` part is a zip identical in shape to the `--zip-file` path (REQ-302); **`--bundle-dir` dotfile/dot-directory exclusion** — a `--bundle-dir` fixture containing dotfiles (e.g. `.env`, `assets/.hidden`) and/or a dot-directory (e.g. `.git/` with files inside) → the compressed zip's entry list excludes all dotfile/dot-directory entries, while sibling non-dotfile files in the same directory are still included (REQ-114); **`--bundle-dir` symlink resolution** — a `--bundle-dir` fixture containing a symlinked file and a symlinked directory (created via `symlinkSync`, skipped via `this.skip()` if creation is rejected in the current environment, e.g. an unprivileged Windows runner) → the compressed zip's entry list includes the symlinked file's target content at the symlink's path and the symlinked directory's nested contents, resolved rather than skipped (REQ-115); **`--zip-file` path** — file sent as-is, no re-compression pass; `Queued` response in both human and `--json` modes (the only response shape the merged Core contract documents — the `202` representation is fixed at `{ jobId, status: "Queued" }`, spec §2.5); `Failed` response in both modes (defensive-path coverage per AC2 / spec §2.6 / §3.1 case 5 / §3.2 — mocked here since it's not a server behavior the real endpoint is expected to produce, only a shape the code must handle if it ever occurs), asserting the Failed-block output (the "Upload failed" line and `Job ID:`/`Message:` labels) renders correctly from the message file; each of the 3 CLI-side `SfError` names (the _actually_-synchronous failure path, per spec §3.2); the developer-preview warning is emitted (`state = 'preview'`) and not suppressed under `--json`'s result payload. **Assert `upload.ts` inlines no customer-facing output literal** — all such output resolves via `messages.getMessage()` per spec §6.3 (the empty-dir error, compression-failure error, and Failed-block output all come from the message file): `grep 'The bundle source directory is empty' src/commands/ui-bundle/upload.ts` → zero matches, `grep 'Failed to compress the bundle source directory' src/commands/ui-bundle/upload.ts` → zero matches, `grep 'Upload failed' src/commands/ui-bundle/upload.ts` → zero matches (the `Failed` block lives in the message file). The `--bundle-dir` compression cases consume the directory fixtures from Step 4.2 (the plain fixture, the dotfile/dot-directory fixture, and the symlink fixture); the connection is mocked for all cases. | spec §5.1, §6.3, AC8 (REQ-115) | +| 4.2 | Add the test-fixture helpers — pick names that don't collide with the confirmed existing exports in `devServerUtils.ts` (`SUITE_TIMEOUT`, `SPAWN_TIMEOUT`, `SPAWN_FAIL_TIMEOUT`, `UiBundleDevHandle`, `spawnUiBundleDev`, `occupyPort`, `startTestHttpServer`, `startViteProxyServer`, `closeServer`) or `uiBundleProjectUtils.ts` (`uiBundlePath`, `authOrgViaUrl`, `createProject`, `createUiBundle`, `createProjectWithUiBundle`, `createProjectWithMultipleUiBundles`, `createEmptyUiBundlesDir`, `createUiBundleDirWithoutMeta`, `writeManifest`, `createProjectWithDevServer`, plus the unexported module-local `createDevServerScript`). Fixtures needed: `createZipFixture` (a pre-built zip for the `--zip-file` path — the spec's suggested name, confirmed collision-free) **and** a `createBundleDirFixture`-style helper that materializes a real uncompressed source directory for the `--bundle-dir` compression path (Step 4.1's compression case and Step 4.3's Tier 2 `--bundle-dir` run both consume it), plus a variant/extension of the directory-fixture helper that also includes dotfiles (e.g. `.env`, `assets/.hidden`) and a dot-directory (e.g. `.git/` with nested files) for the Step 4.1 dotfile-exclusion test case (REQ-114), plus a further variant that creates a symlinked file and a symlinked directory (each pointing at a real target outside the fixture directory) via `symlinkSync` for the Step 4.1 symlink-resolution test case (REQ-115) — this helper must tolerate environments that reject symlink creation (e.g. an unprivileged Windows runner without Developer Mode returns `EPERM`/`ENOSYS`) by signaling the caller to skip the test rather than failing the whole suite — confirm all chosen names are collision-free before use. **Decide each fixture's storage location as part of writing these helpers** (folded in from a spec gap the scout flagged): `_cleanup.nut.ts` sweeps `test_session_*` directories under `process.cwd()` after all NUTs finish, so if a fixture is written **inside** a `TestSession`-managed `test_session_*` dir, cleanup is automatic and no teardown code is needed; if it must live in a separate out-of-session temp/fixtures folder, `_cleanup.nut.ts` will **not** catch it and `upload.nut.ts` (Step 4.3) needs its own teardown. Prefer routing through the existing `TestSession` temp dir — zero new cleanup code — and only add manual teardown if that's not viable. | spec §5.2 Non-Regression Checklist (REQ-208) | +| 4.3 | Write `test/commands/ui-bundle/upload.nut.ts` — Tier 1 (`describe('ui-bundle upload NUTs — Tier 1 (no auth)', ...)`, no guard, `TestSession.create({ devhubAuthStrategy: 'NONE' })` only, flag-parse-only assertions — including neither/both of `--zip-file`/`--bundle-dir` (exactly-one) and missing `--use-salesforce-pages`; note even flag-parse-only NUTs plausibly need a real file/dir path since `Flags.file`/`Flags.directory` check existence, so Tier 1 also consumes the Step 4.2 fixtures) + Tier 2 (guard: `if (!process.env.TESTKIT_AUTH_URL) throw new Error(...)` — must throw, not skip, per the existing contract at `dev.nut.ts:76-85`/`devPort.nut.ts:53-58`/`devWithUrl.nut.ts:61-65`; assert the real-org `POST` returns a `Queued` job id for **both** the `--zip-file` and the `--bundle-dir` (auto-compressed) sources). Consumes the fixture helpers from Step 4.2 — write 4.2 first. | spec §5.2 | + +**Non-regression checkpoint 4** — run here, before moving to Phase 5, since Phase 4 is the first point new test files exist alongside the existing `dev` suites in the same `mocha` glob patterns (`test/**/*.test.ts`, `**/*.nut.ts`) — confirm the new files don't change how the existing suites are discovered or ordered. + +### Phase 5 — Non-regression verification + lint + PR + +**Entry criteria:** Phase 4 exit criteria met (all new tests green in isolation). + +**Exit criteria:** Full Definition of Done (§7 below) satisfied; PR opened. + +| Step | Action | Spec ref | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| 5.1 | Run `yarn fix-license` (exact confirmed command: `eslint src test --fix --rule "header/header: [2]"`) — confirm all new `.ts` files carry the exact 15-line Apache-2.0 header (dynamic year — confirmed `new Date().getFullYear()`, so 2026 today). | spec §5.1 | +| 5.2 | Run `yarn lint` (`eslint src test --color --cache --cache-location .eslintcache`) standalone — this is what `pre-commit` runs, catch failures before they block the commit hook. | spec §5.1 | +| 5.3 | Run `yarn build` (`compile` + `lint`) and `yarn test` (full wireit graph: `test:compile`, `test:only`, `test:command-reference`, `test:deprecation-policy`, `lint`, `test:json-schema`, `link-check`) — this mirrors exactly what `pre-push` and CI (`linux-unit-tests`/`windows-unit-tests`) will independently re-run, so surfacing failures here avoids a failed push. | spec §5.2 | +| 5.4 | Run `yarn test:nuts` (`mocha "**/*.nut.ts" --slow 4500 --timeout 300000 --parallel=false`) locally — covers CI's `nuts` job (matrix `ubuntu-latest`/`windows-latest`, retries 3). Requires `TESTKIT_AUTH_URL` set for Tier 2 to run rather than throw. | spec §5.2 | +| 5.5 | **Final non-regression diff** — `git diff` against the pre-change baseline commit for every file in the spec §5.2 Non-Regression Checklist: `src/commands/ui-bundle/dev.ts`, `messages/ui-bundle.dev.md`, `schemas/ui__bundle-dev.json`, the `UiBundleDevResult` export in `src/config/types.ts`, the `ui-bundle:dev` element in `command-snapshot.json`, `package.json`'s `oclif.topics.ui-bundle` block, `src/index.ts`, and the pre-existing bodies of `dev.test.ts`/`dev.nut.ts`/`devPort.nut.ts`/`devWithUrl.nut.ts`/`devServerUtils.ts`/`uiBundleProjectUtils.ts`. All must show **zero diff lines**. **One intended exception:** `package.json`'s `dependencies` block is _not_ zero-diff — it gains exactly one line, `@salesforce/source-deploy-retrieve` (Step 1.0, spec §2.4/§5.2), plus `yarn.lock` churn; everything else in `package.json` (including the `oclif.topics.ui-bundle` block and every existing dependency) stays untouched, and `dev` stays fully zero-diff. Confirm the SDR line is the _only_ dependency added — no `archiver`/`jszip`/`adm-zip` or other zip lib. Also verify the §2 Non-Goals Compliance Checklist (REQ-301/303/305, spec §7): no `status`-style command file exists, no `--wait`/polling loop exists, and no code was extracted to an external shared library. (REQ-302 is now in-scope, so it is verified _present_ per Phase 1/2, not absent here.) | spec §5.2 + §7 (REQ-301, REQ-303, REQ-305) | +| 5.6 | Commit (conventional-commit format — `commit-msg` hook runs `commitlint --edit` against `@commitlint/config-conventional`, will reject non-conforming messages), open PR against `main`, **note the remaining open items in the PR description**: (1) `requestedName` has no CLI flag (§5 Risk row 4); (2) `--use-salesforce-pages` has no server-side field (spec §2.4); and two external/upstream caveats on the locked multipart `bundle` transport (AC6 resolved per PR #118209): (a) PR #118209 is open/not-merged upstream, so the lock is authoritative-intent, not yet live on the branch; (b) the server doesn't yet persist the bundle bytes (FFX_BLOB byte-write is a TODO). Call these out so reviewers understand the CLI's transport implementation is correct against the locked contract, with the caveats being external blockers on end-to-end success. Also call out in the PR description the one intended `package.json` change (the `@salesforce/source-deploy-retrieve` dependency addition for `--bundle-dir` compression) so reviewers don't flag it as an unexpected non-regression violation. | plan §1 Readiness Gate (external dependency; not spec-traced) | + +--- + +## 4. Dependency Graph + +**Hard blocking edges (must happen in this order):** + +- Phase 1.0 (`@salesforce/source-deploy-retrieve` installed) → Phase 2.3 (`--bundle-dir` compression can't import SDR's zip utility until the dependency resolves; the import itself won't type-check without it) +- Phase 1.1 (`UiBundleUploadResult` type exists) → Phase 1.3 (`upload.ts`'s class signature `SfCommand` needs the type to compile) +- Phase 1.3 (command class + flags exist, including the `exactlyOne` group and `--bundle-dir`) → Phase 1.4/1.5 (new flags need the base command structure in place first) +- Phase 1.4 (`--bundle-name` flag + message keys exist) → Phase 2.4 (`bundleName` default-derivation logic references the flag and message keys) +- Phase 1.5 (`--api-version` flag + `MINIMUM_SUPPORTED_API_VERSION` constant exist) → Phase 2.2 (the unconditional floor check needs the flag declaration and constant, plus the connection resolved in Phase 2.1) +- Phase 1.3/1.4/1.5 (all flags defined) → Phase 2 (can't implement `run()` logic without the flags defined) +- Phase 2 (response mapping to `UiBundleUploadResult` finalized) → Phase 3.1 (schema must match the actual shape being returned, not a guessed one) +- Phase 3.1 (schema exists) → Phase 3.2 (snapshot/schema validation tooling — `test:json-schema` — needs the schema file present to validate against) +- Phase 3.2 (snapshot regenerated) → Phase 5.5 (final diff check needs the snapshot in its final state) +- Phase 2 (feature-complete `run()`) → Phase 4.1/4.3 (tests need real behavior to assert against, not a stub) +- Phase 4.2 (fixture helpers exist — both the zip and the bundle-dir fixtures) → Phase 4.1 (unit test's `--bundle-dir` compression case needs the directory fixture) and Phase 4.3 (`upload.nut.ts` consumes both fixtures, including its Tier 1 flag-parse-only assertions since `Flags.file`/`Flags.directory` need a real path) +- Phase 4 (tests exist and pass in isolation) → Phase 5.3/5.4 (full-suite runs need the new tests present to be part of "full") + +**Non-blocking / parallelizable:** + +- Phase 1.2 (message file) and 1.3 (command class) can proceed in parallel with each other from the start (no Readiness Gate to clear), as long as both land before Phase 2 needs `messages.getMessage()` calls to resolve. Note the tighter documentation-accuracy constraint here: 1.3's static `flags` object calls `messages.getMessage()` at module-load time, so 1.2 must be functionally in place (not just "eventually before Phase 2") for 1.3 itself to load without `Messages.loadMessages` throwing — this is already satisfied by the table's ordering (1.1, 1.2, 1.3), it's just a tighter constraint than "before Phase 2" implies. +- Phase 3.3 (README) and 3.4 (COMMANDS.md regen) are independent of each other and of 3.1/3.2 — README is hand-written prose, COMMANDS.md is tool-generated; do in either order, or in parallel. +- Phase 4.2 (fixture helpers) can be written any time after Phase 2 lands (needs to know what a realistic zip/directory payload looks like), and must land before Phase 4.1's compression case and Phase 4.3 need them. Phase 4.1's non-compression cases (flag validation, `Queued`/`Failed` mapping, `SfError` names) do not depend on 4.2 — they mock the connection — but the `--bundle-dir` compression assertion in 4.1 does consume the directory fixture, so that one case waits on 4.2. +- Open Question 1 is resolved (§1), so no parallel tracking runs against it. The AC6 transport question is also resolved (§5 Risk Callouts row 3) — multipart `bundle` is the locked design. What _does_ run in parallel with Phase 1-4, without blocking code being written: the two residual external caveats (PR #118209 open/not-merged; server bytes not yet persisted) and the missing `requestedName` flag (§5 Risk Callouts row 4). Neither blocks the CLI's own implementation correctness. + +--- + +## 5. Risk Callouts (Open Questions → load-bearing point) + +| # | Question | Where it becomes load-bearing | +| --- | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | ~~Failure-example polling language~~ — **Resolved** | Was tracked as a risk to Phase 2 Step 2.6's output-formatting code. **No longer a risk**: resolved by the merged Core contract (spec §2.5) — the `POST`'s `202 Accepted` representation is fixed at `{ jobId, status: "Queued" }`, so it does not return a synchronous `Failed` response (see §1 above, spec §2.6 / §3.1 case 5 / §3.2). 2.6's code and the spec §2.6 failure example stand as written, understood as defensive-only. | +| 2 | No client-side zip-content sniffing (non-zip `--zip-file` input) | Phase 2 Step 2.3 (staging code deliberately does _not_ validate contents) and Phase 4 Step 4.1 (test for non-existent path only, not for "not a valid zip" — don't accidentally over-test here and imply a contract that doesn't exist). Gap tracked in spec §3.1 case 2 and the §3.2 no-client-side-validation callout (REQ-112) — per spec §2.5's Known Limitations, server-side size/content-type validation is not yet implemented at all today (not merely a dormant, not-yet-enforced seam) — the endpoint currently accepts any binary payload without rejecting a malformed zip, so today there is nothing for a rejection to surface against. This makes the row's underlying question even less consequential right now, but it's still flagged as a forward-looking open item for once validation lands. Doesn't block Phase 2 (REQ-112 already settles "no client-side validation, ever"), only the shape of any error surfaced back from a server-side rejection. | +| 3 | ~~AC6 transport question~~ — **Resolved** | Was tracked as a risk to Phase 2 Step 2.5's `connection.request()` call. **No longer a risk**: resolved by PR #118209 (W-23384691, spec §2.5) — the zip is sent as a multipart `bundle` binary part (`@ConnectParameter(name = "bundle", type = ParameterType.Binary, minVersion = 262)`); `contentReference` was REMOVED; base64-in-JSON and pre-staged `contentReference` were explicitly REJECTED. The CLI's existing multipart `bundle` design now matches the locked server contract. **Two residual caveats** (spec §2.5): (a) PR #118209 is open/not-merged upstream (base `p/salesforce-pages/262-develop`), so the lock is authoritative-intent, not yet live on the branch — subject to change before GA; (b) the server doesn't yet persist the bundle bytes (FFX_BLOB byte-write is a TODO), so even once merged, an end-to-end upload isn't fully functional server-side yet. These are external/upstream blockers on true end-to-end verification (Tier 2 NUTs), not on the CLI's own implementation correctness. `--use-salesforce-pages` still has no server-side field (PR #118209 did NOT add one) — that's a separate open item tracked in row 4. | +| 4 | ~~`requestedName` field has no CLI flag (§2.5)~~ / `--use-salesforce-pages` has no server-side field (§2.4) | **Resolved (requestedName half)** — the `requestedName` gap is now closed by REQ-116 (`--bundle-name`, Phase 1 Step 1.4 + Phase 2 Step 2.4): the flag is optional, defaulting to the bundle source's basename (`.zip`-stripped for `--zip-file`, or the directory name as-is for `--bundle-dir`); it flows into `deployRequest`'s `requestedName` field, which previously was sent as an empty `{}`. **Still open (--use-salesforce-pages half)** — `--use-salesforce-pages` has no corresponding server-side field (spec §2.4 — PR #118209 did NOT add one), so the `pages` form field has no server-side home. This is a separate, still-open matter. | +| 5 | ~~Overlap with pre-signed-URL upload optimization effort~~ — **No longer relevant** | Was noted for awareness as subsumed by the AC6 transport question (row 3). Now that AC6 is resolved (multipart `bundle` locked, `contentReference` removed, pre-staged alternatives explicitly rejected per spec §2.5/PR #118209), the pre-signed-URL surface no longer overlaps with the chosen transport. No longer tracked as a risk or awareness item. | +| 6 | Exact SDR zip API for `--bundle-dir` compression (§2.4) | **Load-bearing for Phase 2 Step 2.3's `--bundle-dir` branch.** The spec fixes only the library (`@salesforce/source-deploy-retrieve`) and its zip utility (`ZipWriter` / zip-stream) as the mechanism, not the call signature — the precise API is resolved at implementation time. Phase 1 Step 1.0 (add the dependency) and Phase 2 Step 2.3 (branch + compress) proceed, but the exact `ZipWriter` call shape is confirmed against the installed SDR version during implementation, not pre-specified here. Do not treat the compression call as contract-locked; the falsifiable check is Step 4.1's "bundle part is a zip identical in shape to the `--zip-file` path," which is API-agnostic. | + +--- + +## 6. Non-Regression Checkpoints + +Spec §5.2's Non-Regression Checklist (zero-diff on `dev`) is checked at 5 points, not just the final gate: + +1. **End of Phase 2** (after 2.8, before Phase 3 starts) — `git diff src/commands/ui-bundle/dev.ts messages/ui-bundle.dev.md src/config/types.ts` — confirms none of the core-logic implementation work in `upload.ts` accidentally touched `dev.ts` or its message file (easy mistake if copy-pasting patterns), and that the `types.ts` edit from Phase 1.1 is still purely additive. +2. **Immediately after Phase 3 Step 3.2** (snapshot regen) — `git diff command-snapshot.json` — highest-risk single step for corrupting the existing `ui-bundle:dev` array element; diff before proceeding to README/COMMANDS.md work, not after. +3. **Immediately after Phase 3 Step 3.4** (README/COMMANDS.md regen), before Phase 4 begins — `git diff README.md COMMANDS.md` — confirms the hand-edit insertion (3.3) and the `oclif readme` regeneration (3.4) are both purely additive, catching a bad line-number insertion or a clobbered `dev` section before it's buried under Phase 4's test-writing work. +4. **End of Phase 4** (after 4.2/4.3 land) — run `yarn test:only` filtered to just `dev.test.ts` (or run the full unit suite and isolate `dev`'s results) and confirm identical pass/fail vs. the pre-change baseline recorded before Phase 1 started — catch a regression introduced by new shared-helper changes (4.2) before it's buried under Phase 5's full-suite run. +5. **Phase 5 Step 5.5** (final gate) — the complete file-by-file diff across every item in the spec §5.2 Non-Regression Checklist, plus its test-parity requirement via 5.3/5.4, plus the §2 Non-Goals Compliance Checklist verification. + +**Recommended baseline capture:** before Phase 1 starts, run `yarn test:only` and `yarn test:nuts` once against the clean `main` tree and save the output (pass/fail counts, not just "green") — this is the literal baseline spec §5's test-parity requirement and checkpoints 4/5 diff against. Without this, "identical pass/fail outcomes" has nothing concrete to compare to. + +--- + +## 7. Definition of Ready (per phase) / Definition of Done (whole plan) + +**Definition of Ready:** + +- **Phase 1 ready:** No hard blockers remain (§1) — Phase 1 can start as soon as the baseline test run is captured (per §6). +- **Phase 2 ready:** Phase 1's new/modified files compile clean (`yarn compile` equivalent, i.e. `tsc -p . --pretty --incremental`) and `@salesforce/source-deploy-retrieve` (Step 1.0) resolves as an installed dependency. +- **Phase 3 ready:** Phase 2's `run()` implementation is feature-complete and manually smoke-tested against at least one `Queued` and one `Failed` case (real or stubbed connection). +- **Phase 4 ready:** Phase 3's schema/snapshot/docs are in place; `yarn build` succeeds end to end. +- **Phase 5 ready:** Phase 4's `upload.test.ts` and `upload.nut.ts` (Tier 1 at minimum) pass in isolation. + +**Definition of Done (whole plan — traces to spec §5 Testing Guidelines and §7 Out of Scope):** + +- AC1–AC5 (spec §2.3, REQ-101–113) all pass, and spec §5.2's Non-Regression Checklist shows zero diff — verified via the Phase-specific checkpoints above, not just a single end-of-plan check. The one intended exception: `package.json` gains the `@salesforce/source-deploy-retrieve` dependency (+ `yarn.lock` churn), and `dev` stays fully zero-diff otherwise. +- REQ-301/303/304/305 (spec §7 Out of Scope) verified absent per §2's checklist — no status/polling command, no `--wait`/polling loop, `--use-salesforce-pages` stays required-boolean/Pages-only, no shared-library extraction. **REQ-302 is now in scope** (`--bundle-dir` + SDR auto-compression) and is verified _present_ via Phase 1 Step 1.0/1.3, Phase 2 Step 2.3, and Phase 4 tests — not absent. +- `upload.test.ts` and `upload.nut.ts` (both tiers) green — including the exactly-one (neither/both) cases and the `--bundle-dir` compression path; Tier 2 confirmed to throw (not skip) when `TESTKIT_AUTH_URL` is unset. +- All 5 non-regression checkpoints in §6 show zero diff / identical pass-fail at their respective phase boundaries (the sole allowed `package.json` diff aside), with the final Phase 5.5 diff being the authoritative last check. +- `yarn fix-license`, `yarn lint`, `yarn build`, `yarn test`, `yarn test:nuts` all pass locally, mirroring `pre-commit`/`pre-push`/CI (`linux-unit-tests`/`windows-unit-tests`/`nuts` jobs) exactly — no hook bypass (`--no-verify`) used to get a commit through. +- `COMMANDS.md` and `command-snapshot.json` show only tool-generated diffs (new `upload` content, including the preview `state`, the `--bundle-dir` flag, and the `d` flagChar), zero hand-edits to generated regions; `command-snapshot.json` re-run through `npx prettier --write` so its formatting matches the repo's 2-space config. +- No inlined customer-facing output literals in `upload.ts` — all authored output resolves via `messages.getMessage()` from `messages/ui-bundle.upload.md` per spec §6.3, verified via the Phase 4 Step 4.1 grep checks (empty-dir/compression-failure/Failed-block literals all moved). +- Open Question 1 is resolved (§1), and the AC6 transport question is resolved (§5 Risk Callouts row 3) — multipart `bundle` is the locked design per PR #118209. The remaining genuinely-open item to note in the PR description at Phase 5 Step 5.6 is: (1) `--use-salesforce-pages` has no server-side field (spec §2.4); and two external/upstream caveats: (a) PR #118209 is open/not-merged, so the lock is authoritative-intent, not yet live on the branch; (b) the server doesn't yet persist the bundle bytes (FFX_BLOB byte-write is a TODO). The exact SDR zip API (§5 Risk row 6) is resolved at implementation time; pre-signed-URL overlap is no longer relevant (§5 Risk Callouts row 5). diff --git a/.sdd/ui-bundle-upload/spec.md b/.sdd/ui-bundle-upload/spec.md new file mode 100644 index 0000000..7c980c6 --- /dev/null +++ b/.sdd/ui-bundle-upload/spec.md @@ -0,0 +1,526 @@ +# Feature Specification — `sf ui-bundle upload` Command + +**Type:** FEAT +**Feature Name:** `sf ui-bundle upload` Command +**Date:** 2026-07-08 + +--- + +## 1. Feature Summary + +`sf ui-bundle upload` is a thin CLI wrapper around `POST /connect/ui-bundle/deployments`. + +**Command state:** the command ships in developer-preview state — `public static readonly state = 'preview';` on the command class. `sf-plugins-core` therefore emits a runtime warning on every invocation and oclif prints a preview banner in `--help` (§2.4). + +**What it enables:** + +- Standard (non-admin) users can persist a React UI Bundle without the Metadata API, which requires admin-only `ModifyMetadata`/`ModifyAllData` at the framework level. +- The bundle source can be supplied two ways: a pre-built zip via `--zip-file`, or an uncompressed source directory via `--bundle-dir` that the CLI auto-compresses before upload (§2.4). + +**How it works:** + +- The endpoint is fully async: the CLI issues one `POST`, which stages the zip, enqueues a job, and returns `202 Accepted` immediately. +- When `--bundle-dir` is used, the CLI compresses the directory to a zip (via `@salesforce/source-deploy-retrieve`) before the `POST`; when `--zip-file` is used, the file is sent as-is. +- The CLI prints the returned job ID and does nothing else — no polling, no zip-content validation, no lifecycle management client-side. + +--- + +## 2. Functional Requirements + +### 2.1 User Stories + +**As a** standard (non-admin) Salesforce user +**I want to** upload a React UI Bundle to my org from the CLI without holding Metadata API `ModifyMetadata`/`ModifyAllData` permissions +**So that** I can create and persist a UI Bundle — the basis of a Salesforce Page — independently, without filing an admin request. + +**As an** agentic surface (CAP, Agentforce Vibes, Agentforce Coworker) +**I want to** a single, stable CLI entrypoint that uploads an agent-generated bundle and returns a machine-parseable job ID +**So that** any agentic entryway can trigger a UI Bundle deployment on a standard user's behalf through one unified command rather than reimplementing the call. + +### 2.2 Core Requirements + +1. Ship `sf ui-bundle upload` as one synchronous call to `POST /connect/ui-bundle/deployments` — no polling (REQ-101). +2. Accept the bundle source as exactly one of `--zip-file` or `--bundle-dir`, and validate the required flags (the `--zip-file`/`--bundle-dir` exactly-one relationship, `--use-salesforce-pages`, `--target-org`) before any network call (REQ-102–105). +3. Produce correct human and `--json` output for both success and failure paths (§2.6), surfacing the server message verbatim (REQ-106–109, 111). +4. Use distinct CLI-side error names, separable from a server-reported `Failed` status, so JSON consumers can branch on `result.name` (REQ-110). +5. Perform no client-side zip-content validation — a server-side concern (REQ-112). +6. When `--bundle-dir` is supplied, compress the directory to a zip via `@salesforce/source-deploy-retrieve` before the `POST`; when `--zip-file` is supplied, send the file as-is (REQ-302). +7. When `--bundle-dir` is compressed, dotfiles and dot-directories (any path segment starting with `.` — e.g. `.env`, `.DS_Store`, `.git/`) are excluded from the resulting zip; `--zip-file` is unaffected (REQ-114). +8. Ship the command in developer-preview state (`state = 'preview'`) so both `--help` and runtime surface the preview warning. +9. The change is additive to the plugin's command surface — new `UiBundleUploadResult` type (REQ-113, 205), generated artifacts (`command-snapshot.json`, `COMMANDS.md` — REQ-202, 212), `README.md` section (REQ-209), and test fixtures (REQ-208) are all new or appended, with no existing `dev` command source modified. The one deliberate exception is `package.json`, which gains `@salesforce/source-deploy-retrieve` as a new runtime dependency (§2.4) — so the framing is additive-to-plugin plus one dependency addition, not strictly "nothing existing modified." +10. When `--bundle-dir` is compressed, symlinked files and symlinked directories are resolved to their target (followed, not skipped) during the recursive directory walk, so they appear in the resulting zip like any other file or directory; `--zip-file` is unaffected (REQ-115). +11. Provide an optional `--bundle-name` flag that maps to the `requestedName` field in the Connect API's `deployRequest` JSON part; when omitted, default to the base name of `--bundle-dir` or `--zip-file`, with any `.zip` extension stripped (case-insensitive), falling back to the unstripped filename if stripping leaves an empty string (REQ-116). +12. Provide an optional `--api-version` flag (via `Flags.orgApiVersion()`) that is passed into `getConnection()`; after resolving the connection, check the connection's resolved `getApiVersion()` value (whether it came from the explicit flag, the target-org's own config default, or auto-negotiation) and throw a dedicated error before any zip staging or network call if the numeric major version is below 67 (REQ-117). + +### 2.3 Acceptance Criteria + +**AC1 (REQ-101–105) — Flags & synchronous POST** + +- [ ] **101.** All flags valid (exactly one bundle source) → exactly one synchronous `POST`; no retry/poll. +- [ ] **102.** Neither `--zip-file` nor `--bundle-dir` given → `FailedFlagValidationError` from the `exactlyOne` relationship (`Exactly one of the following must be provided: --zip-file, --bundle-dir`), no network call. Neither flag is a standalone `required: true` flag anymore; the requirement is enforced by the exactly-one group. +- [ ] **102b.** Both `--zip-file` and `--bundle-dir` given → `FailedFlagValidationError` from the `exactlyOne` relationship (`--zip-file cannot also be provided when using --bundle-dir`, or the symmetric `--bundle-dir cannot also be provided when using --zip-file` depending on parse order), no network call. +- [ ] **103.** `--zip-file` path missing/not-a-file → `Flags.file({ exists: true })` validation error, no network call. Symmetrically, `--bundle-dir` path missing/not-a-directory → `Flags.directory({ exists: true })` validation error, no network call. +- [ ] **104.** `--use-salesforce-pages` omitted → `FailedFlagValidationError` (`Missing required flag use-salesforce-pages`), no network call. +- [ ] **105.** `--target-org` omitted, no default → `NoDefaultEnvError` via `Flags.requiredOrg()`, no network call. Distinct mechanism from 102/104 (org resolver, not flag parser) — see `dev.nut.ts:58` for the existing pattern. + +**AC2 (REQ-106–109) — Output shapes** + +- [ ] **106.** Without `--json`, `Queued` response → human success block (§2.6) to stdout, exit 0. +- [ ] **107.** With `--json`, `Queued` response → `{ "result": { "jobId", "status": "Queued" } }` only, no human text. +- [ ] **108.** Without `--json`, defensive handling for whether the server response body ever carries a `status: "Failed"` shape → human failure block (§2.6) to stderr, exit 1. Not expected under the current merged contract (§2.5) — a `Failed` result requires a job id and a job-shaped `POST` response body, which the upstream spec does not document as a synchronous response — but the CLI does not fail closed if it happens. +- [ ] **109.** With `--json`, equivalent of 108 → `{ "result": { "jobId", "status": "Failed", "message" } }`, exit 1. Same "defensive, not expected" framing as 108. + +**AC3 (REQ-110–112) — Error semantics** + +- [ ] **110.** The _actual_ synchronous-failure path: an HTTP-level 4xx/5xx response from the `POST` call itself (no job id, no valid job-shaped body — e.g. the server's own early size/content-type rejection per §2.5, or auth failure, or no HTTP response at all) → thrown `SfError` with a distinct CLI-side name (`UiBundleUploadAuthError`/`UiBundleUploadNetworkError`/`UiBundleUploadValidationError`), separate from a server `Failed` status result object (108/109). Caveat: per §2.5's Known Limitations, the size/content-type rejection sub-case is not yet live — it's a defensive/forward-looking path, not one that can be exercised against the current endpoint. The auth-failure and no-HTTP-response causes in this same path remain valid today. +- [ ] **111.** Server error message — whether from an HTTP error body (110) or, defensively, a `Failed.message` (108/109) — surfaced verbatim, no rewriting or truncation. +- [ ] **112.** No client-side zip-content validation, ever. + +**AC4 (REQ-113) — Result type** + +- [ ] **113.** `UiBundleUploadResult` is a plain type in `src/config/types.ts`: `{ jobId: string; status: 'Queued' | 'InProgress' | 'Succeeded' | 'Failed'; message?: string }` — a sibling export, not a subclass/modification of `UiBundleDevResult`. + +**AC5 — Non-regression** + +- [ ] Covered by the Non-Regression checklist in §5.2; every item there is falsifiable via `git diff` or test-suite parity. + +**AC7 (REQ-114) — Dotfile exclusion** + +- [ ] **114a.** A `--bundle-dir` source containing a top-level and/or nested dotfile (e.g. `.env`, `assets/.hidden`) → the dotfile is excluded from the compressed zip; sibling non-dotfile files in the same directory are still included. +- [ ] **114b.** A `--bundle-dir` source containing a dot-directory (e.g. `.git/` with files inside it) → the entire dot-directory subtree is excluded (not traversed, not zipped). +- [ ] **114c.** `--zip-file` is out of scope for this AC — it is sent as-is, unaffected, per REQ-112. + +**AC8 (REQ-115) — Symlink resolution** + +- [ ] **115a.** A `--bundle-dir` source containing a symlinked file → the symlink is followed, and its target's content is included in the compressed zip at the symlink's path (not the file's original name/location). +- [ ] **115b.** A `--bundle-dir` source containing a symlinked directory → its contents are recursed into and included in the compressed zip, the same as a real directory at that path. +- [ ] **115c.** A `--bundle-dir` source containing a dangling/broken symlink (target does not exist) → the compression step fails, propagating the filesystem error (`ENOENT`), rather than silently omitting the entry from the zip. + +**AC9 (REQ-116) — Bundle-name flag and default-derivation** + +- [ ] **116a.** `--bundle-name my-custom-name` explicitly provided → `requestedName` in the `deployRequest` JSON part equals `"my-custom-name"` verbatim. +- [ ] **116b.** `--bundle-dir ./my-bundle-src` with no `--bundle-name` → `requestedName` defaults to the directory's base name (`"my-bundle-src"`), unmodified (no `.zip` suffix to strip). +- [ ] **116c.** `--zip-file my-archive.zip` with no `--bundle-name` → `requestedName` defaults to the file's base name with the `.zip` extension stripped, case-insensitively (`"my-archive"`). +- [ ] **116d.** `--zip-file .zip` (a file literally named `.zip` or `.ZIP`) with no `--bundle-name` → `requestedName` falls back to the unstripped filename (`".zip"` or `".ZIP"`), not an empty string, so the multipart `deployRequest` JSON never sends `{"requestedName":""}`. + +**AC10 (REQ-117) — API-version flag and floor enforcement** + +- [ ] **117a.** `--api-version 66.0` explicitly passed on the command line → the resolved connection's `getApiVersion()` reflects `66.0`, which is below the floor, so the command throws `UiBundleUploadApiVersionError` mentioning both the resolved version and the floor `67`, after connection resolution but before any zip staging or network call. +- [ ] **117b.** `--api-version 67.0` (at the floor, not below) → does not throw, proceeds to a normal `Queued` result. +- [ ] **117c.** `--api-version` omitted entirely (the flag's own default resolution kicks in, potentially resolving from the target-org's config, or to `undefined`) → the connection still resolves to some effective API version (org-config default or auto-negotiated), and that resolved value is checked against the floor unconditionally, the same as an explicit flag value. +- [ ] **117d.** The resolved `flags['api-version']` value (which may be `undefined`) is passed into `flags['target-org'].getConnection(flags['api-version'])`; the floor check then reads `orgConnection.getApiVersion()` — the connection's own resolved value — rather than re-checking the raw flag input, regardless of whether the version was explicit or defaulted. + +### 2.4 CLI Command Contract + +**Command state:** `public static readonly state = 'preview';` on the command class. This marks the command as developer-preview, so oclif prints `This command is in preview.` in `--help` output and `sf-plugins-core` emits the runtime warning `⚠ This command is currently in developer preview. Developer preview commands will likely change before shipping, use at your own risk. Don't use developer preview commands in your scripts.` on every invocation. + +| Flag | Char | Type | Required | Notes | +| ------------------------ | ---- | ----------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--zip-file` | `-z` | `Flags.file({ exists: true })` | exactly-one (with `--bundle-dir`) | Pre-built zip source, sent as-is. No client-side zip-content validation (REQ-112). Declares `exactlyOne: ['zip-file', 'bundle-dir']`; no longer a standalone `required: true` flag. | +| `--bundle-dir` | `-d` | `Flags.directory({ exists: true })` | exactly-one (with `--zip-file`) | Uncompressed UI Bundle source directory; CLI auto-compresses it before upload (§2.6, REQ-302). Declares `exactlyOne: ['zip-file', 'bundle-dir']`. `-d` is free — `dev` uses `b/n/o/p/u`, `upload` uses `o/z`, so no collision. | +| `--use-salesforce-pages` | — | `Flags.boolean({ required: true })` | yes | No short char — avoids `-p` collision with `dev`'s `--port`. The AC6 transport is now resolved (multipart `bundle`, §2.5), so this flag is no longer transport-contingent. But there is still no corresponding server-side field — PR #118209 did NOT add a `usePages`/`useSalesforcePages` field — so it remains a CLI-side concept only; the flag→server-field mapping is a separate, still-open matter. | +| `--target-org` | `-o` | `Flags.requiredOrg()` | yes | Same pattern as `dev.ts`; supplies its own messages. | +| `--api-version` | — | `Flags.orgApiVersion()` | no | Same factory as `plugin-data`'s `data:search` command. No short char. Resolved value passed into `getConnection()`; the connection's own resolved `getApiVersion()` (explicit flag, org-config default, or auto-negotiated) is then checked against the floor of 67 before any network call, unconditionally (REQ-117). | +| `--bundle-name` | — | `Flags.string()` | no | No short char. Maps to the `requestedName` field in the `deployRequest` JSON part (REQ-116). Defaults to the base name of `--bundle-dir` or `--zip-file`, with any `.zip` extension stripped (case-insensitive); falls back to the unstripped filename if stripping leaves an empty string. | + +**Exactly-one-of semantics:** `--zip-file` and `--bundle-dir` each declare `exactlyOne: ['zip-file', 'bundle-dir']`. The resulting validation, enforced by the oclif flag parser before any network call: + +- Neither flag given → `FailedFlagValidationError` (`Exactly one of the following must be provided: --zip-file, --bundle-dir`), no network call. +- Both flags given → `FailedFlagValidationError` (`--zip-file cannot also be provided when using --bundle-dir`, or the symmetric message depending on parse order), no network call. +- Exactly one given → proceeds to the `POST` path. + +Global `--json` / `--flags-dir` inherited from `SfCommand`. + +**`--help`:** + +``` +This command is in preview. + +Upload a UI Bundle to your org. + +USAGE + $ sf ui-bundle upload --use-salesforce-pages -o [--json] [--flags-dir ] [-z ] [-d ] + [--api-version ] [--bundle-name ] + +FLAGS + -d, --bundle-dir= Path to an uncompressed UI Bundle source directory; the CLI compresses it before upload. + -o, --target-org= (required) Username or alias of the target org. Not required if the `target-org` + configuration variable is already set. + -z, --zip-file= Path to the UI Bundle source to upload. + --api-version= Override the api version used for api requests made by this command + --bundle-name= Name to associate with the uploaded UI Bundle. + --use-salesforce-pages (required) Toggle whether this UI Bundle should be uploaded to Salesforce Pages. + +GLOBAL FLAGS + --flags-dir= Import flag values from a directory. + --json Format output as json. + +DESCRIPTION + Upload a UI Bundle to your org. + + Use this command to upload a React-based UI Bundle to your Salesforce org. Provide the bundle source as either a + compressed ZIP file (--zip-file) or an uncompressed source directory (--bundle-dir), which the CLI compresses for you. + This can be used by both admin and non-admin users. + + The upload is asynchronous. View the UI bundle in your org to verify completion. + +EXAMPLES + Upload a UI Bundle to Salesforce Pages using your default org: + + $ sf ui-bundle upload --zip-file my-compressed-bundle --use-salesforce-pages + + Upload an uncompressed source directory (auto-compressed by the CLI): + + $ sf ui-bundle upload --bundle-dir ./my-bundle-src --use-salesforce-pages + + Upload to a specific org by alias: + + $ sf ui-bundle upload --zip-file my-compressed-bundle --use-salesforce-pages --target-org my-org +``` + +**New dependency — `@salesforce/source-deploy-retrieve` (SDR):** compression of a `--bundle-dir` source leverages SDR's zip capability (its `ZipWriter` / zip-stream utility) to produce the zip in-memory or in a temp file before the `POST`. SDR is **not** currently in `package.json` (confirmed against the committed `dependencies` block — sibling `@salesforce/*` deps are `@salesforce/core`, `@salesforce/kit`, `@salesforce/sf-plugins-core`, `@salesforce/ui-bundle`, all pinned as `^`-caret ranges), so this feature **adds** it as a new runtime dependency using the same caret convention (exact minor version to be resolved at implementation time). The precise SDR API call is left to implementation; the spec fixes only the library and its zip utility as the mechanism. + +### 2.5 Connect API Contract (v62.0 — merged in Core, feature branch) + +Only the `POST` is in scope for this command; the `GET` below is shown for context/comparison only (REQ-301 excludes it). + +**Endpoints:** + +| Method | Path | In scope for `upload`? | +| ------ | ------------------------------------------------------------ | ------------------------------------------------------- | +| `POST` | `/services/data/v62.0/connect/ui-bundle/deployments` | Yes — the one call this command makes. | +| `GET` | `/services/data/v62.0/connect/ui-bundle/deployments/{jobId}` | No — status polling, context/comparison only (REQ-301). | + +Note: `minVersion = 262` (API v62.0); `allowsPortalUsers = false`; `supportedFormats = {JSON}`; Apex family `ConnectApi.UiBundleDeploy`; `Content-Type: multipart/form-data`; Cost: `Expensive`. + +**`POST` request** — the request is `multipart/form-data` with exactly two required parts: `deployRequest` (`application/json`) and `bundle` (binary, e.g. `application/zip`). `deployRequest` is the actual wire name of the JSON metadata part — confirmed unambiguously by the UI Bundle Deploy API Contract Reference's raw multipart body example (`Content-Disposition: form-data; name="deployRequest"`) and its request-parts table — serialized from the input representation `UiBundleDeployRequestRepresentation` (code constant `DEPLOY_REQUEST_INPUT`). An earlier pass of this spec claimed the wired name was `uiBundleDeployRequest`, with `deployRequest` dismissed as merely an informal PR-doc shorthand; that claim is superseded by the Contract Reference. Per PR #118209 (AC6) the representation now carries ONLY `requestedName`: + +| Field | Type | Required? | Notes | +| --------------- | ------ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `requestedName` | string | optional (recommended) | Human-readable page label → BPO `RequestedName`. Now populated from the CLI's `--bundle-name` flag (REQ-116); when omitted, defaults to the base name of `--bundle-dir` or `--zip-file`, with any `.zip` extension stripped (resolving the gap where this field had no CLI-side mapping). | + +**Example request:** + +``` +curl -X POST \ + "https://.salesforce.com/services/data/v62.0/connect/ui-bundle/deployments" \ + -H "Authorization: Bearer $SF_SESSION_TOKEN" \ + -F 'deployRequest={"requestedName":"Sales Dashboard"};type=application/json' \ + -F "bundle=@sales-dashboard.zip;type=application/zip" +``` + +Raw multipart body shape (illustrating the two part names/content-types directly): + +``` +Content-Disposition: form-data; name="deployRequest" +Content-Type: application/json + +{"requestedName":"Sales Dashboard"} +------boundary123 +Content-Disposition: form-data; name="bundle"; filename="sales-dashboard.zip" +Content-Type: application/zip +``` + +**Transport (AC6) — RESOLVED: multipart `bundle` binary part.** The zip is sent as a `multipart/form-data` binary part named `bundle`, declared server-side as `@ConnectParameter(name = "bundle", type = ParameterType.Binary, minVersion = 262)`; the resource `@ConnectSignature` parameters are `{deployRequest, bundle}` (method `submitDeploy`). Locked 2026-07-09 per the W-23384881 decision and implemented in PR #118209 (W-23384691, base `p/salesforce-pages/262-develop`), which also REMOVED `contentReference` (field + getters/setters + `@ConnectInputProperty`) and enumerated `BUNDLE_INPUT = "bundle"`. Base64-in-JSON and pre-staged `contentReference` were explicitly REJECTED alternatives. This confirms/validates the CLI's existing multipart `bundle` design (§2.2/§2.6) — the transport now matches the locked server contract and is no longer contingent on AC6. **Two caveats sit alongside the "resolved" claim:** + +**`POST` response — 202 Accepted** + +``` +{ "jobId": "", "status": "Queued" } +``` + +**`GET` response (context only)** — representation `UiBundleDeployStatusRepresentation` + +Example response (`InProgress`): + +``` +{ + "jobId": "0Ax000000000001", + "requestedName": "Sales Dashboard", + "status": "InProgress", + "uiBundleId": null, + "error": null +} +``` + +| Field | When populated | Source (BPO col) | +| --------------- | -------------- | ----------------------------- | +| `jobId` | always | `Id` | +| `requestedName` | always | `Label` | +| `status` | always | mapped enum | +| `uiBundleId` | Succeeded only | `UiBundleIdentifier` (9YE id) | +| `error` | Failed only | `ErrorDetail` (plain text) | + +**Status enum (frozen contract):** `Queued | InProgress | Succeeded | Failed`. The entity uses `Success`/`Fail` internally; the service translates at the contract boundary (`UiBundleDeployService#toContractStatus`). + +**HTTP status codes (eventual contract shape, from the upstream ACs):** 202 accept · 400 invalid payload · 403 missing citizen-dev permission · 404 on GET when the job doesn't exist OR is owned by another user (no existence leak; scoped by `CreatedById`). Per the Known Limitations below, 400 and 403 are not live today — see **Server-side validation** for the current, as-implemented behavior. + +**Caveats:** + +- Endpoint is on a feature branch, not yet on main — subject to change before GA. +- `pageUrl` and `workspaceId` are already absent from the current contract — the UI Bundle Deploy API Contract Reference's GET example response and field table include only `jobId`, `requestedName`, `status`, `uiBundleId`, `error`. This reflects the DEC-120 (2026-07-09) rationale as historical context: page URL is to be resolved at render time from developer name, and workspace is read via UDD off the UIBundle FK. Do NOT assume `pageUrl`/`workspaceId` appear in any current response. + +**Access model:** this endpoint is accessible by standard (non-admin) users. + +**Server-side validation — Known Limitations (as of this writing):** + +- Bundle payload validation (format/size/content-type/metadata-type) is **not yet implemented** — the endpoint currently accepts any binary payload without rejecting malformed zips. The 400/403 codes above are the eventual contract shape, not today's live behavior. +- Citizen-dev permission enforcement is **not yet implemented** — no 403 is returned for unauthorized callers yet. +- Do not assume today's accepted payloads will remain valid once validation lands; always send a well-formed UI Bundle zip and a non-empty `requestedName`. + +**Source references** (UI Bundle Deploy API Contract Reference): + +- Resource: `salesforce-pages-connect-impl/java/src/salesforce/pages/connect/impl/resources/UiBundleDeployResource.java` +- Interface/annotations: `salesforce-pages-connect-api/java/src/salesforce/pages/connect/api/resources/IUiBundleDeployResource.java` +- Constants (path, param names): `salesforce-pages-connect-api/java/src/salesforce/pages/connect/api/constants/UiBundleDeployConstants.java` +- Request rep: `salesforce-pages-connect-api/java/src/salesforce/pages/connect/api/representations/UiBundleDeployRequestRepresentation.java` +- Response rep: `salesforce-pages-connect-api/java/src/salesforce/pages/connect/api/representations/UiBundleDeployResponseRepresentation.java` +- Status rep: `salesforce-pages-connect-api/java/src/salesforce/pages/connect/api/representations/UiBundleDeployStatusRepresentation.java` +- Service (validation/permission stubs): `salesforce-pages-connect-impl/java/src/salesforce/pages/connect/impl/service/UiBundleDeployService.java` + +### 2.6 Output Shapes + +> **Output format is governed solely by the `--json` flag.** With `--json`, the command emits ONLY the JSON result object (no human-readable text) — on both the success and the failure path. Without `--json`, the command emits ONLY the human-readable formatted text blocks shown below (to stdout on success, stderr on failure) and never emits JSON — again on both paths. There is no mode that mixes the two. + +**Human — success (`--bundle-dir`, compression happens):** + +``` +Upload UI Bundle to org + +Upload queued successfully. +Job ID: 0BXxx0000000001 +``` + +**Human — success (`--zip-file`, no compression):** + +``` +Upload UI Bundle to org + +Upload queued successfully. +Job ID: 0BXxx0000000001 +``` + +**Human — failure (defensive; see callout below, text sourced from `messages/ui-bundle.upload.md` per §6.3):** + +``` +Upload UI Bundle to org + +Upload failed + Job ID: 0BXxx0000000001 + Message: Bundle validation failed — zip contains disallowed file type at path: src/server.js +``` + +**JSON — success:** `{ "result": { "jobId": "0BXxx0000000001", "status": "Queued" } }` + +**JSON — failure:** `{ "result": { "jobId": "0BXxx0000000001", "status": "Failed", "message": "Bundle validation failed — zip contains disallowed file type at path: src/server.js" } }` + +Status values (`Queued`/`InProgress`/`Succeeded`/`Failed`) match the server-side `UIBundleDeployJob` entity. The CLI only ever reports whichever status the one synchronous `POST` response carries (`Queued` or `Failed`) — it never observes `InProgress`/`Succeeded` in this scope, since that needs the polling surface REQ-301/303 exclude. + +--- + +## 3. Edge Cases and Error Handling + +### 3.1 Edge Cases + +1. **Non-existent or invalid `--zip-file` path** + + - **Scenario:** the path passed to `--zip-file` does not exist, or points to a directory rather than a file. + - **Expected Behavior:** `Flags.file({ exists: true })` raises its validation error before any network call (REQ-103); no `POST` is issued. + +2. **A non-zip file passed as `--zip-file`** + + - **Scenario:** a real, existing file that is not a valid zip is supplied. + - **Expected Behavior:** the CLI never inspects zip contents (REQ-112). `Flags.file({ exists: true })` only checks the path exists, not that it's a valid zip. The bundle is sent as-is; the server is the intended sole validator, but per §2.5's Known Limitations, bundle payload validation is not yet implemented server-side — today the malformed zip is silently accepted (`202 Accepted` / `Queued`), with no CLI-visible error at all. This is a Known Limitation, not a designed behavior. Once server-side validation lands, a bad payload is expected to surface as a synchronous server-side rejection (HTTP 4xx, §3.2), never a CLI-side content check. + +3. **Neither / both of `--zip-file` and `--bundle-dir` supplied** + + - **Scenario:** the invocation omits both bundle-source flags, or supplies both. + - **Expected Behavior:** the `exactlyOne: ['zip-file', 'bundle-dir']` relationship raises `FailedFlagValidationError` before any network call (§3.2 case 4, AC 102/102b). Neither is a standalone `required` flag; the exactly-one group is the sole enforcement point. + +4. **`--bundle-dir` path missing or not a directory** + + - **Scenario:** the path passed to `--bundle-dir` does not exist, or points to a file rather than a directory. + - **Expected Behavior:** `Flags.directory({ exists: true })` raises its validation error before any network call and before compression; no `POST` is issued. Contents of the directory are not inspected for validity — only compressed and sent (REQ-302; REQ-112 still applies — the server is the sole content validator). + +5. **Server response body unexpectedly carries `status: "Failed"`** + + - **Scenario:** the locked Core contract (§2.5) evolves to return a `Failed`-shaped `POST` body — not expected under today's contract, which documents only `Queued`. + - **Expected Behavior:** human/JSON failure output per AC2 (108/109), exit 1 — handled defensively as a returned result object, not thrown. The CLI does not fail closed on an unexpected-but-well-formed body. + +6. **`--bundle-dir` source containing dotfiles or dot-directories** + + - **Scenario:** the source directory passed to `--bundle-dir` contains dotfiles (e.g. `.env`, `.DS_Store`, `assets/.hidden`) or dot-directories (e.g. `.git/` with files inside it). + - **Expected Behavior:** the entire dot-directory subtree is excluded (not traversed, not zipped), and dotfiles are excluded at every level of the recursive walk. Sibling non-dotfile files in the same directory are still included. This is filtering (not validation), so no warning or error is emitted — the zip is produced with dotfiles/dot-directories silently omitted. `--zip-file` is unaffected — sent as-is per REQ-112. + +7. **`--bundle-dir` source containing a symlinked file or symlinked directory** + + - **Scenario:** the source directory passed to `--bundle-dir` contains a symlink pointing at a file (e.g. `linked.js -> ../shared/real.js`) or at a directory (e.g. `linked-dir -> ../shared/real-dir`). + - **Expected Behavior:** the recursive directory walk resolves the symlink to its target (via `statSync`, which follows symlinks by default) rather than skipping it. A symlinked file is included in the compressed zip at the symlink's path with its target's content; a symlinked directory is recursed into and its contents included the same as a real directory. This applies at every level of the walk, same as dotfile exclusion (case 6) — but symlinks are resolved, not filtered. + +8. **`--bundle-dir` source containing a dangling/broken symlink** + + - **Scenario:** the source directory passed to `--bundle-dir` contains a symlink whose target does not exist (e.g. deleted after the symlink was created). + - **Expected Behavior:** `statSync` throws `ENOENT` when it attempts to follow the symlink to a nonexistent target. The error propagates and the compression step fails — the CLI does not catch it or silently omit the dangling entry from the zip. This is a deliberate fail-loud choice, not an oversight. + +9. **`--zip-file` literally named `.zip` or `.ZIP`** + + - **Scenario:** the user passes `--zip-file .zip` (or `.ZIP`), a file whose entire base name is the `.zip` extension. + - **Expected Behavior:** the default-name derivation logic strips the `.zip` extension case-insensitively, leaving an empty string. The fallback rule then uses the unstripped filename (`.zip` or `.ZIP`) as the `requestedName` in the multipart `deployRequest` JSON part, rather than sending an empty string (REQ-116). This is a deliberate edge-case fallback, not a bug. + +10. **Resolved API version below the floor of 67** + - **Scenario:** after `flags['target-org'].getConnection(flags['api-version'])` resolves the connection, `orgConnection.getApiVersion()` reports a major version below 67 — whether that resolved value came from an explicit `--api-version 66.0` on the command line, the target-org's own config default, or auto-negotiation with the org. + - **Expected Behavior:** the command throws `UiBundleUploadApiVersionError` immediately after connection resolution, before any zip staging or network call, citing both the resolved version and the floor (§3.2 case 6, AC10 117a). This is a product decision: the check is unconditional on the connection's resolved version, with no special-casing for explicit vs. defaulted flag input. + +### 3.2 Error Handling + +1. **HTTP 4xx/5xx server rejection from the `POST` itself (size/content-type/validation)** + + - **When:** the server synchronously rejects the request — e.g. its early size/content-type check (§2.5) — returning an HTTP error with no job id and no job-shaped body. Caveat: per §2.5's Known Limitations, this size/content-type sub-case is not yet live against the current endpoint — it's a defensive/forward-looking path, kept here for when server-side validation lands. + - **Display:** thrown `UiBundleUploadValidationError` (`SfError` from `@salesforce/core`), server message surfaced verbatim (REQ-111), no rewriting or truncation. + - **Action:** exit 1; no result object emitted. This is the _actual_ synchronous-failure path (REQ-110), distinct from the defensive `Failed` result object (§3.1 case 5 / AC2 108–109). + +2. **Auth failure** + + - **When:** the target org's auth is invalid/expired, or the endpoint rejects the caller. + - **Display:** thrown `UiBundleUploadAuthError`, server message verbatim. + - **Action:** exit 1, no network result object. + +3. **Network failure / no HTTP response** + + - **When:** the `POST` cannot complete (connection refused, timeout, DNS, etc.). + - **Display:** thrown `UiBundleUploadNetworkError`. + - **Action:** exit 1, no network result object. + +4. **Missing or unresolvable required flags** + + - **When:** neither/both of `--zip-file`/`--bundle-dir` supplied → `FailedFlagValidationError` from the `exactlyOne` relationship; `--use-salesforce-pages` omitted → `FailedFlagValidationError` (flag parser); `--target-org` omitted with no default org → `NoDefaultEnvError` (org resolver, distinct mechanism — see `dev.nut.ts:58`). + - **Display:** the framework's flag/org-resolver validation error. + - **Action:** fail before any network call (REQ-102/102b/104/105), exit 1. + +5. **`--bundle-dir` source containing a dangling/broken symlink** + + - **When:** the recursive directory walk (`collectFiles`) calls `statSync` on a symlink whose target does not exist. + - **Display:** `statSync` throws a raw Node.js `ENOENT` filesystem error. Neither `collectFiles` nor `compressDirectory` wraps this in a try/catch, so it is **not** one of the two custom `SfError`s `compressDirectory` throws elsewhere (`error.bundle-dir-empty` for an empty directory, `error.compression-failed` for a missing `writer.buffer`) — it propagates unmodified out of `run()`. It reaches oclif/`sf-plugins-core`'s generic `SfCommand.catch()` handler, which wraps it in a generic `SfCommandError` (name defaults to the raw error's own name, `Error`) and, since the error's `code` is the string `'ENOENT'` rather than a number, resolves the exit code to `1` via the default branch of `computeErrorCode`. + - **Action:** exit 1; the CLI does not catch this and does not silently omit the dangling entry from the zip. Deliberate fail-loud choice (§3.1 case 8), not an oversight. + +6. **Resolved connection API version below the minimum floor** + - **When:** after connection resolution, `orgConnection.getApiVersion()`'s numeric major version is below 67 (the constant `MINIMUM_SUPPORTED_API_VERSION`) — regardless of whether that resolved value traces back to an explicit `--api-version` flag, the target-org's own config default, or auto-negotiation. + - **Display:** thrown `UiBundleUploadApiVersionError`, message citing both the resolved version and the floor (`"Resolved API version 66.0 isn't supported by this command; --api-version must be 67 or later."`). + - **Action:** exit 1, no zip staging or network call made. This check is unconditional on the connection's resolved version — there is no special-casing for explicit vs. defaulted flag input (§3.1 case 10, AC10 117c). + +> **No client-side zip-content validation, ever (REQ-112).** Content safety is a server-side concern (§2.5 server-side validation); the CLI never inspects, unzips, or scans the payload. + +--- + +## 4. Constraints + +- **Repo:** ships inside the existing `plugin-ui-bundle-dev` repo (not a new plugin) to hit a near-term code-check-in deadline. +- **Naming:** the command is `upload`, not `deploy` — `deploy` would collide with `sf project deploy`'s full Metadata-API lifecycle. +- **Dependency:** the Connect API this command calls through to ultimately invokes the server-side `UIBundleCrud.create(UIBundleSource)`. +- **Non-regression is first-class:** `plugin-ui-bundle-dev` is a shared, shipped production plugin, so `upload` must not regress `sf ui-bundle dev` (see §5.2 Non-Regression Checklist). +- **JIT plugin install:** `plugin-ui-bundle-dev` is a just-in-time install — first invocation of `sf ui-bundle upload` triggers automatic plugin installation; there is no pre-install step today (pre-installing, e.g. baked into the CAP workspace image, is a future consideration, not in scope). + +--- + +## 5. Testing Guidelines + +### 5.1 Unit Testing (`upload.test.ts`) + +- [ ] Neither `--zip-file` nor `--bundle-dir` → `FailedFlagValidationError` (exactly-one), no network call. +- [ ] Both `--zip-file` and `--bundle-dir` → `FailedFlagValidationError` (exactly-one), no network call. +- [ ] Missing `--use-salesforce-pages` → `FailedFlagValidationError`, no network call. +- [ ] Missing `--target-org` (no default) → `NoDefaultEnvError` — distinct from the flag-parse cases. +- [ ] Non-existent `--zip-file` path → `Flags.file({ exists: true })` validation error, no network call. +- [ ] Non-existent / not-a-directory `--bundle-dir` path → `Flags.directory({ exists: true })` validation error, no network call. +- [ ] `--bundle-dir` given → CLI compresses the directory (via `@salesforce/source-deploy-retrieve`) and the multipart `bundle` part is a zip identical in shape to the `--zip-file` path. +- [ ] `--bundle-dir` source containing dotfiles/dot-directories → compressed zip excludes them, sibling files still included (REQ-114). +- [ ] `--bundle-dir` source containing a symlinked file and a symlinked directory → compressed zip includes both, resolved to their target content (REQ-115). +- [ ] `--zip-file` given → file sent as-is, no re-compression pass. +- [ ] `--bundle-name my-custom-name` explicitly provided → `requestedName` in the multipart `deployRequest` JSON equals `"my-custom-name"` verbatim (AC9 116a). +- [ ] `--bundle-dir` with no `--bundle-name` → `requestedName` defaults to the directory's base name (AC9 116b). +- [ ] `--zip-file foo.zip` with no `--bundle-name` → `requestedName` defaults to `"foo"` (AC9 116c). +- [ ] `--zip-file .zip` (edge case: entire name is the extension) with no `--bundle-name` → `requestedName` falls back to `".zip"`, not empty (AC9 116d). +- [ ] `--api-version 66.0` explicitly passed → resolved connection's `getApiVersion()` is below the floor, throws `UiBundleUploadApiVersionError` before any zip staging or network call (AC10 117a). +- [ ] `--api-version 67.0` (at the floor) → does not throw, proceeds to `Queued` (AC10 117b). +- [ ] `--api-version` omitted (defaulted) → resolved value passed into `getConnection()`, and the connection's resolved `getApiVersion()` is still checked against the floor unconditionally (AC10 117c/117d). +- [ ] `Queued` response → human success block and `--json` shape (§2.6). +- [ ] `Failed` response (defensive) → human failure block and `--json` shape (§2.6). +- [ ] Each CLI-side `SfError` name asserted: `UiBundleUploadValidationError` / `UiBundleUploadNetworkError` / `UiBundleUploadAuthError` / `UiBundleUploadApiVersionError`. +- [ ] Preview-state warning emitted (`state = 'preview'`) — not suppressed under `--json`'s result payload. +- [ ] No customer-facing output literal is inlined in `upload.ts` — all such output resolves via `messages.getMessage()` per §6.3. +- [ ] Lint, build, and license-header checks clean on all new `.ts` files. + +### 5.2 Integration Testing (`upload.nut.ts`) + +Tiered like `dev.nut.ts` — Tier 1 (`dev.nut.ts:33-71`, no-auth flag-parse checks) and Tier 2 (`dev.nut.ts:72+`, real-org checks). Tier 2 throws if `TESTKIT_AUTH_URL` is unset, matching `dev.nut.ts`'s existing contract — it does not silently skip. + +- [ ] Tier 1: flag-parse / validation cases run without auth — including neither/both of `--zip-file`/`--bundle-dir` (exactly-one) and missing `--use-salesforce-pages`. +- [ ] Tier 2: real-org `POST` path returns and reports a `Queued` job id, for both the `--zip-file` and `--bundle-dir` (auto-compressed) sources. +- [ ] Tier 2 confirmed to throw (not silently skip) when `TESTKIT_AUTH_URL` is unset. +- [ ] `command-snapshot.json` / `COMMANDS.md` show only tool-generated diffs — zero hand-edits. + +**Non-Regression Checklist** — adding `upload` must not touch the existing `dev` command. **Zero diff required** on: + +- [ ] `src/commands/ui-bundle/dev.ts`, `messages/ui-bundle.dev.md`, `schemas/ui__bundle-dev.json` +- [ ] Existing `UiBundleDevResult` export in `src/config/types.ts` +- [ ] Existing `ui-bundle:dev` element in `command-snapshot.json` (`flagChars: ["b","n","o","p","u"]`) — `upload` is appended as a new 2nd element, never mutating the 1st +- [ ] `test/commands/ui-bundle/{dev.test.ts,dev.nut.ts,devPort.nut.ts,devWithUrl.nut.ts}`, and every existing export in `helpers/devServerUtils.ts` / `helpers/uiBundleProjectUtils.ts` +- [ ] `README.md`'s `### sf ui-bundle dev` section + Quick Start/Features prose (new subsection appended after, not interleaved) +- [ ] `package.json`'s `oclif.topics.ui-bundle` block; `src/index.ts` (stays `export default {};`). Note: the `dependencies` block is **not** zero-diff — it gains `@salesforce/source-deploy-retrieve` (§2.4), the one intended `package.json` change; the `oclif.topics` block and existing deps stay untouched. +- [ ] **Test parity:** running the existing `dev` unit suite and `dev`-scoped NUTs post-change produces identical pass/fail results to the pre-change baseline — zero new failures, zero fixed. + +### 5.3 Manual Testing + +Browser/responsive/cross-device checks from the template do not apply — this is a CLI with no browser surface. CLI-appropriate manual verification instead: + +- [ ] Smoke-test `sf ui-bundle upload` against a real org for a `Queued` result, using both a `--zip-file` and a `--bundle-dir` source, and (if reachable) a server-rejected/error case. +- [ ] Eyeball `--json` output against the documented shapes in §2.6. +- [ ] Confirm `--help` output matches the contract documented in §2.4, including the `This command is in preview.` banner. +- [ ] Confirm the developer-preview runtime warning prints on a normal invocation. + +--- + +## 6. Style Guidelines + +### 6.1 Code Comment Guidelines + +Code comments added for this feature follow these rules: + +1. **Implementation-focused, not business-focused.** A comment explains what the code does or the specific technical reason for a choice — never the feature's business rationale, user story, or requirements narrative. +2. **Concise, not verbose.** Prefer a single short line over a multi-line explanation or paragraph. +3. **No requirements/AC/spec references.** Comments must never cite requirement or acceptance-criteria IDs from the spec or plan documents (e.g. `REQ-112`, `AC2`, `§2.5`). State the constraint or reasoning directly so the comment stands on its own. + + ``` + // Don't: no client-side zip validation (REQ-112) + // Do: server validates zip content; client sends the payload as-is + ``` + +### 6.2 Plan Generation Guidelines + +Plan documents generated for this feature follow these rules: + +1. **No references to people, teams, or email addresses, including but not limited to Salesforce employees.** Attribute work to roles or components, never to named individuals, org charts, or contact addresses. +2. **Reference artifacts, not conversations.** Cite files, requirement IDs, and acceptance criteria (`REQ-302`, `AC1`) rather than chat threads, meetings, or verbal decisions, so each plan step stands on its own and stays reproducible. +3. **Every task is falsifiable.** Each plan item names a concrete verification — a command, a test, or a `git diff` check — so completion is objectively checkable rather than a matter of judgment. +4. **Scoped to this release.** Deferred or roadmap work belongs in §7 Out of Scope, not interleaved into plan steps; a plan step describes only in-scope, shippable work. + +### 6.3 Output Message Guidelines + +All customer-facing output messages — whether success text, info lines, or thrown `SfError` message strings — must follow these rules: + +1. **All customer-facing output messages are defined in `messages/ui-bundle.upload.md` and referenced via `messages.getMessage()`.** Never inline customer-facing strings as string literals in the command source. This extends the oclif/sf-plugins-core convention for summaries/descriptions/examples to all runtime output the user sees. +2. **Server/framework-supplied messages surfaced verbatim are pass-through, not authored strings.** A caught `error.message` from the org connection, an HTTP error body, or any other externally-sourced error text is relayed as-is (§2.3 AC3 REQ-111 requires verbatim surfacing) — it is **not** a hardcoded literal and is out of scope for this rule. +3. **Thrown errors are constructed via `messages.createError(key, tokens)`, and the stable machine-readable error name is derived automatically from the message key.** Per `@salesforce/core`'s `Messages.createError()` convention: strip the `error.` prefix from the key, uppercase the first letter, and the remainder of the key must end in a properly-cased literal `Error` — e.g. key `error.uiBundleUploadValidationError` derives the `name` `UiBundleUploadValidationError`. The error name is not passed as a separate literal; it's computed from the key itself, so the message file and the thrown error's name stay consistent. + +The command currently inlines three customer-facing strings that this rule requires moving into `messages/ui-bundle.upload.md`: the empty-bundle-dir `SfError` message (`'The bundle source directory is empty.'` in `compressDirectory`), the compression-failure `SfError` message (`'Failed to compress the bundle source directory.'`), and the `Failed`-status human block (`'Upload failed'` and its `Job ID:` / `Message:` labels) — this is the "Upload failed" text the user specifically called out as residing separately in upload.ts. Note: `messages/ui-bundle.upload.md` already defines unused `# error.*` keys (`error.upload-failed`, `error.auth-failed`, `error.network-failed`, `error.validation-failed`) that the code does not currently reference — the guideline's intent is that authored output routes through such message keys rather than duplicating strings inline. + +--- + +## 7. Out of Scope + +1. **REQ-301.** No `status` command / `GET /connect/ui-bundle/deployments/{jobId}`. → Dreamforce+. +2. **REQ-303.** No `--wait` flag, no client-side polling. → Dreamforce+. +3. **REQ-304.** `--use-salesforce-pages` stays required-boolean, Pages-only — no generic upload semantics. → Dreamforce+ makes it optional. +4. **REQ-305.** No extraction into a shared TypeScript library — lives entirely in `plugin-ui-bundle-dev` for this release. → Acknowledged roadmap item, not merely a deferred maybe: library extraction is on the roadmap for Dreamforce or shortly after, with initial use cases being CLI-specific from agents, and the eventual goal of both a CLI and a library covering all entryways down the line. + +> **Moved into scope — REQ-302.** Previously "No local-directory source, no auto-compression — `--zip-file` only." Now **in scope**: `--bundle-dir` accepts an uncompressed local source directory and the CLI auto-compresses it via `@salesforce/source-deploy-retrieve` before the `POST` (§2.2 item 6, §2.4, §2.6). The REQ id is retained — still referenced by §2.2, §2.4, §2.6, and §3.1 — rather than dropped. diff --git a/COMMANDS.md b/COMMANDS.md index d3d97d9..7561cf2 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -3,6 +3,7 @@ - [`sf ui-bundle dev`](#sf-ui-bundle-dev) +- [`sf ui-bundle upload`](#sf-ui-bundle-upload) ## `sf ui-bundle dev` @@ -64,4 +65,69 @@ EXAMPLES $ SF_LOG_LEVEL=debug sf ui-bundle dev --target-org myorg ``` +## `sf ui-bundle upload` + +Upload a UI Bundle to your org. + +``` +USAGE + $ sf ui-bundle upload --use-salesforce-pages -o [--json] [--flags-dir ] [-z ] [-d ] + [--api-version ] [--bundle-name ] + +FLAGS + -d, --bundle-dir= Path to an uncompressed UI Bundle source directory; the CLI compresses it before upload. + -o, --target-org= (required) Username or alias of the target org. Not required if the `target-org` + configuration variable is already set. + -z, --zip-file= Path to the UI Bundle source to upload. + --api-version= Override the api version used for api requests made by this command + --bundle-name= Name to associate with the uploaded UI Bundle. + --use-salesforce-pages (required) Toggle whether this UI Bundle should be uploaded to Salesforce Pages. + +GLOBAL FLAGS + --flags-dir= Import flag values from a directory. + --json Format output as json. + +DESCRIPTION + Upload a UI Bundle to your org. + + Use this command to upload a React-based UI Bundle to your Salesforce org. Provide the bundle source as either a + compressed ZIP file (--zip-file) or an uncompressed source directory (--bundle-dir), which the CLI compresses for you. + This can be used by both admin and non-admin users. + + The upload is asynchronous. View the UI bundle in your org to verify completion. + +EXAMPLES + Upload a UI Bundle to Salesforce Pages using your default org: + + $ sf ui-bundle upload --zip-file my-compressed-bundle --use-salesforce-pages + + Upload an uncompressed source directory (auto-compressed by the CLI): + + $ sf ui-bundle upload --bundle-dir ./my-bundle-src --use-salesforce-pages + + Upload to a specific org by alias: + + $ sf ui-bundle upload --zip-file my-compressed-bundle --use-salesforce-pages --target-org my-org + +FLAG DESCRIPTIONS + -d, --bundle-dir= Path to an uncompressed UI Bundle source directory; the CLI compresses it before upload. + + The path to an uncompressed directory containing the UI Bundle source. The CLI compresses the directory into a ZIP + file before uploading. The CLI doesn't validate the contents of the directory — that's a server-side concern. + + -z, --zip-file= Path to the UI Bundle source to upload. + + The path to a compressed ZIP file containing the UI Bundle source. The CLI doesn't validate the contents of the zip + file — that's a server-side concern. + + --bundle-name= Name to associate with the uploaded UI Bundle. + + A human-readable name for the UI Bundle. If not specified, defaults to the base name of --bundle-dir or --zip-file, + with any .zip extension removed. + + --use-salesforce-pages Toggle whether this UI Bundle should be uploaded to Salesforce Pages. + + When set, the UI Bundle is uploaded for use with Salesforce Pages. +``` + diff --git a/README.md b/README.md index 3f454d4..3f15444 100644 --- a/README.md +++ b/README.md @@ -173,4 +173,41 @@ SEE ALSO - Complete Guide: SF_UI_BUNDLE_DEV_GUIDE.md ``` +### `sf ui-bundle upload` + +Upload a UI Bundle to your org. + +```bash +USAGE + $ sf ui-bundle upload (--zip-file | --bundle-dir ) --use-salesforce-pages --target-org + +BUNDLE SOURCE (exactly one required) + -z, --zip-file= Path to a pre-built UI Bundle ZIP file, sent as-is + -d, --bundle-dir= Path to an uncompressed UI Bundle source directory; the CLI compresses it before upload + +REQUIRED FLAGS + --use-salesforce-pages Toggle whether this UI Bundle should be uploaded to Salesforce Pages + -o, --target-org= Salesforce org to authenticate against + +DESCRIPTION + Uploads a React-based UI Bundle to your Salesforce org. Provide the bundle + source as either a compressed ZIP file (--zip-file) or an uncompressed source + directory (--bundle-dir), which the CLI compresses for you. The upload is + asynchronous — the command returns a job ID immediately; view the UI bundle in + your org to verify completion. Can be used by both admin and non-admin users. + +EXAMPLES + Upload a UI Bundle to Salesforce Pages using your default org: + + $ sf ui-bundle upload --zip-file my-compressed-bundle --use-salesforce-pages + + Upload an uncompressed source directory (auto-compressed by the CLI): + + $ sf ui-bundle upload --bundle-dir ./my-bundle-src --use-salesforce-pages + + Upload to a specific org by alias: + + $ sf ui-bundle upload --zip-file my-compressed-bundle --use-salesforce-pages --target-org my-org +``` + diff --git a/command-snapshot.json b/command-snapshot.json index 3bdb1a4..5aaf303 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -6,5 +6,22 @@ "flagChars": ["b", "n", "o", "p", "u"], "flags": ["flags-dir", "json", "name", "open", "port", "target-org", "url"], "plugin": "@salesforce/plugin-ui-bundle-dev" + }, + { + "alias": [], + "command": "ui-bundle:upload", + "flagAliases": [], + "flagChars": ["d", "o", "z"], + "flags": [ + "api-version", + "bundle-dir", + "bundle-name", + "flags-dir", + "json", + "target-org", + "use-salesforce-pages", + "zip-file" + ], + "plugin": "@salesforce/plugin-ui-bundle-dev" } ] diff --git a/messages/ui-bundle.upload.md b/messages/ui-bundle.upload.md new file mode 100644 index 0000000..b54e6a1 --- /dev/null +++ b/messages/ui-bundle.upload.md @@ -0,0 +1,77 @@ +# summary + +Upload a UI Bundle to your org. + +# description + +Use this command to upload a React-based UI Bundle to your Salesforce org. Provide the bundle source as either a compressed ZIP file (--zip-file) or an uncompressed source directory (--bundle-dir). This command compresses the directory for you. This command can be used by both admin and non-admin users. + +The upload is asynchronous. View the UI bundle in your org to verify upload completion. + +# flags.zip-file.summary + +Path to the compressed UI Bundle source to upload. + +# flags.bundle-dir.summary + +Path to an uncompressed UI Bundle source directory. This command compresses the directory into a ZIP file before uploading. + +# flags.use-salesforce-pages.summary + +Upload UI Bundle to Salesforce Pages. This is a required flag as only Salesforce Pages uploads are currently supported. + +# flags.bundle-name.summary + +Name to associate with the uploaded UI Bundle. + +# flags.bundle-name.description + +A human-readable name for the UI Bundle. If not specified, defaults to the base name of --bundle-dir or --zip-file, with any .zip extension removed. + +# examples + +- Upload a UI Bundle to Salesforce Pages using your default org: + + <%= config.bin %> <%= command.id %> --zip-file my-compressed-bundle.zip --use-salesforce-pages + +- Upload an uncompressed source directory (auto-compressed by the CLI): + + <%= config.bin %> <%= command.id %> --bundle-dir ./my-bundle-src --use-salesforce-pages + +- Upload to a specific org by alias: + + <%= config.bin %> <%= command.id %> --zip-file my-compressed-bundle.zip --use-salesforce-pages --target-org my-org + +# info.upload-queued + +Upload queued successfully. + +# info.job-id + +Job ID: %s + +# error.upload-failed + +Upload failed + Job ID: %s + Message: %s + +# error.bundle-dir-empty + +The bundle source directory is empty. + +# error.uiBundleUploadApiVersionError + +API version %s isn't supported by this command; --api-version must be %s or later. + +# error.uiBundleUploadAuthError + +Authentication error: %s + +# error.uiBundleUploadNetworkError + +Network error: %s + +# error.uiBundleUploadValidationError + +Validation error: %s diff --git a/package.json b/package.json index 1f7e8f9..e8af840 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "@salesforce/sf-plugins-core": "^12.2.6", "@salesforce/ui-bundle": "^1.118.4", "chokidar": "^3.6.0", + "form-data": "^4.0.5", "http-proxy": "^1.18.1", + "jszip": "^3.10.1", "micromatch": "^4.0.8", "open": "^10.1.0", "path-to-regexp": "^8.3.0" diff --git a/schemas/ui__bundle-upload.json b/schemas/ui__bundle-upload.json new file mode 100644 index 0000000..7207b5b --- /dev/null +++ b/schemas/ui__bundle-upload.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/UiBundleUploadResult", + "definitions": { + "UiBundleUploadResult": { + "type": "object", + "properties": { + "jobId": { + "type": "string", + "description": "ID of the enqueued upload job" + }, + "status": { + "type": "string", + "enum": ["Queued", "InProgress", "Succeeded", "Failed"], + "description": "Status of the upload job" + }, + "message": { + "type": "string", + "description": "Server-provided message, present on Failed status" + } + }, + "required": ["jobId", "status"], + "additionalProperties": false, + "description": "Command execution result" + } + } +} diff --git a/src/commands/ui-bundle/upload.ts b/src/commands/ui-bundle/upload.ts new file mode 100644 index 0000000..e62bb81 --- /dev/null +++ b/src/commands/ui-bundle/upload.ts @@ -0,0 +1,179 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { basename, join, relative, sep } from 'node:path'; +import FormData from 'form-data'; +import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; +import { Messages } from '@salesforce/core'; +import JSZip from 'jszip'; +import type { UiBundleUploadResult } from '../../config/types.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-ui-bundle-dev', 'ui-bundle.upload'); + +// Versions below this floor aren't supported by the UI Bundle deploy endpoint. +const MINIMUM_SUPPORTED_API_VERSION = 67; + +/** Recursively collect absolute paths of every file under a directory. */ +function collectFiles(root: string): string[] { + const files: string[] = []; + for (const name of readdirSync(root)) { + // Skip dotfiles and dot-directories (e.g. .env, .git) — never bundled. + if (name.startsWith('.')) continue; + const full = join(root, name); + // statSync follows symlinks to their target, so symlinked files/dirs are bundled correctly. + const stat = statSync(full); + if (stat.isDirectory()) files.push(...collectFiles(full)); + else if (stat.isFile()) files.push(full); + } + return files; +} + +/** Compress a source directory into a zip Buffer using jszip. */ +async function compressDirectory(dir: string): Promise { + const zip = new JSZip(); + let fileCount = 0; + for (const file of collectFiles(dir)) { + // Entry paths inside a zip are always posix; normalize Windows separators. + const entryPath = relative(dir, file).split(sep).join('/'); + zip.file(entryPath, readFileSync(file)); + fileCount++; + } + // An empty directory produces no zip entries; reject rather than POST an empty bundle. + if (fileCount === 0) { + throw messages.createError('error.uiBundleUploadValidationError', [messages.getMessage('error.bundle-dir-empty')]); + } + // JSZip's generateAsync resolves with a Buffer or rejects; no silent-failure path exists. + return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 3 } }); +} + +export default class UiBundleUpload extends SfCommand { + public static readonly state = 'preview'; + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + + public static readonly flags = { + 'zip-file': Flags.file({ + summary: messages.getMessage('flags.zip-file.summary'), + char: 'z', + exists: true, + exactlyOne: ['zip-file', 'bundle-dir'], + }), + 'bundle-dir': Flags.directory({ + summary: messages.getMessage('flags.bundle-dir.summary'), + char: 'd', + exists: true, + exactlyOne: ['zip-file', 'bundle-dir'], + }), + 'use-salesforce-pages': Flags.boolean({ + summary: messages.getMessage('flags.use-salesforce-pages.summary'), + required: true, + }), + 'target-org': Flags.requiredOrg(), + 'api-version': Flags.orgApiVersion(), + 'bundle-name': Flags.string({ + summary: messages.getMessage('flags.bundle-name.summary'), + description: messages.getMessage('flags.bundle-name.description'), + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(UiBundleUpload); + + // Step 1: Resolve the org connection. + const orgConnection = flags['target-org'].getConnection(flags['api-version']); + + // Check the connection's resolved API version (explicit flag, org-config default, or auto-negotiated) + // against the floor before doing any zip staging or network work. + const apiVersion = parseInt(orgConnection.getApiVersion(), 10); + if (apiVersion < MINIMUM_SUPPORTED_API_VERSION) { + throw messages.createError('error.uiBundleUploadApiVersionError', [ + orgConnection.getApiVersion(), + String(MINIMUM_SUPPORTED_API_VERSION), + ]); + } + + // Step 2: Stage the zip. Contents are never validated here; that's a server-side concern. + // --bundle-dir is compressed on the fly; --zip-file is read and sent as-is. + const bundleDir = flags['bundle-dir']; + let zipBuffer: Buffer; + let zipFilename: string; + if (bundleDir) { + zipBuffer = await compressDirectory(bundleDir); + zipFilename = `${basename(bundleDir)}.zip`; + } else { + const zipFile = flags['zip-file']!; + zipBuffer = readFileSync(zipFile); + zipFilename = basename(zipFile); + } + + // Defaults to the bundle source's base name (zip extension stripped) when --bundle-name is omitted. + // Falls back to the unstripped filename if stripping would leave an empty string (e.g. a file literally named ".zip"). + const strippedZipFilename = zipFilename.replace(/\.zip$/i, ''); + const bundleName = flags['bundle-name'] ?? (strippedZipFilename || zipFilename); + + // Step 3: Build the multipart body and issue a single synchronous POST, no retry/poll loop. + // We send form.getBuffer() (the fully-assembled multipart Buffer) since jsforce's instanceof FormData check fails across differing form-data module copies. + const form = new FormData(); + form.append('deployRequest', JSON.stringify({ requestedName: bundleName }), { contentType: 'application/json' }); + form.append('bundle', zipBuffer, { filename: zipFilename }); + + let response: { jobId: string; status: string; message?: string }; + try { + response = await orgConnection.request<{ jobId: string; status: string; message?: string }>({ + method: 'POST', + url: `${orgConnection.baseUrl()}/connect/ui-bundle/deployments`, + body: form.getBuffer(), + headers: form.getHeaders(), + }); + } catch (error) { + // jsforce marks a bad/expired session with these codes or this refresh-failure message; anything else with an errorCode is a server-side rejection, otherwise it never reached the server. + const errorMessage = error instanceof Error ? error.message : String(error); + const errorCode = + error && typeof error === 'object' && 'errorCode' in error ? String(error.errorCode) : undefined; + if (errorCode && ['INVALID_SESSION_ID', 'ERROR_HTTP_401', 'ERROR_HTTP_403'].includes(errorCode)) { + throw messages.createError('error.uiBundleUploadAuthError', [errorMessage]); + } + if (errorMessage.startsWith('Unable to refresh session due to:')) { + throw messages.createError('error.uiBundleUploadAuthError', [errorMessage]); + } + if (errorCode) { + throw messages.createError('error.uiBundleUploadValidationError', [errorMessage]); + } + throw messages.createError('error.uiBundleUploadNetworkError', [errorMessage]); + } + + // Step 4: Map the response. The server is only expected to return `Queued`; `Failed` is handled defensively. + if (response.status === 'Failed') { + // logToStderr, like log, is a no-op under --json. + this.logToStderr(messages.getMessage('error.upload-failed', [response.jobId, response.message ?? ''])); + // oclif has no built-in way to set a non-zero exit for a returned (non-thrown) result, so set it manually. + process.exitCode = 1; + return { jobId: response.jobId, status: 'Failed', message: response.message }; + } + + if (response.status === 'Queued') { + this.log(messages.getMessage('info.upload-queued')); + this.log(messages.getMessage('info.job-id', [response.jobId])); + return { jobId: response.jobId, status: 'Queued' }; + } + + // Any other status (InProgress/Succeeded) isn't reachable from this single synchronous POST. + return response as UiBundleUploadResult; + } +} diff --git a/src/config/types.ts b/src/config/types.ts index 168df2e..293f3c4 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -30,6 +30,18 @@ export type UiBundleDevResult = { devServerUrl: string; }; +/** + * Command execution result + */ +export type UiBundleUploadResult = { + /** ID of the enqueued upload job */ + jobId: string; + /** Status of the upload job */ + status: 'Queued' | 'InProgress' | 'Succeeded' | 'Failed'; + /** Server-provided message, present on Failed status */ + message?: string; +}; + /** * Dev server configuration options * Options for starting and managing the dev server process diff --git a/test/commands/ui-bundle/helpers/uiBundleProjectUtils.ts b/test/commands/ui-bundle/helpers/uiBundleProjectUtils.ts index caf98a2..b76db29 100644 --- a/test/commands/ui-bundle/helpers/uiBundleProjectUtils.ts +++ b/test/commands/ui-bundle/helpers/uiBundleProjectUtils.ts @@ -193,3 +193,26 @@ export function createProjectWithDevServer( return { projectDir, uiBundleDir }; } + +/** + * Create a placeholder zip fixture for `ui-bundle upload` NUTs. Written inside + * the session dir so the session's own cleanup sweep removes it automatically. + */ +export function createZipFixture(session: TestSession, fileName = 'ui-bundle.zip'): string { + const zipPath = join(session.dir, fileName); + writeFileSync(zipPath, Buffer.from([0x50, 0x4b, 0x03, 0x04])); + return zipPath; +} + +/** + * Create an uncompressed UI Bundle source directory for `ui-bundle upload` + * `--bundle-dir` NUTs. Written inside the session dir so the session's own + * cleanup sweep removes it automatically. Returns the directory path. + */ +export function createBundleDirFixture(session: TestSession, dirName = 'ui-bundle-src'): string { + const dir = join(session.dir, dirName); + mkdirSync(join(dir, 'src'), { recursive: true }); + writeFileSync(join(dir, 'index.html'), ''); + writeFileSync(join(dir, 'src', 'app.js'), 'console.log("hi");'); + return dir; +} diff --git a/test/commands/ui-bundle/upload.nut.ts b/test/commands/ui-bundle/upload.nut.ts new file mode 100644 index 0000000..a022fb1 --- /dev/null +++ b/test/commands/ui-bundle/upload.nut.ts @@ -0,0 +1,175 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit'; +import { expect } from 'chai'; +import { createZipFixture, createBundleDirFixture, authOrgViaUrl } from './helpers/uiBundleProjectUtils.js'; + +/* ------------------------------------------------------------------ * + * Tier 1 — No Auth * + * * + * Validates flag-level parse errors that fire before any org or * + * network interaction. No credentials needed; always runs. * + * ------------------------------------------------------------------ */ +describe('ui-bundle upload NUTs — Tier 1 (no auth)', () => { + let session: TestSession; + + before(async () => { + session = await TestSession.create({ devhubAuthStrategy: 'NONE' }); + }); + + after(async () => { + await session?.clean(); + }); + + // --target-org is declared as Flags.requiredOrg(). Running without it + // must fail at parse time with NoDefaultEnvError before any other logic, + // mirroring dev.nut.ts:52-60. Without a resolvable org, requiredOrg's default + // resolution throws before the exactly-one / required-flag validations run — + // so those parse checks are exercised in Tier 2 (below), where an org resolves. + it('should require --target-org', () => { + const zipPath = createZipFixture(session); + + const result = execCmd(`ui-bundle upload --zip-file ${zipPath} --use-salesforce-pages --json`, { + ensureExitCode: 1, + cwd: session.dir, + }); + + expect(result.jsonOutput?.name).to.equal('NoDefaultEnvError'); + expect(result.jsonOutput?.message).to.include('target-org'); + }); +}); + +/* ------------------------------------------------------------------ * + * Tier 2 — Real Org * + * * + * Exercises the real POST /connect/ui-bundle/deployments call * + * against a live org. Requires TESTKIT_AUTH_URL. Fails when absent * + * (mandatory, not silently skipped), matching dev.nut.ts:76-85. * + * ------------------------------------------------------------------ */ +describe('ui-bundle upload NUTs — Tier 2 (real org)', () => { + let session: TestSession; + let targetOrg: string; + + before(async () => { + if (!process.env.TESTKIT_AUTH_URL) { + throw new Error( + 'TESTKIT_AUTH_URL is required for Tier 2 tests. Set it in .env (local) or CI secrets (GitHub Actions).' + ); + } + + session = await TestSession.create({ devhubAuthStrategy: 'NONE' }); + targetOrg = authOrgViaUrl(); + }); + + after(async () => { + await session?.clean(); + }); + + // Flag-parse checks that need a resolvable org: requiredOrg's default + // resolution runs during parse, so the exactly-one / required-flag + // validations are only reachable once --target-org resolves. + + // Neither bundle-source flag → exactly-one relationship fails at parse time. + it('should require exactly one of --zip-file / --bundle-dir (neither given)', () => { + // oclif exits 2 for FailedFlagValidationError (flag-parse errors), distinct from the runtime NoDefaultEnvError exit-1 case in Tier 1. + const result = execCmd(`ui-bundle upload --use-salesforce-pages --target-org ${targetOrg} --json`, { + ensureExitCode: 2, + cwd: session.dir, + }); + + expect(result.jsonOutput?.message).to.include('Exactly one of the following must be provided'); + }); + + // Both bundle-source flags → exactly-one relationship fails at parse time. + it('should reject both --zip-file and --bundle-dir together', () => { + const zipPath = createZipFixture(session); + const bundleDir = createBundleDirFixture(session); + + const result = execCmd( + `ui-bundle upload --zip-file ${zipPath} --bundle-dir ${bundleDir} --use-salesforce-pages --target-org ${targetOrg} --json`, + { + ensureExitCode: 2, + cwd: session.dir, + } + ); + + expect(result.jsonOutput?.message).to.include('cannot also be provided when using'); + }); + + // --use-salesforce-pages is required; omitting it fails at parse time. + it('should require --use-salesforce-pages', () => { + const zipPath = createZipFixture(session); + + const result = execCmd(`ui-bundle upload --zip-file ${zipPath} --target-org ${targetOrg} --json`, { + ensureExitCode: 2, + cwd: session.dir, + }); + + expect(result.jsonOutput?.message).to.include('Missing required flag'); + expect(result.jsonOutput?.message).to.include('use-salesforce-pages'); + }); + + // Real-org call: POST /connect/ui-bundle/deployments with a placeholder zip. + // Requires the endpoint to be deployed on the target org; runs only when + // TESTKIT_AUTH_URL opts into a real connection. + it('should upload a UI Bundle and return a Queued job id (--zip-file)', function () { + // POST /connect/ui-bundle/deployments is grounded in merged Core source on feature branch + // p/salesforce-pages/262-develop (API v62.0), still subject to change before GA, and returns + // 202 { jobId, status: "Queued" }. Not yet deployed on the integration org — it returns 404. + // Guard behind UI_BUNDLE_UPLOAD_ENDPOINT_LIVE until the endpoint ships; set it to re-enable this test. + if (!process.env.UI_BUNDLE_UPLOAD_ENDPOINT_LIVE) { + this.skip(); + } + + const zipPath = createZipFixture(session); + + const result = execCmd( + `ui-bundle upload --zip-file ${zipPath} --use-salesforce-pages --target-org ${targetOrg} --json`, + { + ensureExitCode: 0, + cwd: session.dir, + } + ); + + expect(result.jsonOutput?.result).to.have.property('status', 'Queued'); + expect(result.jsonOutput?.result).to.have.property('jobId'); + }); + + // Real-org call with an uncompressed source directory the CLI compresses. + it('should upload a UI Bundle and return a Queued job id (--bundle-dir, auto-compressed)', function () { + // POST /connect/ui-bundle/deployments is grounded in merged Core source on feature branch + // p/salesforce-pages/262-develop (API v62.0), still subject to change before GA, and returns + // 202 { jobId, status: "Queued" }. Not yet deployed on the integration org — it returns 404. + // Guard behind UI_BUNDLE_UPLOAD_ENDPOINT_LIVE until the endpoint ships; set it to re-enable this test. + if (!process.env.UI_BUNDLE_UPLOAD_ENDPOINT_LIVE) { + this.skip(); + } + + const bundleDir = createBundleDirFixture(session); + + const result = execCmd( + `ui-bundle upload --bundle-dir ${bundleDir} --use-salesforce-pages --target-org ${targetOrg} --json`, + { + ensureExitCode: 0, + cwd: session.dir, + } + ); + + expect(result.jsonOutput?.result).to.have.property('status', 'Queued'); + expect(result.jsonOutput?.result).to.have.property('jobId'); + }); +}); diff --git a/test/commands/ui-bundle/upload.test.ts b/test/commands/ui-bundle/upload.test.ts new file mode 100644 index 0000000..afa7574 --- /dev/null +++ b/test/commands/ui-bundle/upload.test.ts @@ -0,0 +1,688 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { expect } from 'chai'; +import { TestContext, MockTestOrgData } from '@salesforce/core/testSetup'; +import { Messages, Connection } from '@salesforce/core'; +import { stubSfCommandUx } from '@salesforce/sf-plugins-core'; +import JSZip from 'jszip'; +import UiBundleUpload from '../../../src/commands/ui-bundle/upload.js'; +import type { UiBundleUploadResult } from '../../../src/config/types.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-ui-bundle-dev', 'ui-bundle.upload'); + +/** + * Create a placeholder zip fixture. Content is never inspected client-side, + * so any bytes satisfy `Flags.file({ exists: true })`'s existence check. + */ +function createZipFixture(): string { + const zipPath = join(tmpdir(), `upload-test-${Date.now()}-${Math.random().toString(36).slice(2)}.zip`); + writeFileSync(zipPath, Buffer.from([0x50, 0x4b, 0x03, 0x04])); + return zipPath; +} + +/** + * Materialize an uncompressed source directory for the `--bundle-dir` path. + * A couple of nested files are enough to exercise SDR's recursive compression. + */ +function createBundleDirFixture(): string { + const dir = mkdtempSync(join(tmpdir(), 'upload-test-dir-')); + mkdirSync(join(dir, 'src')); + writeFileSync(join(dir, 'index.html'), ''); + writeFileSync(join(dir, 'src', 'app.js'), 'console.log("hi");'); + return dir; +} + +/** + * Materialize an uncompressed source directory with dotfiles and dot-directories. + * Used to verify the dotfile filter path. + */ +function createBundleDirWithDotfilesFixture(): string { + const dir = mkdtempSync(join(tmpdir(), 'upload-test-dir-dotfiles-')); + mkdirSync(join(dir, 'src')); + mkdirSync(join(dir, '.git')); + writeFileSync(join(dir, 'index.html'), ''); + writeFileSync(join(dir, 'src', 'app.js'), 'console.log("hi");'); + writeFileSync(join(dir, '.env'), 'SECRET=value'); + writeFileSync(join(dir, 'src', '.hidden'), 'hidden content'); + writeFileSync(join(dir, '.git', 'config'), '[core]'); + return dir; +} + +/** + * Materialize an uncompressed source directory containing a symlinked file and a + * symlinked directory, alongside real entries. Used to verify that `collectFiles` + * follows symlinks (via `statSync`) rather than skipping them (via `Dirent` checks). + * + * Returns `undefined` if symlink creation isn't permitted in this environment (e.g. + * unprivileged Windows CI without Developer Mode), so callers can skip the test. + */ +function createBundleDirWithSymlinksFixture(): string | undefined { + const dir = mkdtempSync(join(tmpdir(), 'upload-test-dir-symlinks-')); + mkdirSync(join(dir, 'src')); + writeFileSync(join(dir, 'index.html'), ''); + writeFileSync(join(dir, 'src', 'app.js'), 'console.log("hi");'); + + // A real file living outside the bundle dir, targeted by a symlink inside it. + const realTargetDir = mkdtempSync(join(tmpdir(), 'upload-test-real-target-')); + const realTargetFile = join(realTargetDir, 'real-target.js'); + writeFileSync(realTargetFile, 'console.log("linked file");'); + + // A real subdirectory living outside the bundle dir, targeted by a symlinked directory inside it. + const realSubdir = join(realTargetDir, 'real-subdir'); + mkdirSync(realSubdir); + writeFileSync(join(realSubdir, 'nested.js'), 'console.log("linked dir");'); + + try { + symlinkSync(realTargetFile, join(dir, 'linked.js'), 'file'); + symlinkSync(realSubdir, join(dir, 'linked-dir'), 'dir'); + } catch (e) { + const err = e as NodeJS.ErrnoException; + // Unprivileged Windows runners (no Developer Mode/admin) reject symlink creation. + if (err.code === 'EPERM' || err.code === 'ENOSYS') { + return undefined; + } + throw e; + } + + return dir; +} + +/** Read the full multipart body (with the `bundle` part embedded) from a captured request. */ +function bundleBufferFromRequest(request: unknown): Buffer { + // The request body is now the fully-assembled multipart Buffer (form.getBuffer()). + return (request as { body: Buffer }).body; +} + +/** The local zip-file signature — every zip stream starts with these 4 bytes. */ +const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]); + +/** Marker for the `deployRequest` multipart part's Content-Disposition header. */ +const DEPLOY_REQUEST_DISPOSITION = 'Content-Disposition: form-data; name="deployRequest"'; + +describe('ui-bundle:upload command unit tests', () => { + const $$ = new TestContext(); + + // Default resolved API version for every stubbed connection in this file. @salesforce/core's + // test harness hardcodes the mocked `/services/data` response — used by `retrieveMaxApiVersion` + // during `Connection.create()` — to `{ version: '42.0' }` (see `stubContext` in + // node_modules/@salesforce/core/lib/testSetup.js). That's below MINIMUM_SUPPORTED_API_VERSION (67) + // and isn't reachable via `$$.fakeConnectionRequest` (the hardcoded case short-circuits before + // that hook runs). Stub `useLatestApiVersion` — the step `Connection.create()` runs before the + // command ever calls `getApiVersion()` — so a connection resolves to a supported version by + // default; tests exercising response mapping, dotfile filtering, symlinks, etc. never touch the + // floor check. A test that needs a specific *resolved* (as opposed to explicitly-flagged) version + // reassigns `resolvedApiVersion` before invoking the command. Explicit `--api-version` flags still + // take priority: `Org.getConnection(apiVersion)` calls the real `setApiVersion` afterward, which + // this stub doesn't touch. + let resolvedApiVersion = '67.0'; + + beforeEach(() => { + resolvedApiVersion = '67.0'; + $$.SANDBOX.stub(Connection.prototype, 'useLatestApiVersion').callsFake(async function (this: Connection) { + this.setApiVersion(resolvedApiVersion); + }); + }); + + afterEach(() => { + $$.restore(); + }); + + /* ------------------------------------------------------------------ * + * Flag-validation-only cases — fail during this.parse(), before any * + * org resolution or network interaction. No connection stubbing. * + * ------------------------------------------------------------------ */ + describe('flag validation (no network interaction)', () => { + it('neither --zip-file nor --bundle-dir -> FailedFlagValidationError (exactly-one), no network call', async () => { + const testOrg = new MockTestOrgData(); + await $$.stubAuths(testOrg); + const requestStub = $$.SANDBOX.stub(); + $$.fakeConnectionRequest = requestStub; + stubSfCommandUx($$.SANDBOX); + + try { + await UiBundleUpload.run(['--use-salesforce-pages', '--target-org', testOrg.username], import.meta.url); + expect.fail('should have thrown'); + } catch (e) { + const err = e as Error & { message: string; cause?: Error }; + // Flag order in the message isn't stable; assert on the prefix and both names. + expect(err.message).to.include('Exactly one of the following must be provided'); + expect(err.message).to.include('--zip-file'); + expect(err.message).to.include('--bundle-dir'); + // SfCommand wraps the thrown error in a generic Error; the original class survives as `cause`. + expect(err.cause?.constructor.name).to.equal('FailedFlagValidationError'); + } + expect(requestStub.called).to.be.false; + }); + + it('both --zip-file and --bundle-dir -> FailedFlagValidationError (exactly-one), no network call', async () => { + const testOrg = new MockTestOrgData(); + await $$.stubAuths(testOrg); + const requestStub = $$.SANDBOX.stub(); + $$.fakeConnectionRequest = requestStub; + stubSfCommandUx($$.SANDBOX); + const zipPath = createZipFixture(); + const bundleDir = createBundleDirFixture(); + + try { + await UiBundleUpload.run( + [ + '--zip-file', + zipPath, + '--bundle-dir', + bundleDir, + '--use-salesforce-pages', + '--target-org', + testOrg.username, + ], + import.meta.url + ); + expect.fail('should have thrown'); + } catch (e) { + const err = e as Error & { message: string; cause?: Error }; + // Message wording depends on parse order; assert on the stable prefix. + expect(err.message).to.include('cannot also be provided when using'); + expect(err.cause?.constructor.name).to.equal('FailedFlagValidationError'); + } + expect(requestStub.called).to.be.false; + }); + + it('missing --use-salesforce-pages -> FailedFlagValidationError, no network call', async () => { + const testOrg = new MockTestOrgData(); + await $$.stubAuths(testOrg); + const requestStub = $$.SANDBOX.stub(); + $$.fakeConnectionRequest = requestStub; + stubSfCommandUx($$.SANDBOX); + const zipPath = createZipFixture(); + + try { + await UiBundleUpload.run(['--zip-file', zipPath, '--target-org', testOrg.username], import.meta.url); + expect.fail('should have thrown'); + } catch (e) { + const err = e as Error & { message: string; cause?: Error }; + expect(err.message).to.include('Missing required flag use-salesforce-pages'); + expect(err.cause?.constructor.name).to.equal('FailedFlagValidationError'); + } + expect(requestStub.called).to.be.false; + }); + + it('missing --target-org with no default -> NoDefaultEnvError', async () => { + const requestStub = $$.SANDBOX.stub(); + $$.fakeConnectionRequest = requestStub; + stubSfCommandUx($$.SANDBOX); + const zipPath = createZipFixture(); + + try { + await UiBundleUpload.run(['--zip-file', zipPath, '--use-salesforce-pages'], import.meta.url); + expect.fail('should have thrown'); + } catch (e) { + const err = e as Error & { name: string; message: string }; + expect(err.name).to.equal('NoDefaultEnvError'); + expect(err.message).to.include('target-org'); + } + expect(requestStub.called).to.be.false; + }); + + it('non-existent --zip-file path -> file-existence validation error, no network call', async () => { + const testOrg = new MockTestOrgData(); + await $$.stubAuths(testOrg); + const requestStub = $$.SANDBOX.stub(); + $$.fakeConnectionRequest = requestStub; + stubSfCommandUx($$.SANDBOX); + const nonExistentPath = join(tmpdir(), `does-not-exist-${Date.now()}.zip`); + + try { + await UiBundleUpload.run( + ['--zip-file', nonExistentPath, '--use-salesforce-pages', '--target-org', testOrg.username], + import.meta.url + ); + expect.fail('should have thrown'); + } catch (e) { + const err = e as Error & { name: string; message: string }; + expect(err.message).to.include(`No file found at ${nonExistentPath}`); + } + expect(requestStub.called).to.be.false; + }); + + it('non-existent --bundle-dir path -> directory-existence validation error, no network call', async () => { + const testOrg = new MockTestOrgData(); + await $$.stubAuths(testOrg); + const requestStub = $$.SANDBOX.stub(); + $$.fakeConnectionRequest = requestStub; + stubSfCommandUx($$.SANDBOX); + const nonExistentDir = join(tmpdir(), `does-not-exist-dir-${Date.now()}`); + + try { + await UiBundleUpload.run( + ['--bundle-dir', nonExistentDir, '--use-salesforce-pages', '--target-org', testOrg.username], + import.meta.url + ); + expect.fail('should have thrown'); + } catch (e) { + const err = e as Error & { name: string; message: string }; + expect(err.message).to.include(nonExistentDir); + } + expect(requestStub.called).to.be.false; + }); + }); + + /* ------------------------------------------------------------------ * + * Response-mapping / SfError-name cases — exercise the org * + * resolution + connection.request() path via a stubbed connection. * + * ------------------------------------------------------------------ */ + describe('response mapping and error semantics (stubbed connection)', () => { + let testOrg: MockTestOrgData; + let zipPath: string; + + beforeEach(async () => { + testOrg = new MockTestOrgData(); + await $$.stubAuths(testOrg); + zipPath = createZipFixture(); + }); + + it('Queued response -> correct return value and human log calls', async () => { + const requestStub = $$.SANDBOX.stub().resolves({ jobId: '0BXxx0000000001', status: 'Queued' }); + $$.fakeConnectionRequest = requestStub; + const uxStubs = stubSfCommandUx($$.SANDBOX); + + const result = await UiBundleUpload.run( + ['--zip-file', zipPath, '--use-salesforce-pages', '--target-org', testOrg.username], + import.meta.url + ); + + expect(result).to.deep.equal({ jobId: '0BXxx0000000001', status: 'Queued' } as UiBundleUploadResult); + expect(requestStub.calledOnce).to.be.true; + expect(uxStubs.log.args.flat()).to.deep.include('Upload queued successfully.'); + expect(uxStubs.log.args.flat()).to.deep.include('Job ID: 0BXxx0000000001'); + expect(uxStubs.logToStderr.called).to.be.false; + + // The multipart body includes a `deployRequest` JSON part alongside the `bundle` part. + const sent = bundleBufferFromRequest(requestStub.firstCall.args[0]).toString('utf8'); + expect(sent).to.include(DEPLOY_REQUEST_DISPOSITION); + // requestedName defaults to the zip fixture's base name with the .zip extension stripped. + const expectedName = basename(zipPath).replace(/\.zip$/i, ''); + expect(sent).to.include(`Content-Type: application/json\r\n\r\n{"requestedName":"${expectedName}"}`); + }); + + it('--bundle-name explicitly provided -> requestedName equals the flag value verbatim', async () => { + const requestStub = $$.SANDBOX.stub().resolves({ jobId: '0BXxx0000000007', status: 'Queued' }); + $$.fakeConnectionRequest = requestStub; + stubSfCommandUx($$.SANDBOX); + + await UiBundleUpload.run( + [ + '--zip-file', + zipPath, + '--use-salesforce-pages', + '--target-org', + testOrg.username, + '--bundle-name', + 'my-custom-bundle-name', + ], + import.meta.url + ); + + expect(requestStub.calledOnce).to.be.true; + const sent = bundleBufferFromRequest(requestStub.firstCall.args[0]).toString('utf8'); + expect(sent).to.include('Content-Type: application/json\r\n\r\n{"requestedName":"my-custom-bundle-name"}'); + }); + + it('--bundle-dir with no --bundle-name -> requestedName defaults to the bundle dir basename', async () => { + const requestStub = $$.SANDBOX.stub().resolves({ jobId: '0BXxx0000000008', status: 'Queued' }); + $$.fakeConnectionRequest = requestStub; + stubSfCommandUx($$.SANDBOX); + const bundleDir = createBundleDirFixture(); + + await UiBundleUpload.run( + ['--bundle-dir', bundleDir, '--use-salesforce-pages', '--target-org', testOrg.username], + import.meta.url + ); + + expect(requestStub.calledOnce).to.be.true; + const sent = bundleBufferFromRequest(requestStub.firstCall.args[0]).toString('utf8'); + // Directories have no .zip suffix to strip; the basename is used as-is. + const expectedName = basename(bundleDir); + expect(sent).to.include(`Content-Type: application/json\r\n\r\n{"requestedName":"${expectedName}"}`); + }); + + it('--zip-file named ".zip" -> requestedName falls back to the unstripped filename, not empty', async () => { + const requestStub = $$.SANDBOX.stub().resolves({ jobId: '0BXxx0000000011', status: 'Queued' }); + $$.fakeConnectionRequest = requestStub; + stubSfCommandUx($$.SANDBOX); + const dotZipPath = join(tmpdir(), '.zip'); + writeFileSync(dotZipPath, Buffer.from([0x50, 0x4b, 0x03, 0x04])); + + await UiBundleUpload.run( + ['--zip-file', dotZipPath, '--use-salesforce-pages', '--target-org', testOrg.username], + import.meta.url + ); + + expect(requestStub.calledOnce).to.be.true; + const sent = bundleBufferFromRequest(requestStub.firstCall.args[0]).toString('utf8'); + expect(sent).to.include('Content-Type: application/json\r\n\r\n{"requestedName":".zip"}'); + }); + + it('--api-version below the minimum floor -> throws UiBundleUploadApiVersionError, no network call', async () => { + const requestStub = $$.SANDBOX.stub().resolves({ jobId: '0BXxx0000000009', status: 'Queued' }); + $$.fakeConnectionRequest = requestStub; + stubSfCommandUx($$.SANDBOX); + + try { + await UiBundleUpload.run( + ['--zip-file', zipPath, '--use-salesforce-pages', '--target-org', testOrg.username, '--api-version', '66.0'], + import.meta.url + ); + expect.fail('should have thrown'); + } catch (e) { + const err = e as Error & { name: string; message: string }; + expect(err.name).to.equal('UiBundleUploadApiVersionError'); + expect(err.message).to.include('66.0'); + expect(err.message).to.include('67'); + } + expect(requestStub.called).to.be.false; + }); + + it('--api-version at the minimum floor -> does not throw, proceeds to a Queued result', async () => { + const requestStub = $$.SANDBOX.stub().resolves({ jobId: '0BXxx0000000010', status: 'Queued' }); + $$.fakeConnectionRequest = requestStub; + stubSfCommandUx($$.SANDBOX); + + const result = await UiBundleUpload.run( + ['--zip-file', zipPath, '--use-salesforce-pages', '--target-org', testOrg.username, '--api-version', '67.0'], + import.meta.url + ); + + expect(result).to.deep.equal({ jobId: '0BXxx0000000010', status: 'Queued' } as UiBundleUploadResult); + expect(requestStub.calledOnce).to.be.true; + }); + + it('omitted --api-version, connection resolves below the minimum floor -> throws UiBundleUploadApiVersionError, no network call', async () => { + // Simulates an org-config default or auto-negotiated version below the floor, with no + // --api-version flag on the command line at all. Before this change, only an explicit + // --api-version input was checked; the resolved-version check must now catch this too. + resolvedApiVersion = '66.0'; + const requestStub = $$.SANDBOX.stub().resolves({ jobId: '0BXxx0000000012', status: 'Queued' }); + $$.fakeConnectionRequest = requestStub; + stubSfCommandUx($$.SANDBOX); + + try { + await UiBundleUpload.run( + ['--zip-file', zipPath, '--use-salesforce-pages', '--target-org', testOrg.username], + import.meta.url + ); + expect.fail('should have thrown'); + } catch (e) { + const err = e as Error & { name: string; message: string }; + expect(err.name).to.equal('UiBundleUploadApiVersionError'); + expect(err.message).to.include('66.0'); + expect(err.message).to.include('67'); + } + expect(requestStub.called).to.be.false; + }); + + it('omitted --api-version, connection resolves at/above the minimum floor -> does not throw, proceeds to a Queued result', async () => { + // Sanity check for the shared default: an omitted flag with a supported resolved version + // (the beforeEach default of 67.0) must behave like every other non-version-specific test. + const requestStub = $$.SANDBOX.stub().resolves({ jobId: '0BXxx0000000013', status: 'Queued' }); + $$.fakeConnectionRequest = requestStub; + stubSfCommandUx($$.SANDBOX); + + const result = await UiBundleUpload.run( + ['--zip-file', zipPath, '--use-salesforce-pages', '--target-org', testOrg.username], + import.meta.url + ); + + expect(result).to.deep.equal({ jobId: '0BXxx0000000013', status: 'Queued' } as UiBundleUploadResult); + expect(requestStub.calledOnce).to.be.true; + }); + + it('--zip-file -> sends the file as-is (a zip) in the bundle part, no re-compression', async () => { + const requestStub = $$.SANDBOX.stub().resolves({ jobId: '0BXxx0000000003', status: 'Queued' }); + $$.fakeConnectionRequest = requestStub; + stubSfCommandUx($$.SANDBOX); + + await UiBundleUpload.run( + ['--zip-file', zipPath, '--use-salesforce-pages', '--target-org', testOrg.username], + import.meta.url + ); + + expect(requestStub.calledOnce).to.be.true; + // getBuffer() returns the whole multipart body; the placeholder zip is embedded verbatim. + const sent = bundleBufferFromRequest(requestStub.firstCall.args[0]); + expect(sent.includes(ZIP_MAGIC)).to.be.true; + }); + + it('--bundle-dir -> compresses the directory (via SDR) into a zip bundle part', async () => { + const requestStub = $$.SANDBOX.stub().resolves({ jobId: '0BXxx0000000004', status: 'Queued' }); + $$.fakeConnectionRequest = requestStub; + stubSfCommandUx($$.SANDBOX); + const bundleDir = createBundleDirFixture(); + + const result = await UiBundleUpload.run( + ['--bundle-dir', bundleDir, '--use-salesforce-pages', '--target-org', testOrg.username], + import.meta.url + ); + + expect(result).to.deep.equal({ jobId: '0BXxx0000000004', status: 'Queued' } as UiBundleUploadResult); + expect(requestStub.calledOnce).to.be.true; + // The bundle part is a real SDR-produced zip (its local-file-header magic appears in the body). + const sent = bundleBufferFromRequest(requestStub.firstCall.args[0]); + expect(sent.includes(ZIP_MAGIC)).to.be.true; + // A compressed two-file directory is meaningfully larger than the 4-byte placeholder. + expect(sent.length).to.be.greaterThan(100); + }); + + it('--bundle-dir with dotfiles -> dotfiles and dot-directories are filtered out', async () => { + const requestStub = $$.SANDBOX.stub().resolves({ jobId: '0BXxx0000000005', status: 'Queued' }); + $$.fakeConnectionRequest = requestStub; + stubSfCommandUx($$.SANDBOX); + const bundleDir = createBundleDirWithDotfilesFixture(); + + await UiBundleUpload.run( + ['--bundle-dir', bundleDir, '--use-salesforce-pages', '--target-org', testOrg.username], + import.meta.url + ); + + expect(requestStub.calledOnce).to.be.true; + const sent = bundleBufferFromRequest(requestStub.firstCall.args[0]); + const zip = await JSZip.loadAsync(sent); + const entries = Object.keys(zip.files); + + // Assert non-dotfiles are present. + expect(entries).to.include('index.html'); + expect(entries).to.include('src/app.js'); + + // Assert dotfiles and dot-directory contents are absent. + expect(entries.some((e) => e.includes('.env'))).to.be.false; + expect(entries.some((e) => e.includes('.hidden'))).to.be.false; + expect(entries.some((e) => e.includes('.git'))).to.be.false; + }); + + it('--bundle-dir with symlinks -> symlinked files and directories are bundled, not skipped', async function () { + const bundleDir = createBundleDirWithSymlinksFixture(); + if (!bundleDir) { + this.skip(); + return; + } + + const requestStub = $$.SANDBOX.stub().resolves({ jobId: '0BXxx0000000006', status: 'Queued' }); + $$.fakeConnectionRequest = requestStub; + stubSfCommandUx($$.SANDBOX); + + await UiBundleUpload.run( + ['--bundle-dir', bundleDir, '--use-salesforce-pages', '--target-org', testOrg.username], + import.meta.url + ); + + expect(requestStub.calledOnce).to.be.true; + const sent = bundleBufferFromRequest(requestStub.firstCall.args[0]); + const zip = await JSZip.loadAsync(sent); + const entries = Object.keys(zip.files); + + // Non-symlink entries are still present. + expect(entries).to.include('index.html'); + expect(entries).to.include('src/app.js'); + + // The symlinked file and the symlinked directory's nested file are both bundled. + expect(entries).to.include('linked.js'); + expect(entries).to.include('linked-dir/nested.js'); + + const linkedFileContent = await zip.files['linked.js'].async('string'); + expect(linkedFileContent).to.equal('console.log("linked file");'); + + const linkedDirFileContent = await zip.files['linked-dir/nested.js'].async('string'); + expect(linkedDirFileContent).to.equal('console.log("linked dir");'); + }); + + it('Failed response (defensive) -> correct return value, logged to stderr, exitCode 1', async () => { + const savedExitCode = process.exitCode; + process.exitCode = undefined; + try { + const requestStub = $$.SANDBOX.stub().resolves({ + jobId: '0BXxx0000000002', + status: 'Failed', + message: 'Bundle validation failed', + }); + $$.fakeConnectionRequest = requestStub; + const uxStubs = stubSfCommandUx($$.SANDBOX); + + const result = await UiBundleUpload.run( + ['--zip-file', zipPath, '--use-salesforce-pages', '--target-org', testOrg.username], + import.meta.url + ); + + expect(result).to.deep.equal({ + jobId: '0BXxx0000000002', + status: 'Failed', + message: 'Bundle validation failed', + } as UiBundleUploadResult); + expect(process.exitCode).to.equal(1); + expect(uxStubs.log.called).to.be.false; + expect(uxStubs.logToStderr.calledOnce).to.be.true; + const stderrOutput = uxStubs.logToStderr.args.flat().join('\n'); + expect(stderrOutput).to.include('Upload failed'); + expect(stderrOutput).to.include('0BXxx0000000002'); + expect(stderrOutput).to.include('Bundle validation failed'); + // Verify the stderr output matches what the message file produces. + const expectedMessage = messages.getMessage('error.upload-failed', [ + '0BXxx0000000002', + 'Bundle validation failed', + ]); + expect(stderrOutput).to.equal(expectedMessage); + } finally { + process.exitCode = savedExitCode; + } + }); + + it('HTTP error with errorCode -> throws UiBundleUploadValidationError, message verbatim', async () => { + const serverError = new Error('The org rejected the bundle: unsupported file type') as Error & { + errorCode: string; + }; + serverError.errorCode = 'INVALID_INPUT'; + $$.fakeConnectionRequest = $$.SANDBOX.stub().rejects(serverError); + stubSfCommandUx($$.SANDBOX); + + try { + await UiBundleUpload.run( + ['--zip-file', zipPath, '--use-salesforce-pages', '--target-org', testOrg.username], + import.meta.url + ); + expect.fail('should have thrown'); + } catch (e) { + const err = e as Error & { name: string; message: string }; + expect(err.name).to.equal('UiBundleUploadValidationError'); + expect(err.message).to.include('The org rejected the bundle: unsupported file type'); + } + }); + + it('network failure (no HTTP response) -> throws UiBundleUploadNetworkError, message verbatim', async () => { + $$.fakeConnectionRequest = $$.SANDBOX.stub().rejects(new Error('ECONNREFUSED: connection refused')); + stubSfCommandUx($$.SANDBOX); + + try { + await UiBundleUpload.run( + ['--zip-file', zipPath, '--use-salesforce-pages', '--target-org', testOrg.username], + import.meta.url + ); + expect.fail('should have thrown'); + } catch (e) { + const err = e as Error & { name: string; message: string }; + expect(err.name).to.equal('UiBundleUploadNetworkError'); + expect(err.message).to.include('ECONNREFUSED: connection refused'); + } + }); + + it('auth error via INVALID_SESSION_ID errorCode -> throws UiBundleUploadAuthError, message verbatim', async () => { + const authError = new Error('Session expired or invalid') as Error & { errorCode: string }; + authError.errorCode = 'INVALID_SESSION_ID'; + $$.fakeConnectionRequest = $$.SANDBOX.stub().rejects(authError); + stubSfCommandUx($$.SANDBOX); + + try { + await UiBundleUpload.run( + ['--zip-file', zipPath, '--use-salesforce-pages', '--target-org', testOrg.username], + import.meta.url + ); + expect.fail('should have thrown'); + } catch (e) { + const err = e as Error & { name: string; message: string }; + expect(err.name).to.equal('UiBundleUploadAuthError'); + expect(err.message).to.include('Session expired or invalid'); + } + }); + + it('auth error via ERROR_HTTP_401 errorCode -> throws UiBundleUploadAuthError, message verbatim', async () => { + const authError = new Error('Unauthorized') as Error & { errorCode: string }; + authError.errorCode = 'ERROR_HTTP_401'; + $$.fakeConnectionRequest = $$.SANDBOX.stub().rejects(authError); + stubSfCommandUx($$.SANDBOX); + + try { + await UiBundleUpload.run( + ['--zip-file', zipPath, '--use-salesforce-pages', '--target-org', testOrg.username], + import.meta.url + ); + expect.fail('should have thrown'); + } catch (e) { + const err = e as Error & { name: string; message: string }; + expect(err.name).to.equal('UiBundleUploadAuthError'); + expect(err.message).to.include('Unauthorized'); + } + }); + + it('auth error via refresh-failure message -> throws UiBundleUploadAuthError, message verbatim', async () => { + const refreshError = new Error('Unable to refresh session due to: invalid grant'); + $$.fakeConnectionRequest = $$.SANDBOX.stub().rejects(refreshError); + stubSfCommandUx($$.SANDBOX); + + try { + await UiBundleUpload.run( + ['--zip-file', zipPath, '--use-salesforce-pages', '--target-org', testOrg.username], + import.meta.url + ); + expect.fail('should have thrown'); + } catch (e) { + const err = e as Error & { name: string; message: string }; + expect(err.name).to.equal('UiBundleUploadAuthError'); + expect(err.message).to.include('Unable to refresh session due to:'); + } + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index e0aa9b3..5782a8e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4563,6 +4563,17 @@ form-data@^4.0.4: hasown "^2.0.2" mime-types "^2.1.12" +form-data@^4.0.5: + version "4.0.6" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.6.tgz#28e864e1b786dbebb68db1f452f9635278665827" + integrity sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.4" + mime-types "^2.1.35" + fromentries@^1.2.0: version "1.3.2" resolved "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz" @@ -4947,6 +4958,13 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" +hasown@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== + dependencies: + function-bind "^1.1.2" + hast-util-to-html@^9.0.4: version "9.0.5" resolved "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz" @@ -6218,7 +6236,7 @@ mime-db@1.52.0: resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-types@^2.1.12: +mime-types@^2.1.12, mime-types@^2.1.35: version "2.1.35" resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==