How the Scope CLI is bundled, released, installed and updated from the public
microsoft/scope repository without internal repository dependencies.
The CLI is bundled into a single .mjs file using esbuild.
The manual release workflow builds and tests the bundle, then publishes it to
GitHub Releases in the same repository. The installer and updater use that public
release destination.
flowchart LR
A[microsoft/scope<br/>publish-cli.yml] -->|Build and test| B[Validated bundle]
B -->|GITHUB_TOKEN| C[microsoft/scope releases<br/>scope.mjs]
C -->|install-cli.sh| D[User workstation<br/>~/.local/bin/scope]
The build script lives at apps/cli/build.ts (TypeScript, run via tsx):
pnpm build:cli # from repo root
pnpm build # from apps/cli/This produces apps/cli/dist/scope.mjs (~1MB minified).
esbuild strips types without typechecking and treats unknown identifiers as globals, so a
type error or an undefined identifier bundles cleanly and only fails at runtime. To close that gap,
the CLI's build script runs tsc --noEmit before esbuild:
Because the OSS CI entry points invoke the CLI build script — pnpm build (pnpm -r build,
used by the CI Build job) and pnpm build:cli (used by the
CLI Bundle Integration Tests and release jobs) — the CLI is now typechecked automatically wherever it is
built, with no separate CI step. tsc requires the shared package's dist to exist; every one
of these entry points builds shared first (topologically for pnpm -r, explicitly for
build:cli), which esbuild already required, so there is no new ordering constraint.
Motivation: In PR #1151 an import of
normalizeUrlwas removed fromcriteria.tswhile a call site remained, soscope criteria exportthrewnormalizeUrl is not definedat runtime — yet CI stayed green because nothing typechecked the esbuild-bundled CLI.tsc --noEmitcatches this class of error (TS2304: Cannot find name 'normalizeUrl') and now fails the build.
apps/cli is the only esbuild-bundled TypeScript app; all other apps/workers/packages build with
tsc and are therefore already typechecked by pnpm build.
| Define | Source | Purpose |
|---|---|---|
process.env.SCOPE_CLI_VERSION |
apps/cli/package.json version |
Reported by --version |
There is no build-time or runtime default API destination. Both the bundle and
source-mode CLI require SCOPE_API_URL or a command's -u/--url option
(--api-url for MCP server create/update); the command-line option takes
precedence. Missing or blank URLs fail before an API request with configuration
guidance. SCOPE_DEFAULT_API_URL is no longer injected or read, and
SCOPE_API_PORT no longer derives a localhost destination. Local development
must also configure SCOPE_API_URL explicitly. Help, version, and CLI updates
remain available without API configuration.
| Plugin | Purpose |
|---|---|
strip-shebang |
Removes the source shebang so esbuild's banner shebang is the only one |
shim-react-devtools |
Stubs out react-devtools-core (optional Ink peer dep, not installed) |
- ESM format with a
createRequirepolyfill banner (CJS won't work due to Ink's top-level await) - All dependencies bundled — no
node_modulesneeded at runtime - Pure shared runtime imports — CLI gate constants and helpers come from
shared/typesandshared/gates, not the top-levelsharedbarrel. The barrel also initializes server-side modules with dynamic Redis imports that esbuild cannot bundle. Type-only imports fromsharedare safe because they are erased. - Node.js >= 20 required at runtime
Build first, then exercise the actual artifact:
pnpm build:cli
node apps/cli/dist/scope.mjs --version
pnpm exec vitest run --config vitest.integration.config.ts \
apps/cli/src/bundle.integration.test.ts \
apps/cli/src/utils/update-check.integration.test.tsBoth suites copy the bundle outside the checkout and run it from that temporary
directory using process.execPath, empty NODE_PATH/NODE_OPTIONS, and
--no-global-search-paths. This prevents pnpm's Vitest launcher from making
workspace dependencies available to the child process and hiding missing bundled
modules. Coverage includes the extensionless installed scope executable, mock
API commands, missing-URL failures, explicit URL precedence, offline help/version,
and update checks; no release is published by these tests.
The release workflow sorts valid cli/v* tags in microsoft/scope semantically
and bumps the highest version by the selected patch, minor or major increment.
Only when there are no matching tags does it bootstrap from the validated
apps/cli/package.json version's major/minor/patch components, logging that
decision and removing the development prerelease suffix before bumping. With the current
0.0.0-dev baseline, the default minor bump produces 0.1.0. Invalid tags,
invalid package versions, and tag-read/fetch failures fail the workflow rather
than masquerading as an empty release history.
The version is written only in the release workspace before building; it is not
committed back to main. The release tag targets the exact checked-out source SHA.
Workflow-level concurrency serializes version selection through publication,
without cancelling an in-progress release.
- Local builds produce
0.0.0-dev— clearly indicating a dev build. - Dev mode (
pnpm cli) reports0.1.0-dev. - Release builds carry the selected version number.
- The
cli/v*prefix allows other monorepo components to have their own tag namespaces.
Maintainers manually dispatch Publish CLI
on upstream main and select a bump type (default: minor). The read-only build
job checks out full tag history, installs locked dependencies, sets the version,
runs the typechecked bundle build and bundle integration tests, and checks the
bundle's reported version. The separate publish job downloads that exact artifact
and creates cli/v<version> with scope.mjs attached.
Only the publish job gets contents: write, using this repository's
GITHUB_TOKEN. Fork repositories and non-main refs cannot publish. No FLUX app,
internal release repository, cloud environment, OIDC or custom secret is needed.
Repository rules must permit the workflow token to create release tags; rules
are not changed by this workflow. No release exists until a maintainer explicitly
runs it successfully.
Use the public installer with Node.js >= 20 and curl:
curl --fail --location https://raw.githubusercontent.com/microsoft/scope/main/install-cli.sh | bashThe installer selects a published, non-prerelease cli/v* release and downloads
scope.mjs with bounded retries. It verifies the reported version before
atomically replacing ~/.local/bin/scope (SCOPE_INSTALL_DIR overrides the
directory). Missing releases, API/download errors and version mismatches fail
explicitly without replacing an existing installation. Installation does not
require GitHub authentication; public API rate limits still apply.
The published website's onboarding pages use this same URL.
website/install-cli.sh remains a compatibility entry point: it downloads
the canonical root script completely before running it, rather than
maintaining another release lookup or installation implementation.
scope update retains its gh release download implementation, so updating
in-place requires gh configured with GitHub authentication. Alternatively,
rerun the public installer without gh.
After each command, the CLI performs a non-blocking check for newer versions:
- Checks
cli/v*releases inmicrosoft/scope(2s per background lookup attempt) - Compares the current embedded version against the latest release tag
- If newer, prints a one-line notice with the upgrade command
- Suppressed by
SCOPE_NO_UPDATE_CHECK=1 - Uses
ghwhen available, falling back to the public REST API;GH_TOKENorGITHUB_TOKENis optional for that fallback
This is the one place in the CLI that calls fetch directly rather than the
centralized apiFetch() wrapper (apps/cli/src/utils/api-client.ts): it targets the
external GitHub API with its own token auth and must never receive the Scope
SCOPE_TOKEN bearer that apiFetch() injects. All Scope-API requests go through
apiFetch() (see auth-rbac.md subtask 7).
SCOPE_TOKEN remains a raw IdP access token, not a Scope-issued credential.
Existing enrolled users are compatible; a new identity must intentionally call
POST /api/v1/users/me before ordinary authenticated commands. The enrollment
POST is no-store and must not be prefetched/polled. Interactive CLI login/keychain
support remains deferred; see CLI authentication guidance.
apiFetch() is a thin facade over the ky
HTTP client: ky owns the underlying transport (a cached ky.create() instance with a
beforeRequest auth hook), while the facade keeps the CLI-specific concerns — URL
normalization, the single 401 re-auth retry, redacted logging, and ApiError
shaping. update-check.ts stays on raw fetch precisely because it must bypass that
auth hook.
For scoped operations the facade also appends the resolved project as a
?projectId= query param (see Project scoping below): pass { projectId } in the
apiFetch init and it is URL-encoded onto the query and stripped from the ky init (a
blank value is a no-op). This is the single seam through which every scoped command
inherits project scoping.
Source: apps/cli/src/utils/update-check.ts
Every user-facing entity carries an immutable projectId
(Data Organization: Projects), so the
CLI must know which project a scoped command targets. The scope project group
manages the selection:
| Command | Purpose |
|---|---|
scope project list [--include-deleted] |
List projects (optionally including soft-deleted ones) |
scope project create --name <name> [--description <text>] [--use] |
Create a project (--use selects it after creating) |
scope project show |
Show the currently selected project |
scope project use <id> |
Persist the selected project to ~/.config/scope/config.json |
scope project update <id> [--name <name>] [--description <text>] |
Update a project's name or description |
scope project delete <id> |
Soft-delete a project |
scope project restore <id> |
Restore a soft-deleted project |
Scoped commands (run list/run submit, and every entity list/search/create/
import) resolve the effective project with this precedence:
--project <id>flag (per-invocation override)SCOPE_PROJECTenvironment variable- the saved selection from
scope project use <id>
There is no default project. When none of these resolves, scoped commands
fail fast: requireProjectId() throws and the top-level handler in index.ts
prints a clean Error: No project selected… line and exits 1 — no request is
issued. Point reads and by-_id mutations (e.g. run get -i <id>) are globally
unique and need no project. See
SCOPE_PROJECT for the env-var reference.
| Aspect | Dev (pnpm cli) |
Bundled (scope) |
|---|---|---|
| Runner | tsx (TypeScript direct) | Node.js (single .mjs) |
| API default | None; configure SCOPE_API_URL or -u |
None; configure SCOPE_API_URL or -u |
| Version | 0.1.0-dev |
Embedded package version (0.0.0-dev locally) |
| Command name | pnpm cli |
scope |
| Update check | Disabled | Enabled |