Install, upgrade, and manage Olares apps via olares-cli market — the per-user Market app-store v2 mirror of the Olares Market UI. Covers catalog browsing (li...
---
name: olares-market
version: 1.3.0
description: "Install, upgrade, and manage Olares apps via olares-cli market — the per-user Market app-store v2 mirror of the Olares Market UI. Covers catalog browsing (list, get, categories), the full app lifecycle (install, uninstall, upgrade, clone, cancel, stop, resume), runtime status, --mine view of the active profile's apps (same set the Market UI's My Terminus tab shows, including in-flight installs / upgrades / failures), upload / delete for local Helm chart packages, and --watch flags that block until the app reaches a terminal state. Use when the user mentions Olares Market, olares-cli market, app store, installing / upgrading / uninstalling / cloning / stopping / resuming / cancelling an Olares app, 'my apps', '我的应用', upload chart, --watch, or asks 'is <app> installed yet' / 'show me my Olares apps'."
metadata:
requires:
bins: ["olares-cli"]
cliHelp: "olares-cli market --help"
---
# market (App-store v2 + per-user market-backend)
**CRITICAL — before doing anything, MUST use the Read tool to read [`../olares-shared/SKILL.md`](../olares-shared/SKILL.md) for the profile selection, login, and HTTP 401/403 recovery rules that every command here depends on.**
## Core concepts
### Source resolution
The market backend serves multiple "sources" of charts. The CLI resolves which one to talk to from `-s / --source`, falling back to a default that depends on the verb:
| Source id | What it is | Used by (default) |
|--------------|---------------------------------------------------------|--------------------------------|
| `market.olares` | Public catalog (read-only browse) | `list`, `get`, `categories`, `install`, `upgrade`, `clone`, `status` |
| `upload` | Local source for charts pushed through the SPA's "Local Sources → Upload" UI **and** through `market upload` | `upload`, `delete` (hard-coded — see below) |
| `cli` | Legacy local source for charts uploaded via earlier CLI revisions; still readable via `-s cli` on browse verbs but no longer a write target | read-only (`list`, `status`, ...) |
| `studio` | Local source for charts produced by Devbox / Studio | read-only (`list`, `status`, ...) |
Resolution is centralized in [`cli/cmd/ctl/market/common.go`](cli/cmd/ctl/market/common.go):
- `resolveCatalogSource(opts)` → `opts.Source` if set, else `defaultCatalogSource = "market.olares"`.
- `chartUploadSource = "upload"` constant — the **only** local source `market upload` and `market delete` ever write to. `-s` is intentionally NOT exposed on those two verbs: pinning the bucket avoids the historical foot-gun where a chart pushed to `cli` was invisible to the SPA's Local Sources tab despite using the same backend.
When `-s` is omitted, every command that DOES accept it prints `Using source: <id>` to stderr so the agent can confirm which backend it hit. `-a / --all-sources` (where supported) bypasses the single-source resolver and asks the backend across every source the user has access to.
### App lifecycle / state machine
The backend tracks two orthogonal axes per app: **`State`** (where the row currently is) and **`OpType`** (which mutation is in flight). The full enum lives in [`framework/app-service/api/app.bytetrade.io/v1alpha1/appmanager_states.go`](framework/app-service/api/app.bytetrade.io/v1alpha1/appmanager_states.go). The CLI groups them into four buckets in [`cli/cmd/ctl/market/watch.go`](cli/cmd/ctl/market/watch.go):
| Bucket | Examples | Meaning |
|----------------------|------------------------------------------------------------------|------------------------------------------|
| Progressing | `pending`, `installing`, `upgrading`, `uninstalling`, `stopping`, `resuming`, `installingCanceling`, …Canceling | Backend is actively working; keep polling |
| Terminal success | `running`, `stopped`, `uninstalled` | Mutation finished cleanly |
| Terminal failure | `installFailed`, `upgradeFailed`, `uninstallFailed`, `stopFailed`, `resumeFailed` | Mutation finished with a hard error |
| Canceled / cancel-failed | `installingCanceled`, `upgradingCanceled`, `resumingCanceled`, `installingCancelFailed`, `upgradingCancelFailed`, `resumingCancelFailed` | A `cancel` request landed (or failed) |
The CLI maps each verb to the subset of buckets it considers terminal — see the `--watch` section below.
### `OpType` vs `State` (race-safety)
The same `State` can mean different things depending on which mutation is in flight. Concrete example: an `upgrade` issued against an app already in `running` will return `state=running, opType=running` for one or two ticks before the backend flips to `state=upgrading, opType=upgrade`. A naive watcher would declare success at tick zero.
The fix lives in [`cli/cmd/ctl/market/watch.go`](cli/cmd/ctl/market/watch.go) (`waitForTerminal` + `watchTarget.matchOpType`): for mutating verbs the watcher refuses to accept any "success" classification until either:
1. the row's `OpType` matches the op the CLI just issued, **or**
2. the row disappears entirely (only legal for `uninstall` / `status`).
`cancel` and `status` deliberately set `matchOpType=false` because they are op-agnostic by design.
## Authentication transport
Every request goes through a factory-injected `*http.Client` whose `RoundTripper` (a `refreshingTransport` — see [`cli/pkg/cmdutil/factory.go`](cli/pkg/cmdutil/factory.go)) **injects `X-Authorization` and auto-rotates expired tokens transparently**. The `MarketClient` itself is purely an HTTP wrapper — it never sees the access_token.
- Base URL: `<rp.MarketURL>/app-store/api/v2` — built in [`cli/cmd/ctl/market/client.go`](cli/cmd/ctl/market/client.go) (`NewMarketClient` / `apiPrefix`).
- Auth header: `X-Authorization: <access_token>` (NOT `Authorization: Bearer …`). The transport handles header injection; the client code does not call `req.Header.Set("X-Authorization", …)` anywhere.
- `MarketURL` is derived from the Olares ID (`https://market.<localPrefix><terminusName>`) and surfaced through [`cli/pkg/credential/types.go`](cli/pkg/credential/types.go) (`ResolvedProfile.MarketURL`).
- Two clients in `MarketClient`: `httpClient` (30s timeout, JSON verbs) and `uploadClient` (no timeout, multipart chart pushes). Both share the SAME `refreshingTransport` instance via the Factory, so a refresh triggered on one is immediately visible on the other.
- 401/403 reaches `executeRequest` only when the transport already auto-refreshed and STILL got rejected (consistent server-side rejection); it's reformatted via `reformatMarketAuthErr`.
- When `/api/refresh` itself fails, `executeRequest` surfaces the typed `*credential.ErrTokenInvalidated` / `*credential.ErrNotLoggedIn` directly so the user sees the canonical "run profile login" CTA without `request failed: Get "https://...":` noise. **Recovery rules → [`../olares-shared/SKILL.md`](../olares-shared/SKILL.md) "Automatic token refresh".**
> All `market` verbs use replayable request bodies (JSON in `*bytes.Reader`, multipart in `*bytes.Buffer`), so they all benefit from the transport's reactive 401-retry path. There is no streaming-upload edge case in the market tree — chart pushes are fully buffered before the request goes out.
## Command cheatsheet
All verbs live under `olares-cli market <verb>`. Common flags:
- `-s / --source <id>` — pin to a single source (`market.olares` for the
remote catalog; `cli` / `upload` / `studio` for local helm-chart
sources). Auto-selected when omitted.
- `-a / --all-sources` — span every source the user has (where supported).
- `-o / --output {table,json}` — output format. Default `table`. `json`
emits a parseable payload (and suppresses any informational stderr
hints — see `-q` for the related "no output at all" toggle).
- `-q / --quiet` — suppress all stdout/stderr output; the exit code
still propagates the operation result. Use in scripts that only need
the success/failure signal (e.g. `olares-cli market list -q && ...`).
- `--no-headers` — omit table column headers (and the trailing
"Total: N …" summary). **Exposed only on `list` and `categories`**
— the row-oriented browse verbs. Deliberately NOT on:
- `get` — renders a key:value detail layout (one record, fields
like `Name: …`, `Title: …`), closer to `kubectl describe` than
to a row-oriented table. There are no "headers" separable from
values; for machine-readable output of `get` use `-o json`.
- `status` — always prints headers in table mode (its rows are a
runtime probe, not a stable scrape target; pipe through `awk`
on column index, or use `-o json`).
- Mutating verbs (`install` / `upgrade` / `stop` / `resume` /
`uninstall` / `cancel` / `clone` / `upload` / `delete`) — they
don't render tables, so `--no-headers` on them is a no-op
footgun in scripts that pass it expecting it to apply.
Useful for piping a stable table format into `awk` / `cut` /
`column` etc. without needing JSON. Has no effect in JSON output.
(No short flag.) Wiring lives in `addNoHeadersFlag` in
[`cli/cmd/ctl/market/options.go`](cli/cmd/ctl/market/options.go)
— separate from `addOutputFlags` (which only carries `-o` / `-q`),
so adding a new browse verb means explicitly opting in.
- Mutating verbs additionally accept `-w / --watch` plus the timing
knobs (see the [`--watch`](#watch-flag) section).
**`-o` and `-q` are on EVERY market verb** — read-only and mutating
alike — because every verb has something to print (a table, an
`OperationResult`, a chart-management payload) and benefits from a
quiet mode in scripts. Concretely:
- `olares-cli market install firefox -o json` → emits a single
`OperationResult` JSON document (see
[Lifecycle output shape](#lifecycle-output-shape) below) and
suppresses the stderr `info` chatter (`Installing 'firefox' ...`).
- `olares-cli market install firefox -q` → no stdout, no stderr,
exit code reflects backend acceptance (or, when combined with
`--watch`, terminal success / failure).
- `olares-cli market uninstall firefox -o json -q` is contradictory
but tolerated — `-q` wins and nothing is printed.
`-s` (source pin) and `-a` (span all sources) are narrower. Scope by
verb family:
| Flag | Read-only browsing | Lifecycle (mutating) | Chart management |
|------------------|---------------------------------------------------------|---------------------------------------------------------------|--------------------|
| `-s / --source` | `list`, `categories`, `status`, `get` | `install`, `upgrade`, `clone` | `upload`, `delete` |
| `-a / --all-sources` | `list`, `categories`, `status` | — | — |
`-s` is NOT on `uninstall` / `stop` / `resume` / `cancel`: these act
on whichever per-user state row matches the app name, regardless of
source. `-a` is read-only only — the inventory verbs use it to span
sources; mutating an app implicitly targets the source the user is
already running it from.
`--no-headers` is the narrowest — only `list` and `categories` (the
row-oriented browse verbs). `get` deliberately does NOT accept it
because its output is a key:value detail layout, not a table — use
`-o json` for scripted access to its fields.
Combine freely — e.g. `olares-cli market list --mine --no-headers
-o table` for a header-less table you can pipe into shell tools, or
`olares-cli market categories -q` to just check exit status, or
`olares-cli market install firefox --watch -o json | jq '.finalState'`
to read the watcher's verdict.
### Catalog (read-only)
```bash
olares-cli market list # auto-selected source (usually market.olares)
olares-cli market list -s market.olares # narrow to a specific source by id
olares-cli market list -s cli # browse a local source (cli / upload / studio)
olares-cli market list -c AI # filter by category
olares-cli market list -a # query every source the user has
olares-cli market list -o json
olares-cli market list --no-headers # table without column headers (scripting)
olares-cli market list -q # no output; exit code only
```
`-s / --source` works for both catalog browse and `--mine`: it pins the
listing to a single source id. Valid ids are `market.olares` (the
official remote catalog) and the three local-chart sources — `cli`,
`upload`, `studio` — used for locally-uploaded helm charts. Omit `-s`
to fall back to the auto-selected source (catalog browse) or to span
every source the user has (`--mine`). The id is matched verbatim and
unknown ids silently produce an empty result (the listing prints a
"no apps in source 'X'" hint on stderr rather than erroring out), so
when you're unsure what's available run `olares-cli market list -a` and
read the SOURCE column to enumerate the user's configured sources.
```bash
olares-cli market list --mine # what apps does this user have right now (all sources by default)
olares-cli market list -m -s cli # narrow to a single source
olares-cli market list -m -c AI -o json # category filter still works in mine mode
```
`list --mine` (alias `-m`) diverts the verb from `/market/data`
(catalog browse) to `/market/state` (per-user state rows) and is the
canonical "show me my apps" listing — the exact same set the Market UI's
"My Terminus" tab shows.
> **"My apps" ≠ "已安装应用 / completed installs only."** The Market UI
> shows in-flight install rows (`pending` / `downloading` / `installing`
> + their `*Canceling` / `*CancelFailed` variants), every post-install
> transitional state (`upgrading` / `resuming` / `stopping` /
> `applyingEnv` / `uninstalling`) and every post-install failure
> (`upgradeFailed` / `stopFailed` / `resumeFailed` / `applyEnvFailed` /
> `uninstallFailed`) on My Terminus as well, because they're all "the
> user's apps" — the user clicked something and expects to monitor /
> retry / cancel the row. `--mine` matches that mental model. Only the
> 6 SPA-hidden states (see below) drop out.
Differences vs catalog browse:
- **Source scope defaults to every source** — pass `-s` to narrow to one,
`-a` (or omitting `-s`) keeps the full cross-source view. This matches
the agent's mental model of "show me my apps" without forcing `-a`.
- **State filter mirrors the SPA's "My Terminus" filter exactly.** The
denylist is the SPA's `uninstalledAppStates` set in
[`apps/packages/app/src/constant/config.ts`](apps/packages/app/src/constant/config.ts)
(around line 170), which `MarketRemotePage.vue` →
`appStore.getSourceInstalledApp(sourceId)` calls through
`uninstalledApp(status)` to decide what shows up under the Market's
"My Terminus" tab. The 6 hidden states are: `pendingCanceled`,
`downloadingCanceled`, `downloadFailed`, `installFailed`,
`installingCanceled`, `uninstalled`. Everything else stays — including
in-flight install rows (`pending`, `downloading`, `installing` plus
their `*Canceling` / `*CancelFailed` variants), `initializing` /
`initializingCanceling`, and every post-install transitional /
failure state (`running`, `stopped`, `stopping`, `stopFailed`,
`resuming`, `resumeFailed`, `upgrading`, `upgradeFailed`,
`uninstalling`, `uninstallFailed`, `applyingEnv`, `applyEnvFailed`,
`*Canceling` / `*Canceled` / `*CancelFailed` siblings).
- This deliberately does NOT mirror the backend state machine in
`framework/app-service/pkg/appstate/state_transition.go`: the SPA
keeps `pending` / `downloading` / `installing` rows visible on My
Terminus because the user just clicked install and wants to
see / monitor / cancel the in-progress row, so the CLI must show
them too for the two views to agree.
- Use `market status` if you specifically want a runtime-state view.
- If the SPA changes its `uninstalledAppStates` set, update the
`notInstalledStates` map in `cli/cmd/ctl/market/types.go` and the
`TestIsInstalledState` / `TestFetchInstalledAppsMirrorsSpaUninstalledFilter`
tables in `cli/cmd/ctl/market/list_test.go` together so the two
listings stay in sync.
- **Output adds a STATE column** and (for JSON) a `state` field.
- **Version is the version on the user's state row**, not the catalog
latest. It comes from the `version` field at the `AppStateLatest`
level of `/market/state` (the same field the SPA's `AppStatusLatest`
interface reads) — the chart the user picked for this row, regardless
of whether the install / upgrade has completed. If the user installed
1.0.10 and the marketplace catalog has since moved to 1.2.3, this
listing will surface 1.0.10 — that is the intended behavior; do not
"correct" it to the catalog version. During an upgrade in flight,
the row may show the target version while STATE is still `upgrading`,
which is also intentional. Title and categories ARE best-effort
enriched from `/market/data`; locally-uploaded charts that have since
been deleted from their source still surface but may render with
blank title / categories. Version is left blank only when the state
row genuinely lacks it (older backends, in-flight rows that haven't
been bound yet).
- **Clones look up the catalog by `rawAppName`, not their unique
`name`.** A cloned multi-instance app gets its own per-instance
identifier (e.g. `windowsefe992`) but the catalog only knows the
source app (`windows`). The state row carries the source name as
`rawAppName`; the parser uses it as the catalog lookup key whenever
non-empty (and falls back to `name` for normal non-clone installs)
so clones still pick up the source app's title and categories. Source
of truth for the rule: `framework/app-service/pkg/utils/app/app.go`
`GetRawAppName`.
```bash
olares-cli market categories # category counts in the auto-selected source
olares-cli market categories -s market.olares # pin to a single source
olares-cli market categories -a -o json # every source, JSON output
olares-cli market categories --no-headers # table without the CATEGORY/APPS header row
olares-cli market categories -q # no output; exit code only
```
```bash
olares-cli market get firefox # detailed info, table view
olares-cli market get firefox -o json # full upstream payload
```
`get` answers questions like "is this app cloneable?" — look at the `cloneable` field in JSON output (see [`cli/cmd/ctl/market/get.go`](cli/cmd/ctl/market/get.go)).
### Runtime (read-only)
```bash
olares-cli market status # all installed apps in the resolved source
olares-cli market status -s market.olares # pin the listing to a specific source
olares-cli market status firefox # one app, with the source-fallback hint
olares-cli market status firefox -a # search across every source
olares-cli market status firefox --watch # see the --watch section
olares-cli market status firefox -q # no output; exit code only
```
`status <app>` UX rules — implemented in [`cli/cmd/ctl/market/status.go`](cli/cmd/ctl/market/status.go) (`runStatusSingle`):
- If the row is missing in the resolved source **and** in every other source the user has, the CLI prints `app 'X' is not installed (run 'olares-cli market install X' to install it)`.
- If the row exists but under a different source than the one the user passed, the CLI prints an info hint `App is installed under source 'Y' (not 'X')` and continues to render the row, so the agent does not need to retry blindly.
- `runStatusAll` (no app argument) explicitly rejects `--watch`. Use `status <app> --watch` instead.
> `market status` (no app) and `market list --mine` overlap but are
> not interchangeable: `status` is the runtime-state-focused view
> (`STATE / OPERATION / PROGRESS`, filters by source by default), while
> `list --mine` is the "my apps" inventory view
> (`NAME / TITLE / VERSION / STATE / SOURCE / CATEGORIES`, defaults to
> every source and hides exactly the same rows the Market SPA hides
> from its "My Terminus" tab — the 6 `uninstalledAppStates` listed in
> the State filter bullet above). Prefer `list --mine` when the
> user asks "what apps do I have" / "show me my apps" / "我的应用";
> prefer `status` when they want runtime / progress detail. Note that
> "my apps" deliberately INCLUDES in-flight installs and failed rows
> (not just `running` ones), since that is what the SPA shows.
### Lifecycle (mutating, support `--watch`)
```bash
olares-cli market install firefox --watch
olares-cli market install firefox --version 1.0.11 --env DEBUG=1 --watch
olares-cli market install firefox -o json # one OperationResult JSON doc; status="accepted"
olares-cli market install firefox --watch -o json # JSON with finalState/finalOpType populated
olares-cli market install firefox -q # silent; exit code only
olares-cli market install firefox --watch -q # silent + block until terminal
olares-cli market install ollama-webui --watch --watch-timeout 30m # bump deadline for image-pull-heavy installs
olares-cli market install firefox --watch --watch-interval 1s # tight polling for fast feedback
olares-cli market install firefox --watch --watch-timeout 5m --watch-interval 1s # tight CI bounds
```
```bash
olares-cli market upgrade firefox --version 1.0.12 --watch
olares-cli market upgrade firefox --version 1.0.12 -o json # accepted payload only
olares-cli market upgrade firefox --version 1.0.12 --watch -o json | jq -r '.finalState'
olares-cli market upgrade firefox --version 1.0.12 --watch --watch-timeout 30m # slow image pull
```
> **`upgrade` deliberately does NOT accept `--env`** — mirrors the Market SPA's `upgradeApp({app_name, source, version})` payload (see [`apps/.../components/appcard/InstallButton.vue`](apps/packages/app/src/components/appcard/InstallButton.vue) and [`useAppAction.ts`](apps/packages/app/src/components/appcard/useAppAction.ts)). Existing env values are preserved server-side from the prior install. To change env values, use `olares-cli market env --set KEY=value <app>` (out-of-band) — that's the same flow the SPA exposes via its env-editor dialog. The CLI's `UpgradeApp` wire payload uses a dedicated `UpgradeRequest` type that has no `envs` field, so passing envs accidentally is impossible.
#### `upgrade` pre-flight gates (parity with SPA `canUpgrade`)
Before issuing `PUT /apps/{name}/upgrade`, `runUpgrade` runs a four-gate
pre-flight that mirrors the SPA's `canUpgrade(statusLatest, appId,
sourceId)` predicate in [`apps/.../constant/config.ts`](apps/packages/app/src/constant/config.ts). All
four must pass or the CLI bails locally with a self-contained error
(formatted via `failOp` so `-o json` carries it in the `message` field
and `-q` still surfaces the exit code). The gates and their failure
modes:
| Gate | Source of truth | Pass condition | CLI error on failure |
|------|-----------------|----------------|----------------------|
| 1. **Row exists** | `GET /market/state` | `appName` is found via Name **or** RawName (clones included) | `cannot upgrade '<X>': app is not installed (no per-user state row); use 'olares-cli market install <X>' first` |
| 2. **State is upgradable** | `app_state_latest[].status.state` | state ∈ `{running, stopped, stopFailed, upgradeFailed, applyEnvFailed}` (verbatim mirror of SPA's `isUpgradableAppStates`) | `cannot upgrade '<X>' in state '<S>': upgrade is only allowed from running, stopped, stopFailed, upgradeFailed, applyEnvFailed; ...` |
| 3. **Strict semver newer** | `app_state_latest[].version` vs `--version` (or `resolveVersionInSource` latest) | `target > installed` after `Masterminds/semver/v3` strict-parse of both sides | `cannot upgrade '<X>': target version '<T>' is already installed — nothing to do` / `... is older than installed version '<I>'; downgrade via upgrade is rejected` |
| 4. **Not suspended** | `POST /apps` → `apps[0].app_simple_info.app_labels` | labels contain NEITHER `suspend` NOR `remove` (verbatim mirror of SPA's `suspendApp`) | `cannot upgrade '<X>': chart is marked 'suspend' or 'remove' in source '<S>' (the SPA hides the Upgrade button for the same reason); upstream has withdrawn this app` |
Gate behavior nuances worth knowing:
- **Soft-fail on probe error for gate 4**. If `/apps` errors or returns
an empty `apps[]`, the preflight surfaces a one-line stderr warning
(`warning: preflight could not read catalog metadata ...; skipping
suspend-label check`) and **proceeds** with the upgrade. Same
philosophy as `shouldAutoCascade` in [`uninstall.go`](cli/cmd/ctl/market/uninstall.go) — the
backend has the final say, and we don't want a flaky catalog probe
to block the user when gates 1–3 already passed. Gates 1–3 never
soft-fail because their inputs come from the same `/market/state`
response the row lookup already has in hand.
- **Source mismatch is a warning, not a failure**. If the installed
row's source is `market.test` and the user passes `-s market.olares`,
the preflight prints a stderr warning and continues — sometimes
legitimate (chart moved between sources), and the backend can
reject it cleanly if not.
- **Clones (RawName != Name)** use `RawName` for the gate-4 catalog
lookup so a clone like `windowsefe992` reads its suspend label from
the source app `windows`, matching how the SPA renders the upgrade
button.
- **Mid-flight rows** (state row exists but `version` is empty —
typical for `pending` / older backends) bail with `cannot upgrade
'<X>': no version recorded on the state row (mid-flight install or
older backend) — re-run 'olares-cli market status <X> --watch' until
the row stabilizes, then retry`.
The whole predicate lives in [`cli/cmd/ctl/market/preflight.go`](cli/cmd/ctl/market/preflight.go) and is
pinned by [`preflight_test.go`](cli/cmd/ctl/market/preflight_test.go) (`TestIsUpgradableState`,
`TestIsAppSuspended`, `TestPreflightUpgrade`). If the SPA reshuffles
`isUpgradableAppStates` or `suspendApp`, update both the predicate
and its tests in lockstep so the CLI's bar tracks the dialog's bar.
```bash
olares-cli market uninstall firefox --watch
olares-cli market uninstall firefox --cascade --delete-data --watch # see Security rules
olares-cli market uninstall ollamav2 # auto-cascade for single-user CS apps
olares-cli market uninstall ollamav2 --cascade=false # force no cascade on a CS app
olares-cli market uninstall firefox -o json # status="accepted"
olares-cli market uninstall firefox --watch -q # silent; exit code = 0 iff successfully uninstalled
olares-cli market uninstall firefox --watch --watch-interval 1s # snappy uninstall completion signal
olares-cli market uninstall ollamav2 --cascade --watch --watch-timeout 30m # cascade with large shared sub-charts
```
#### Auto-cascade for CS apps (`uninstall` / `stop`)
`uninstall` and `stop` both **auto-decide** their `--cascade` default to mirror the Market SPA's `csAppUninstall()` and `csAppStop()` dialogs (both in `apps/.../stores/market/csAppOperation.ts`, dispatched from `appService.ts`). When the user does **not** pass `--cascade`, the CLI:
1. Probes user count via `GET /api/users/v2` (`fetchUserTotals` in [`cli/cmd/ctl/market/users_helper.go`](cli/cmd/ctl/market/users_helper.go)).
2. If single-user, looks up the app's state row via `/market/state` using `lookupInstalledApp` ([`cli/cmd/ctl/market/preflight.go`](cli/cmd/ctl/market/preflight.go)) — the same helper `preflightUpgrade` uses, so both gates agree on what "the user's row" is. The row carries both the canonical `Name` (e.g. `windowsefe992`) AND a `RawName` (the source app, e.g. `windows`). **Match rule is strict on `Name`, NOT `RawName`**: when both the primary `windows` row and clones like `windowsefe992` (which carry `RawName=windows`) are installed, a query for `windows` MUST return the primary, never a clone. The SPA enforces the same contract — clicking "Upgrade" / "Uninstall" / "Stop" on a clone card sends the per-instance name, not the source name. The only fallback is the legacy / malformed case where a row has `Name=""` and `RawName=<appName>` — those still match by RawName because Name is empty (the only way the disambiguation rule could fire). See `TestLookupInstalledAppDisambiguatesPrimaryFromClones` for the pinned semantics.
3. Fetches catalog metadata via `/apps` using **`RawName` when present, falling back to `Name`** for the lookup key. **Clones (where `RawName != Name`) MUST go through `RawName` here** — the catalog (`/apps`) is indexed by source app, NOT by per-instance clone name, and the SPA's `csAppUninstall()` / `csAppStop()` read `AppFullInfo` the same way (keyed by source app under the hood). Using the clone name instead would return an empty `/apps` response, make `isCSV2` answer false, and silently flip `--cascade` off for single-user CS clones — diverging from the SPA. Same RawName-preferred-catalog-key trick is used by `preflightUpgrade` (`preflight.go`) and `fetchInstalledApps` (`list.go`); the three sites must stay in lockstep.
4. Runs `isCSV2(appInfo)` ([`cli/cmd/ctl/market/common.go`](cli/cmd/ctl/market/common.go)) on the result — TRUE only when `app_info.app_entry.apiVersion == "v2"` AND `subCharts` is non-empty (verbatim mirror of SPA's `isCSV2()` in `apps/.../constant/constants.ts`).
5. **`--cascade` defaults to `true` iff both conditions hold** (`single-user && isCSV2`). Otherwise default stays `false`. Shared decision helper `shouldAutoCascade` ([`cli/cmd/ctl/market/uninstall.go`](cli/cmd/ctl/market/uninstall.go)) is reused unchanged by `stop` ([`cli/cmd/ctl/market/stop.go`](cli/cmd/ctl/market/stop.go)). The auto-decision's stderr explainer surfaces the catalog key when it differs from the user's app name (e.g. `--cascade auto-enabled: single-user instance + v2 multi-chart app (via source app "windows" in source "market.olares") ...`), making the decision auditable when a clone is involved.
`--cascade` passed explicitly (`--cascade`, `--cascade=true`, `--cascade=false`) always wins — cobra's `cmd.Flags().Changed("cascade")` gates the auto-default so the override is non-ambiguous. Any probe error (HTTP failure, malformed catalog) **soft-fails to `--cascade=false`** (the historical default) — the verb always proceeds, the backend's own validation has the final say.
> **Why mirror SPA here instead of always defaulting `--cascade=false`?** The most common interactive uninstall / stop in Olares is a personal-cloud admin operating on a CS app like `ollamav2` (Ollama with shared GPU server sub-chart). The SPA dialog auto-checks "also tear down / stop the shared server" for the single-user-admin case and only surfaces the checkbox in multi-user setups so the admin can opt out. Without auto-cascade the CLI version of the same op leaves the shared sub-chart orphaned (uninstall) or running (stop), which breaks the next install or wastes the GPU. The auto-default puts `olares-cli market uninstall` / `stop` on parity with the dialog UX; explicit `--cascade=false` is the documented escape hatch.
> **Subtle SPA difference, same CLI surface.** `csAppUninstall` always shows a dialog (it has a "delete data" checkbox to confirm) and the cascade checkbox visibility is gated by `csApp && isAdmin && users > 1`; `csAppStop` skips the dialog entirely for non-CS / non-admin (returns `all=false`) AND for single-user CS (returns `all=true`), only popping for the CS-admin-multi-user case. The CLI collapses both flows to the same `single-user && isCSV2 ⇒ default true` rule because it is non-interactive — there is no dialog to pop in the multi-user branch, so the CLI keeps the conservative `false` default there and asks the user to opt in with `--cascade`.
> **"CS" here is the SPA's `isCSV2` — v2 multi-chart — NOT `clusterScoped`.** They are independent concepts: `clusterScoped` controls install permissions (admin-only); `isCSV2` controls cascade semantics. Updating only one when the SPA changes its predicate will silently desync the CLI from the dialog.
> **The `--cascade` JSON wire field is `all`, not `cascade`** — same field the SPA's DELETE / `/apps/{name}/stop` payloads use. See `UninstallRequest` in [`cli/cmd/ctl/market/types.go`](cli/cmd/ctl/market/types.go) and `StopApp` in [`cli/cmd/ctl/market/client.go`](cli/cmd/ctl/market/client.go).
```bash
olares-cli market clone firefox --title "Firefox (work)" --watch
olares-cli market clone firefox --title "FF" --entrance-title firefox=Work --watch
olares-cli market clone firefox --title "FF" --watch -o json # JSON includes targetApp (the backend-assigned clone name)
olares-cli market clone firefox --title "FF" --watch -q # silent; exit code only
olares-cli market clone firefox --title "FF" --watch --watch-timeout 30m # large chart with slow first-time pull
```
`clone` quirks (cite [`cli/cmd/ctl/market/clone.go`](cli/cmd/ctl/market/clone.go)):
- `--title` is required and capped at 30 characters.
- The clone target name is decided by the backend; the CLI tracks it in `OperationResult.TargetApp` and falls back to the source app name only if the backend never reports one. **Read `targetApp` from the JSON output** — it's the only way to discover the unique per-instance identifier (e.g. `windowsefe992`) the backend just minted.
- Only multi-instance apps are cloneable — confirm with `market get <app>` (`cloneable: true`) before running.
```bash
olares-cli market stop firefox # fire-and-forget; returns once backend accepts (status="accepted")
olares-cli market resume firefox # fire-and-forget; returns once backend accepts
olares-cli market stop firefox --watch
olares-cli market stop firefox --cascade --watch # force cascade (also stop dependents)
olares-cli market stop ollamav2 --watch # auto-cascade for single-user CS apps (see SPA-parity note below)
olares-cli market stop ollamav2 --cascade=false --watch # force NO cascade on a CS app
olares-cli market resume firefox --watch
olares-cli market stop firefox --watch -o json # JSON; idempotent on already-stopped (see Idempotent shortcut)
olares-cli market stop firefox --watch -q # silent
olares-cli market stop firefox --watch --watch-interval 1s --watch-timeout 2m # snappy + tight cap
olares-cli market resume firefox --watch --watch-interval 1s --watch-timeout 2m
```
> **Bare invocation semantics** (`market stop firefox` / `market resume firefox` with **no other flags**): the CLI fires one `POST /apps/{app}/stop` (or `/resume`) and returns the moment the backend accepts the request — the row may still be in `stopping` / `resuming` when the CLI exits. The exit code reflects acceptance only (HTTP 2xx, request well-formed), **not** terminal landing. Use `--watch` if you need the CLI to block until the row reaches `stopped` / `running`, or re-attach after the fact with `olares-cli market status firefox --watch`.
`stop` shares the same auto-cascade rule as `uninstall` — see [Auto-cascade for CS apps](#auto-cascade-for-cs-apps-uninstall--stop) above. TL;DR: omit `--cascade` and the CLI checks `single-user && isCSV2`; if both hold, `--cascade` defaults to `true` and prints a one-line stderr explainer; otherwise stays `false`. The on-wire field is `all` (matches the SPA's stop payload), passed through `MarketClient.StopApp` ([`cli/cmd/ctl/market/client.go`](cli/cmd/ctl/market/client.go)).
```bash
olares-cli market cancel firefox --watch # cancel the in-flight op
olares-cli market cancel firefox --watch -o json # JSON; finalState is one of the *Canceled states
olares-cli market cancel firefox --watch -q # silent
olares-cli market cancel firefox --watch --watch-interval 1s --watch-timeout 2m # cancel is usually fast
```
`cancel` always sets `matchOpType=false` (it is itself op-agnostic) — see [`cli/cmd/ctl/market/cancel.go`](cli/cmd/ctl/market/cancel.go).
### Local sources (chart push)
`upload` and `delete` **always** target the SPA's "Local Sources → Upload" bucket (`chartUploadSource = "upload"` constant in [`cli/cmd/ctl/market/common.go`](cli/cmd/ctl/market/common.go)). Neither verb exposes `-s / --source` — passing `-s` to either is a flag-parse error from cobra. Rationale: the SPA's Local Sources tab manages a single `upload` bucket; offering the CLI's old `cli` / `studio` write targets meant a CLI-pushed chart could be invisible to the SPA, which broke every "upload then install via SPA" flow. Pinning the source eliminates that desync.
```bash
olares-cli market upload ./myapp-1.0.0.tgz # writes to source 'upload' (the only valid target)
olares-cli market upload ./charts/ # all .tgz / .tar.gz under dir
olares-cli market upload ./myapp-1.0.0.tgz -o json # JSON: per-file status=success/failed, includes filename + message
olares-cli market upload ./charts/ -q # silent; exit code aggregates per-file results
```
```bash
olares-cli market delete myapp # latest version in source 'upload'
olares-cli market delete myapp --version 1.0.0
olares-cli market delete myapp -o json # OperationResult-shaped: status=success on accepted delete
olares-cli market delete myapp -q # silent
```
`upload` does not run a chart — `install <app> -s upload` does (note the
new install source: previously `cli`, now `upload` to match the bucket
the CLI writes to). Unlike the lifecycle verbs, `upload` returns
`status="success"` immediately on accepted upload (there is no async
chart-store reconciliation to wait for) and `delete` does the same on
accepted delete. Neither supports `--watch`.
> **Migration note for existing scripts.** Anything passing `-s cli` /
> `-s studio` to `market upload` or `market delete` will now fail with
> `unknown flag: -s`. Drop the flag — the target is implicit. Charts
> that were previously uploaded to `cli` via older CLI revisions can
> still be **read** with `market list -s cli` but must be re-uploaded
> through the new path (which lands them in `upload`) for the SPA's
> Local Sources tab to see them.
<a id="lifecycle-output-shape"></a>
### Lifecycle output shape (`-o json` / `-q`)
Every mutating verb (`install`, `upgrade`, `uninstall`, `clone`, `stop`,
`resume`, `cancel`, `upload`, `delete`) emits exactly **one** JSON
document under `-o json` and **nothing** under `-q` (exit code carries
the signal). The struct is `OperationResult` in
[`cli/cmd/ctl/market/types.go`](cli/cmd/ctl/market/types.go):
```json
{
"app": "firefox",
"operation": "install",
"status": "accepted",
"message": "install requested for version 1.0.11",
"source": "market.olares",
"version": "1.0.11",
"user": "guotest458@olares.com"
}
```
The `status` field transitions through:
| Mode | `status` value | `finalState` / `finalOpType` | Exit code |
|------------------------------|------------------------------------------|------------------------------|-----------|
| Without `--watch` (accepted) | `"accepted"` | omitted | 0 |
| `--watch` reaches success | `"success"` | populated | 0 |
| `--watch` reaches failure | `"failed"` | populated (terminal state) | non-zero |
| `--watch` times out | `"failed"` (with timeout message) | last-seen state | non-zero |
| Request rejected pre-flight | `"failed"` (HTTP error or validation) | omitted | non-zero |
| `upload` / `delete` | `"success"` (no async lifecycle to wait) | n/a | 0 |
`finalState` / `finalOpType` carry the `omitempty` JSON tag so non-watch
invocations stay byte-identical to the pre-watch CLI release —
existing scripts that parse `OperationResult` continue to work
unchanged.
Useful one-liners:
```bash
# Read the final landing state (only present under --watch):
olares-cli market install firefox --watch -o json | jq -r '.finalState'
# Discover a clone's backend-assigned name:
olares-cli market clone firefox --title "FF" --watch -o json | jq -r '.targetApp'
# Quiet mode purely for CI gating:
if olares-cli market install firefox --watch -q; then
echo "install ok"
fi
```
`-q` ALSO swallows the stderr `info` lines that lifecycle verbs print
between transitions (`Installing 'firefox' ...`, `[firefox] state=running
op=install ...`); `-o json` swallows only `info` and reroutes the final
result to stdout as JSON.
<a id="watch-flag"></a>
## `--watch` (block until terminal state)
The synchronous CLI experience: lifecycle verbs return immediately when the backend accepts the request, but the actual mutation is asynchronous. `--watch` polls the same backend the SPA polls and only returns once the row reaches a terminal state (or the watcher gives up). Implementation lives in [`cli/cmd/ctl/market/watch.go`](cli/cmd/ctl/market/watch.go) (`waitForTerminal`, `runWithWatch`, `newWatchTarget`).
### Flags
Defined in [`cli/cmd/ctl/market/options.go`](cli/cmd/ctl/market/options.go) (`addWatchFlags`):
- **`-w / --watch`** — opt-in. Default **off**. When off, the verb prints
`OperationResult{status:"accepted"}` and returns immediately; the
backend lifecycle still runs asynchronously and the user must follow
up with `status --watch` (see [recovery](#status--watch-op-agnostic-recovery))
to confirm landing.
- **`--watch-timeout`** *(duration; default `15m`)* — total deadline
for the watch loop. If the row hasn't reached a terminal state by
this point, the watcher exits with `<op> '<app>' watch timed out
(last state: <S>, op: <O>)` and the process returns non-zero. The
mutation **is not canceled** on timeout (re-attach with `status
--watch`). Accepts Go-style durations: `90s`, `5m`, `30m`, `1h`,
`2h30m`. **No effect without `--watch`.** Pick by op cost: install
/ upgrade of image-pull-heavy charts on slow links → `30m`–`1h`;
stop / resume / cancel → `2m`–`5m` is plenty; uninstall → default
`15m` is usually fine.
- **`--watch-interval`** *(duration; default `2s`)* — polling cadence
for `GET /market/state`. The watcher fetches one snapshot per
interval, classifies it (see [Per-op terminal sets](#per-op-terminal-sets))
and either decides terminal or sleeps another `--watch-interval`.
Lower values (`500ms`, `1s`) give snappier CI feedback at the cost
of more backend load; raise to `5s`–`10s` on slow / metered networks
or when running many parallel watchers. **No effect without
`--watch`.** Note `--watch-interval` is **wall-clock**, not "tries
to reach terminal that many times" — the watcher quits the moment
the deadline computed from `--watch-timeout` passes regardless of
how many polls fit.
#### Tuning quick reference
| Scenario | `--watch-interval` | `--watch-timeout` | Why |
|-----------------------------------------------------------|--------------------|-------------------|------------------------------------------------------------------------------------|
| Default (interactive shell) | `2s` | `15m` | Built-in defaults; covers most installs on a normal home cluster. |
| Tight CI / e2e feedback | `1s` | `5m` | Faster pickup of terminal state; cap shorter so failed tests don't hang a job. |
| Image-pull-heavy install (Stable Diffusion, Ollama, ...) | `2s`–`5s` | `30m`–`1h` | Pulls can dominate; default 15m may timeout right before `running`. |
| Slow / metered network | `5s`–`10s` | `30m` | Cuts polling chatter, keeps deadline comfortable. |
| Stop / resume / cancel | `1s` | `2m` | Terminal state arrives in seconds; tight bounds catch real failures fast. |
| Uninstall (cascade-heavy, sub-charts to tear down) | `2s` | `15m` | Cascade work can serialize; default is OK, raise timeout if shared chart is large. |
### Per-op terminal sets
| Op | Success | Failure | `matchOpType` | `absentMeansSuccess` | `acceptInitialAbsent` | `idempotentSuccess` |
|--------------|----------------------------------------------|----------------------------------------------------------|---------------|----------------------|-----------------------|---------------------|
| `install` | `running` | `*Failed` ∪ `*Canceled` | true | false | false | false |
| `clone` | `running` | `*Failed` ∪ `*Canceled` | true | false | false | false |
| `upgrade` | `running` | `upgradeFailed` ∪ `*Canceled` | true | false | false | false |
| `uninstall` | `uninstalled` (or row absent) | `uninstallFailed` | true | true | **true** | false |
| `stop` | `stopped` | `stopFailed` | true | false | false | **true** |
| `resume` | `running` | `resumeFailed` / `resumingCanceled` / `resumingCancelFailed` | true | false | false | **true** |
| `cancel` | `*Canceled` ∪ `*Failed` ∪ `running` / `stopped` / `uninstalled` (or row absent) | `*CancelFailed` | **false** | **true** | false | false |
| `status` | `running` / `stopped` / `uninstalled` / `*Canceled` | `*Failed` / `*CancelFailed` | **false** | true | false | n/a (op-agnostic) |
The `*` columns expand to the matching state-set constants (`operationFailedStates`, `cancelFailedStates`, `canceledStates`) defined in [`cli/cmd/ctl/market/watch.go`](cli/cmd/ctl/market/watch.go).
### Tick-zero absent shortcut (`uninstall`)
`uninstall` is the only verb where the row being **absent from the very first poll** counts as terminal success. Other `absentMeansSuccess` verbs (currently just `status --watch`) require having seen the row at least once before treating its disappearance as success.
The driving scenario: multi-user CS app (e.g. `ollamav2` — v2 multi-chart with shared sub-charts).
1. `olares-cli market uninstall ollamav2 --watch` (no `--cascade`, multi-user default) clears the per-user row; the watch sees `uninstalling → row gone` and exits cleanly via the `seen-then-absent` path.
2. `olares-cli market uninstall ollamav2 --cascade --watch` re-runs to tear down the shared sub-charts. The backend accepts the DELETE, but the user's per-user row was already cleared in step 1 and does **not** re-appear in `/market/state` for the cascade-only pass. Without the tick-zero shortcut, the `seen` gate is never satisfied and the watcher would hang until `--watch-timeout` (~15m) and then exit with a timeout error.
`uninstall` therefore sets `acceptInitialAbsent = true` in `newWatchTarget`. Inside `waitForTerminal` the absent branch accepts either:
- `absentMeansSuccess ∧ seen` — canonical "we saw the row, then the backend pruned it" (covers both step-1 above and `status --watch` while an uninstall is in flight); **or**
- `absentMeansSuccess ∧ acceptInitialAbsent` (uninstall only) — tick-zero absence, covering step-2 above plus `uninstall` on an already-uninstalled app.
`status --watch` deliberately does **not** opt in: its production entry point (`runStatusSingle` in [`cli/cmd/ctl/market/status.go`](cli/cmd/ctl/market/status.go)) fetches the initial row before invoking `waitForTerminal`, so "first-poll absent" there would only ever mean "row just disappeared between the prefetch and our first poll" — already handled by the `seen` path. The classifier still independently refuses the tick-zero shortcut for `watchStatus` to keep that invariant intact even if a future caller wires status in differently (regression-guarded by `TestWaitForTerminalStatusDoesNotShortCircuitOnInitialAbsent`).
`install` / `upgrade` / `clone` / `cancel` likewise stay off the shortcut: tick-zero absence for those verbs means "not yet provisioned", not "done". A misclassification there would falsely report success on a row the backend hasn't even started yet.
Coverage: `TestWaitForTerminalUninstallAbsentFromStart` (the cascade-re-run hang case) and `TestWaitForTerminalUninstallAbsent` (seen-then-absent) in [`cli/cmd/ctl/market/watch_test.go`](cli/cmd/ctl/market/watch_test.go).
### Idempotent no-op shortcut (`stop` / `resume`)
Some ops are no-ops when the row is already at the target state:
- `market stop firefox --watch` when `firefox` is already `stopped`.
- `market resume firefox --watch` when `firefox` is already `running`.
The backend treats these requests as no-ops and **does not** bump the row's `OpType` to `stop` / `resume` — it simply leaves the row at `{state=stopped|running, opType=""}`. Under the default strict OpType gate the watcher would never see `OpType` flip to its target verb and would hang until `--watch-timeout` fires (~15m default), then exit with a timeout error. This was a real reported regression on `market stop firefox --watch`.
`stop` and `resume` therefore set `idempotentSuccess = true` in `newWatchTarget`. Inside `waitForTerminal` the classifier accepts:
- `state ∈ successSet ∧ matchesOpType(row)` — the normal lifecycle path (`stop → stopping → stopped, op=stop`); **or**
- `state ∈ successSet ∧ row.OpType == ""` (only when `idempotentSuccess`) — the no-op short-circuit (`stop` against already-`stopped`, `op=""`).
The shortcut **only** applies to the success set, never to the failure set: a stale `stopFailed` row with `OpType=""` from some prior lifecycle is intentionally classified as still-progressing rather than as a fresh failure of the request we just issued.
Crucially, `install` and `upgrade` deliberately do **not** opt into this shortcut. `{state=running, opType=""}` for those ops is ambiguous — it could mean "we're done" or "stale row from a previous install of a different version, new install op not yet picked up by the backend" — and the strict OpType gate is the only way to keep tick-zero classification race-safe. Coverage: `TestClassifierStopAlreadyStopped`, `TestClassifierResumeAlreadyRunning`, `TestClassifierInstallNoIdempotentShortcut`, plus end-to-end `TestWaitForTerminalStopOnAlreadyStopped` / `TestWaitForTerminalResumeOnAlreadyRunning` in [`cli/cmd/ctl/market/watch_test.go`](cli/cmd/ctl/market/watch_test.go).
### Broad terminal set for `cancel`
`cancel` deliberately has the **widest** success set of any verb — wider even than `status`. Once the cancel request has been accepted by the backend, the watcher is really asking "did the row stop moving?", not "did the row reach a specific terminal state". Classifying narrowly here would hang the watch on perfectly-settled rows.
The driving scenarios (all reproduced as regression tests):
- `market cancel firefox --watch` issued during `downloading`, but the download had already terminally failed before the cancel arrived. The row settles at `downloadFailed` and never visits any `*Canceled` state.
- `market cancel firefox --watch` issued during `installing`, partial-install rollback brings the chart to a stable `stopped` state.
- `market cancel firefox --watch` raced and lost: the underlying install completed before the DELETE landed. Row is at `running`. From the user's POV the cancel didn't prevent the install, but the row is **settled** and the watch should not hang — `OperationResult.State` will surface `running` so the caller can decide whether to redo (uninstall + reinstall) or accept.
- CS app cancel-during-install where backend rollback prunes the per-user row entirely. The watcher needs `absentMeansSuccess` (with the standard `seen` guard) to terminate cleanly.
The full success set for `watchCancel`:
- `canceledStates` — `*Canceled` (original semantics; what the SPA renders as "Canceled")
- `operationFailedStates` — `*Failed` (underlying op died terminally before / during cancel; cancel "won by default")
- `{running, stopped, uninstalled}` — stable resting states (cancel raced and lost OR rollback brought the row to a stable terminus)
- **plus** `absentMeansSuccess = true` (with `acceptInitialAbsent = false`, the safe `seen`-first variant)
Failure stays narrow on purpose: **only** `cancelFailedStates` (`*CancelFailed`) is classified as cancel-failure. That's the one and only signal that the cancel request itself was rejected by the backend — exit non-zero, surface to the caller, retry path is "wait for the in-flight op to finish, then act on the result".
This is intentionally different from `status --watch`, which treats `*Failed` as failure (because `status` is asking "is the app OK?"). For `cancel` the question is "did the cancel request land?", and a terminally-failed underlying op IS a kind of cancel-success — the row will never reach `running` and that's exactly what the user asked for.
Coverage: `TestClassifierCancelIgnoresOpType`, `TestClassifierCancelBroadTerminalSet`, plus end-to-end `TestWaitForTerminalCancelLifecycle`, `TestWaitForTerminalCancelLandsOnDownloadFailed`, `TestWaitForTerminalCancelLandsOnStopped`, `TestWaitForTerminalCancelStillFailsOnCancelFailed` in [`cli/cmd/ctl/market/watch_test.go`](cli/cmd/ctl/market/watch_test.go).
### `status --watch` (op-agnostic recovery)
The reason `status` got its own `--watch` even though it is read-only: a user runs `install firefox` *without* `--watch`, then five minutes later wants to know whether it landed. The recovery is:
```bash
olares-cli market status firefox --watch
```
The watcher uses `case watchStatus` in `newWatchTarget` (see [`cli/cmd/ctl/market/watch.go`](cli/cmd/ctl/market/watch.go)): any **stable** terminal state is success (the user did not declare an op, so any quiescent state is fine), and a row that has disappeared between ticks is also treated as success. `matchOpType` is forced to `false` so the CLI does not get stuck waiting for a specific OpType that is not its own.
`status --watch` requires an app name — see the errors table.
### OpType gating in practice
Concretely, `upgrade`'s watcher will not classify `state=running, opType=running` (or any leftover OpType from the previous mutation) as success. It waits until the backend reports `opType=upgrade` at least once, and only then accepts a `state=running` tick as terminal success. The same logic protects `install` after a previous `upgrade`, etc.
### Output semantics
| Mode | What the user sees |
|---------------------|-------------------------------------------------------------------------------------|
| TTY (`-o table`) | Per-transition `info` lines on stderr (`installing`, `running`, …) and final OK/Fail line. |
| `-o json` | Exactly **one** final `OperationResult` JSON document with the new `finalState` / `finalOpType` fields populated (`omitempty` keeps non-watch JSON output unchanged). |
| `-q / --quiet` | No transition lines, no final summary; exit code still reflects success/failure. |
### Ctrl-C and timeout
- The watch context is wrapped in `signal.NotifyContext` for `SIGINT` / `SIGTERM`. Pressing Ctrl-C exits cleanly with `<op> '<app>' watch canceled by user`. **The underlying mutation is NOT canceled** — re-attach with `status --watch` if needed.
- Timeout exits with `<op> '<app>' watch timed out (last state: <S>, op: <O>)`. The mutation again may still be running on the cluster; `status --watch` is the canonical recovery.
- After three consecutive transport errors the watcher gives up with `<op> '<app>' watch aborted after N consecutive errors`.
### Watch flow (mermaid)
```mermaid
flowchart TD
A[verb returns 200] --> B{--watch set?}
B -- no --> Z[print summary / exit]
B -- yes --> C[waitForTerminal loop]
C --> D[GetMarketState every --watch-interval]
D --> E{matchOpType ok?}
E -- waiting --> C
E -- ok --> F{state in successSet?}
F -- yes --> G[exit 0 + finalState]
F -- no --> H{state in failureSet?}
H -- yes --> I[exit 1 + finalState]
H -- no --> J{ctx done?}
J -- timeout --> K[watch timed out]
J -- signal --> L[watch canceled by user]
J -- no --> C
```
## Common errors → fixes
| Error message | Cause | Fix |
|-------------------------------------------------------------------------------------------|---------------------------------------------------------------------|------------------------------------------------------------------------|
| `server rejected the access token (HTTP 401/403)` | Profile token is expired / wrong / missing | Defer to [`../olares-shared/SKILL.md`](../olares-shared/SKILL.md) (login + profile rules) |
| `app 'X' is not installed (run 'olares-cli market install X' to install it)` | Row missing in every source the user has | It really is not installed — install it, or check spelling |
| `App is installed under source 'Y' (not 'X')` (info, not error) | The user passed `-s X` but the row lives in source `Y` | Re-run with `-s Y` for a clean filter, or `-a` to query every source |
| `--watch requires an app name (...)` | `status --watch` (or any lifecycle verb) was invoked without an app | Pass an app name, or drop `--watch` for the listing |
| `<op> '<app>' watch timed out (last state: <S>, op: <O>)` | `--watch-timeout` elapsed before terminal state | Bump `--watch-timeout`, or drill into the stuck state via `market status <app>` |
| `<op> '<app>' watch canceled by user` | User pressed Ctrl-C | The mutation is likely still running on the cluster — re-attach with `market status <app> --watch` |
| `<op> '<app>' watch aborted after N consecutive errors` | Network / proxy flake during polling | Check connectivity, then re-attach with `status --watch` |
| `--title is required for cloning` / `--title cannot exceed 30 characters` | `clone` was invoked without a valid title | Pass `--title "<= 30 chars>"` |
| `app '<app>' from source '<src>' does not support clone` | App is single-instance only | Verify with `market get <app>`; only `cloneable: true` apps clone |
| `invalid version '<v>'` | `--version` value is not semver (`MAJOR.MINOR.PATCH[-pre][+meta]`) | Use a valid semver string |
| `unknown flag: -s` (on `upload` / `delete`) | Legacy script still passes `-s` to `upload` / `delete` | Drop the flag — both verbs now hard-code source to `upload` (see Local sources). For a non-`upload` source, re-upload via the SPA. |
## Typical workflows
Install with watch (the happy path):
```bash
olares-cli market install firefox --watch --watch-timeout 30m
echo "exit=$?" # 0 only after firefox reports state=running with opType=install
```
Forgot `--watch` on install — recover via `status --watch`:
```bash
olares-cli market install firefox # backgrounded; CLI returns immediately
olares-cli market status firefox --watch # waits for any stable terminal state
```
Upgrade in place, scripted:
```bash
olares-cli market upgrade firefox --version 1.0.12 --watch -o json | jq '.finalState'
# expect "running"; finalOpType = "upgrade"
```
Clone with entrance titles:
```bash
olares-cli market clone firefox \
--title "Firefox (work)" \
--entrance-title firefox=Work \
--watch
```
Stop, then resume:
```bash
olares-cli market stop firefox --watch
olares-cli market resume firefox --watch
```
Cancel a stuck install and confirm the cancellation:
```bash
olares-cli market cancel firefox --watch # waits for *Canceled
olares-cli market status firefox --watch # confirms installingCanceled / uninstalled
```
Push a local chart, then install it from `cli` source:
```bash
olares-cli market upload ./myapp-1.0.0.tgz # writes to source 'upload' (-s is no longer accepted)
olares-cli market install myapp -s upload --watch
```
Inventory check — "what apps does this user have?":
```bash
olares-cli market list --mine # all sources by default
olares-cli market list --mine -s cli -o json | jq '.[].name'
```
Tight CI bounds — fail fast in pipelines:
```bash
# Snappy polling + tight cap so a flaky install doesn't burn a CI job slot.
# `--watch-timeout` is the deadline; `--watch-interval` is the cadence.
olares-cli market install firefox --watch \
--watch-interval 1s \
--watch-timeout 5m \
-o json | jq -e '.status == "success"'
```
Slow / metered network — back off the poll cadence, extend the deadline:
```bash
# Cut polling chatter, give image pulls room to finish.
olares-cli market install ollama-webui --watch \
--watch-interval 10s \
--watch-timeout 1h
```
> **`--watch-interval` and `--watch-timeout` are NO-OPs without `--watch`.**
> The watch loop is only spun up when `--watch` is passed; otherwise the
> verb returns immediately on backend acceptance and the polling knobs are
> silently ignored (cobra binds them either way). Don't rely on
> `--watch-timeout` as a fail-safe for a fire-and-forget invocation — pair
> it with `--watch` or it's just decoration.
## Security rules
- Confirm intent before `uninstall --delete-data` — this is **irreversible** on the user's volumes.
- Confirm intent before `uninstall --cascade` / `stop --cascade` — they fan out to every dependent chart and can take down adjacent apps.
- Never echo `<access_token>` into the terminal or into a script. The CLI already injects it via `X-Authorization`; if the agent thinks it needs to print the token, it is doing the wrong thing — read [`../olares-shared/SKILL.md`](../olares-shared/SKILL.md) instead.
- Treat `cancel` as a **request**, not a guarantee. The backend may have already finished the mutation by the time the cancel lands. Always re-confirm the actual landed state with `market status <app> --watch` before reporting "canceled" to the user.
- `--watch` on Ctrl-C / timeout exits the CLI but does **not** stop the cluster-side mutation. Communicate this clearly when surfacing a watch error.
## See also
- [`olares-shared`](../olares-shared/SKILL.md) — profile model, login, automatic token refresh, full auth-error recovery table. **Read this one first.**
- [`olares-files`](../olares-files/SKILL.md) — drive / sync / cache file browser, upload / download / share / chown.
- [`olares-settings`](../olares-settings/SKILL.md) — Olares Settings UI mirror (users, appearance, vpn, network, gpu, video, search, backup, restore, advanced, integration, apps).
- [`olares-dashboard`](../olares-dashboard/SKILL.md) — Olares Dashboard SPA proxy (overview, applications, GPU, fan, ranking).
- [`olares-cluster`](../olares-cluster/SKILL.md) — per-user Kubernetes view (pods / workloads / namespaces / jobs / cronjobs / nodes / middleware).
don't have the plugin yet? install it then click "run inline in claude" again.