Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 69 additions & 4 deletions apps/docs/content/docs/cli/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -114,15 +114,80 @@ shared profile cannot also set its own endpoint or API key.
| `SIM_API_KEY` | API key — skips `sim login` entirely |
| `SIM_WORKSPACE` | Workspace to target |
| `SIM_OUTPUT` | Output format |
| `SIM_CONFIG_DIR` | Relocate both files away from `~/.sim` |
| `SIM_CONFIG_DIR` | Relocate the config directory and update cache; file-specific overrides below still win |
| `SIM_CONFIG_FILE` | Relocate only the config file |
| `SIM_CREDENTIALS_FILE` | Relocate only the credentials file |
| `SIM_TIMEOUT_SECONDS` | Per-request timeout; `0` waits indefinitely. Defaults to `3600`, above every timeout the server itself applies |
| `SIM_DEBUG` | Trace each request's method, URL, status and duration to stderr |
| `SIM_NO_UPDATE_CHECK` | Turn off update checks |

Node ignores `HTTPS_PROXY` unless you also set `NODE_USE_ENV_PROXY=1`, and only
from Node 22.21 and 24.5. The CLI warns when a proxy is configured but will not
be used.
## Update notices

On eligible invocations, the CLI uses a daily cache before asking
`registry.npmjs.org` what is published under the `latest` tag. Prerelease
installs are skipped entirely, so a `-preview` or `-dev` build is never told to
upgrade. When a newer one exists, it prints a single line on stderr naming both
versions and the command that upgrades:

```
Update available: sim 2.1.2 → 2.1.5. Run: npm install -g sim@latest
```

Comment thread
mzxchandra marked this conversation as resolved.
Apart from the configured registry URL, the request identifies only the CLI
version — no Sim API key, workspace, or command — and it never follows a
redirect away from the registry it asked.

One caveat worth stating plainly: if you point `npm_config_registry` at a
private mirror, the check goes to that mirror instead of npm. Query-string
credentials (an Artifactory or Nexus `?token=…`, for example) are preserved and
sent as part of the configured registry request — they have to be, or the
mirror would reject it. As with other registry traffic, configured proxies or
TLS inspection can observe what that network setup permits. A registry URL
containing username/password userinfo, such as
`https://user:password@registry.example`, is rejected and no update check is
made.

An empty or whitespace-only `npm_config_registry` is treated as unset, so the
public registry remains the default. Non-empty malformed and non-HTTP(S) values
disable the update check rather than making an unexpected public request.

The notice is skipped entirely when:

- `SIM_NO_UPDATE_CHECK` is set to anything but `0` or `false`
- stderr is not a terminal, so redirected and piped output is never affected
- a CI environment variable is present (`CI`, `GITHUB_ACTIONS`, `JENKINS_URL`,
`TEAMCITY_VERSION`, `BUILDKITE`)
- the CLI is running under `npm exec` or `npx`, which may use a project-local or
ephemeral package where global-install advice is inappropriate
- the CLI is running from a checkout of the sim repository, whose version
deliberately trails the published one
- the installed version is a prerelease

The daily pace comes from a timestamp in the config directory's
`update-check.json`: `~/.sim/update-check.json` by default, or under
`SIM_CONFIG_DIR` when that is set. `SIM_CONFIG_FILE` and
`SIM_CREDENTIALS_FILE` do not move the cache, so it may not sit beside a file
relocated with either of those variables.

This throttle is best-effort across processes. Two commands that start together
can both see a stale cache and check. Cache replacement is atomic, so either
complete write can win without leaving a partially interleaved file. If the
cache cannot be written — for example, because the config directory is
read-only — every eligible invocation attempts a check because there is no
timestamp to reuse.

The registry check has a one-second deadline. On expiry, the CLI terminates its
short-lived request process so stalled DNS, connection, or response work cannot
remain active and delay the command. `SIM_NO_UPDATE_CHECK=1` still turns the
check off.

The command the notice prints matches how Sim was installed — `npm install -g`,
`pnpm add -g`, `bun add -g`, or `yarn global add` — so running it updates the
executable already on your `PATH` rather than installing a second copy under a
different package manager.

Node's `fetch` uses `HTTP(S)_PROXY` when opted in with `NODE_USE_ENV_PROXY=1`
(Node 22.21+ or 24.0+) or `--use-env-proxy` (Node 22.21+ or 24.5+).

For CI, set `SIM_API_KEY` and `SIM_WORKSPACE` and nothing needs to touch the
filesystem at all.
Expand Down
46 changes: 46 additions & 0 deletions apps/docs/content/docs/cli/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ title: Troubleshooting
description: The failures whose cause is not obvious from the error message
---

import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

Errors print one line to stderr, prefixed `Error:`, and exit `1` — except
`sim whoami`, which exits `2` when it could not reach the API to check at all.
Most say what to do next; the cases below are the ones that do not.
Expand Down Expand Up @@ -85,6 +87,50 @@ editing the file by hand:
sim --output table configure --set-output json
```

## A command is missing that the documentation describes

The docs track the current release, so a command that exists here and not in
`sim --help` usually means the installed CLI is older than the feature. Compare
`sim --version` against the published version and upgrade:

```bash
sim --version
```

Then upgrade with the package manager you installed it with — using a different
one installs a second copy instead of replacing the executable on your `PATH`:

<Tabs items={['npm', 'pnpm', 'bun']}>
<Tab value="npm">
```bash
npm install -g sim@latest
```
</Tab>
<Tab value="pnpm">
```bash
pnpm add -g sim@latest
```
</Tab>
<Tab value="bun">
```bash
bun add -g sim@latest
```
</Tab>
</Tabs>

The CLI can also tell you this through a cached daily check on eligible
invocations, and the command it prints already matches your installation. It
stays quiet when stderr is redirected, in CI, and under `npm exec` or `npx`.

## An update notice appears in output I am parsing

It should not: the notice is written to stderr, never stdout, so `--output json`
piped to `jq` is unaffected. If something merges the two streams, silence it:

```bash
export SIM_NO_UPDATE_CHECK=1
```

## Anything else

An unexpected error prints a stack trace. That is a bug in the CLI — please
Expand Down
19 changes: 18 additions & 1 deletion packages/sim-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,9 +256,26 @@ The main environment variables are:
| `SIM_API_KEY` | API key, usually for CI |
| `SIM_WORKSPACE` | Workspace to target |
| `SIM_OUTPUT` | `table`, `json`, `yaml`, or `text` |
| `SIM_CONFIG_DIR` | Directory containing CLI config and credentials |
| `SIM_CONFIG_DIR` | Base directory for CLI config, credentials, and the update cache |
| `SIM_TIMEOUT_SECONDS` | Per-request timeout; `0` waits indefinitely |
| `SIM_DEBUG` | Print request diagnostics to stderr |
| `SIM_NO_UPDATE_CHECK` | Turn off the update notice |

On eligible interactive invocations, `sim` uses a daily cache before asking
`registry.npmjs.org` what is published under the `latest` tag and prints one
line on stderr when a newer version exists. Prerelease installs are skipped
entirely. The cache lives in `~/.sim` by default and follows `SIM_CONFIG_DIR`;
without a writable cache, each eligible invocation checks again. Concurrent
invocations can also perform duplicate checks. The registry request has a
one-second deadline; the short-lived request process is terminated on expiry.
Apart from the configured registry URL, it sends only its own version and never
your Sim API key. If `npm_config_registry` points at a private mirror, its query
string is preserved, including any query-string credentials. Registry URLs
containing username/password userinfo are rejected. Set
`SIM_NO_UPDATE_CHECK=1` to turn it off. Empty or whitespace-only registry values
use the public default; non-empty malformed or non-HTTP(S) values fail closed.
The full list of cases where it stays quiet is in the
[configuration guide](https://docs.sim.ai/cli/configuration).

## Documentation

Expand Down
14 changes: 14 additions & 0 deletions packages/sim-cli/src/config/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,17 @@ export function configPath(): string {
export function credentialsPath(): string {
return process.env.SIM_CREDENTIALS_FILE || join(configDir(), 'credentials')
}

/**
* Where the once-a-day update check remembers that it ran.
*
* Cache, not configuration, so it is safe to delete at any time and gets no
* `SIM_*` override of its own: nobody relocates a cache deliberately, and
* `SIM_CONFIG_DIR` already moves it for the two callers that matter — the test
* harness and anyone keeping `~/.sim` somewhere else. It is kept out of the
* config file because that file is INI the user edits, and a timestamp inside a
* `[profile x]` section would surface in `sim configure` and `sim whoami`.
*/
export function updateCachePath(): string {
return join(configDir(), 'update-check.json')
}
75 changes: 73 additions & 2 deletions packages/sim-cli/src/program.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
/**
* @vitest-environment node
*/
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Command } from 'commander'
import { describe, expect, it } from 'vitest'
import { buildProgram } from './program'
import { CLI_VERSION } from './version'

/** Parses argv against a program whose output and exits are captured, not taken. */
async function parse(argv: string[]): Promise<{ out: string; code: string | null }> {
const program = buildProgram()
async function parse(
argv: string[],
program: Command = buildProgram()
): Promise<{ out: string; code: string | null }> {
let out = ''
const capture = (command: Command) => {
command.exitOverride()
Expand Down Expand Up @@ -159,3 +164,69 @@ describe('help typed after a command that does not exist', () => {
expect(implicit.out).toContain('Usage: sim profiles add')
})
})

/** Commander keeps lifecycle hooks on a private field and offers no getter. */
function preActionHooks(program: Command): Array<(a: Command, b: Command) => unknown> {
const { _lifeCycleHooks: hooks } = program as Command & {
_lifeCycleHooks?: Record<string, Array<(a: Command, b: Command) => unknown>>
}
return hooks?.preAction ?? []
}

describe('the update check', () => {
/**
* The notice must cost `--version` and `--help` nothing. Commander answers
* both during parsing, before any action hook runs, so the guarantee is
* structural — this holds it in place if the check is ever moved.
*
* It swaps in a sentinel hook rather than watching for a request or a cache
* file. Those side effects never appear from inside a checkout no matter
* what runs, because the check suppresses itself there — so asserting on
* them would pass even if the hook fired, which is precisely the regression
* this is meant to catch.
*/
it('fires no preAction hook for the two commands commander answers while parsing', async () => {
let fired = 0
const program = buildProgram()
const hooks = preActionHooks(program)
expect(hooks).toHaveLength(1)
hooks.splice(0, hooks.length, () => {
fired += 1
})

await parse(['--version'], program)
await parse(['--help'], program)
expect(fired).toBe(0)

const dir = mkdtempSync(join(tmpdir(), 'sim-cli-program-'))
const previousConfigDir = process.env.SIM_CONFIG_DIR
process.env.SIM_CONFIG_DIR = dir
try {
await parse(['configure', '--set-output', 'json'], program)
expect(fired).toBe(1)
} finally {
if (previousConfigDir === undefined) Reflect.deleteProperty(process.env, 'SIM_CONFIG_DIR')
else process.env.SIM_CONFIG_DIR = previousConfigDir
rmSync(dir, { recursive: true, force: true })
}
})

/**
* The positive half, and the one that matters: without it the hook can be
* deleted from `buildProgram` and every other test still passes.
*
* It asserts registration rather than a resulting request, because the check
* suppresses itself when it is running from a checkout — and inside this
* suite `import.meta.url` IS a checkout, so the behavioural path is
* unreachable here by construction. That path is covered directly in
* check.test.ts and walked against the real registry from a staged global
* install before release.
*/
it('registers the update check as a root preAction hook', async () => {
const program = buildProgram()
const preAction = preActionHooks(program)

expect(preAction).toHaveLength(1)
await expect(preAction[0](program, program)).resolves.toBeUndefined()
})
})
3 changes: 3 additions & 0 deletions packages/sim-cli/src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
buildGeneratedCommands,
refuseHelpAfterUnknownCommand,
} from './runtime/build'
import { announceUpdateIfAvailable } from './update/check'
import { CLI_VERSION } from './version'

/** Root program description, shared by `--help` and the generated docs. */
Expand Down Expand Up @@ -151,6 +152,8 @@ export function buildProgram(options: { version?: boolean } = {}): Command {

program.addHelpText('after', HELP_EPILOGUE)

program.hook('preAction', () => announceUpdateIfAvailable())

refuseHelpAfterUnknownCommand(program)
assertNoReservedProgramFlags(program)

Expand Down
Loading
Loading