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
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,14 @@ npm install -g @reply-team/reply-cli # newest internal build (@latest)
npm install -g @reply-team/reply-cli@0.3.0 # a specific build
```

`reply install` works on an internal build too, and keeps you on the internal
channel: it reads the package name it is running as, so it will never move you
between the two. Because the internal package lives on GitHub Packages, the
registry line and the `read:packages` token above have to be in place — the
command reminds you of both if the update fails. What it compares against is the
newest release of any kind, pre-releases included, which is exactly the internal
stream; the public channel compares against the promoted release instead.

## Releases

Releases are automated with [semantic-release](https://semantic-release.gitbook.io/).
Expand Down
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ contacts, and the inbox are on the way.

## Installation

Requires [Node.js](https://nodejs.org) 20 or newer. Install globally from npm:
`reply` runs on [Node.js](https://nodejs.org) **20 or newer** — check yours with
`node --version`, and install or upgrade Node first if it is older. Then install
the CLI globally from npm:

```sh
npm install -g reply-cli
Expand All @@ -18,6 +20,41 @@ npm install -g reply-cli
reply --version
```

That is the whole installation. There is nothing else to run.

## Staying up to date

One command keeps the CLI current:

```sh
reply install
```

It looks up the newest release, and when it can update your copy safely it runs
npm for you and reports the result:

```
✓ reply 0.4.0 → 0.5.0 installed
```

Already on the newest release, it says so and does nothing. Where the copy is
not ours to change — installed inside a project, run through `npx`, or built
from a checkout — it prints the exact command that fits your setup and leaves
everything alone. It exits non-zero whenever an update exists and was not
applied, so `reply install --dry-run` works as a check in CI.

`reply --version` mentions a newer release when there is one:

```
0.4.0
reply 0.4.0 → 0.5.0 available · run `reply install`
```

That check reads the public GitHub releases, is cached for a day, times out
after a second and a half, and stays silent when it fails. It never runs for any
other command, and never at all with `--json`, when output is piped, in CI, or
with `REPLY_NO_UPDATE_CHECK=1` set.

## Usage

```sh
Expand Down Expand Up @@ -199,6 +236,7 @@ Reply.io login (`reply auth login`) to actually do anything.
| `REPLY_PROFILE` | Profile to use (same as `--profile`) |
| `REPLY_TEAM_ID` | Team/workspace id sent as `X-TEAM-ID` (same as `--team-id`) |
| `REPLY_CONFIG_DIR` | Config directory (default `~/.config/reply`; `%APPDATA%\reply` on Windows) |
| `REPLY_NO_UPDATE_CHECK` | Set to `1` to never check whether a newer release exists |

## Contributing

Expand Down
129 changes: 129 additions & 0 deletions src/__tests__/commands/install.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import {describe, it, expect, beforeEach, vi} from 'vitest';
import path from 'path';

const mock_run_install = vi.hoisted(()=>vi.fn());
vi.mock('../../selfupdate/install', ()=>({run_install: mock_run_install}));

import {handle_install, install_command} from '../../commands/install';
import type {Install_report} from '../../selfupdate/install';
import {RuntimeError} from '../../utils/errors';

const MODULE_DIR = path.join(path.parse(process.cwd()).root, 'usr', 'lib', 'node_modules', 'reply-cli');

const report = (over: Partial<Install_report> = {}): Install_report=>({
current: '0.4.0',
latest: '0.5.0',
up_to_date: false,
channel: 'public',
install: {kind: 'npm-global', package: 'reply-cli', path: MODULE_DIR},
action: 'updated',
command: 'npm install -g reply-cli@latest',
note: `Installed globally with npm (${MODULE_DIR}).`,
...over,
});

const capture = async(fn: ()=>unknown | Promise<unknown>): Promise<{out: string; err: string}>=>{
const out: string[] = [];
const err: string[] = [];
const log = console.log;
const error = console.error;
const write = process.stdout.write;
console.log = (...a: unknown[])=>{ out.push(a.join(' ')); };
console.error = (...a: unknown[])=>{ err.push(a.join(' ')); };
process.stdout.write = ((c: unknown): boolean=>{ out.push(String(c)); return true; }) as typeof process.stdout.write;
try { await fn(); } finally { console.log = log; console.error = error; process.stdout.write = write; }
const clean = (s: string[]): string=>s.join('\n').replace(/\x1b\[[0-9;]*m/g, '').trim();
return {out: clean(out), err: clean(err)};
};

beforeEach(()=>{ vi.clearAllMocks(); });

describe('handle_install', ()=>{
it('keeps stdout clean and reports the update on stderr', async()=>{
mock_run_install.mockResolvedValue(report());
const {out, err} = await capture(()=>handle_install({}));
expect(out).toBe('');
expect(err).toContain('reply 0.4.0 → 0.5.0 installed');
});

it('says so when nothing needs doing, and exits zero', async()=>{
mock_run_install.mockResolvedValue(report({action: 'current', up_to_date: true, latest: '0.4.0'}));
const {err} = await capture(()=>handle_install({}));
expect(err).toContain('reply 0.4.0 is the newest release');
});

it('prints the exact command and fails when it cannot update', async()=>{
mock_run_install.mockResolvedValue(report({
action: 'manual',
install: {kind: 'npx', package: 'reply-cli', path: MODULE_DIR},
command: 'npx reply-cli@latest',
note: 'Running through npx, which resolves the newest published version on each run.',
}));
let thrown: unknown;
const {err} = await capture(async()=>{
await handle_install({}).catch((e: unknown)=>{thrown = e;});
});
expect(err).toContain('npx reply-cli@latest');
expect(err).toContain('Running through npx');
expect(thrown).toBeInstanceOf(RuntimeError);
expect(thrown).toMatchObject({exit_code: 1, code: 'update.manual'});
});

it('reports why npm failed, shows what npm said, and offers the elevated command', async()=>{
mock_run_install.mockResolvedValue(report({
action: 'failed',
detail: 'npm exited with code 243 (permission denied)',
command: 'sudo npm install -g reply-cli@latest',
npm_output: 'npm error code EACCES\nnpm error syscall mkdir',
}));
let thrown: unknown;
const {err} = await capture(async()=>{
await handle_install({}).catch((e: unknown)=>{thrown = e;});
});
expect(err).toContain('Could not update automatically: npm exited with code 243 (permission denied).');
expect(err).toContain('npm error syscall mkdir');
expect(err).toContain('sudo npm install -g reply-cli@latest');
expect(thrown).toMatchObject({code: 'update.npm_failed'});
});

it('does not narrate progress under --json', async()=>{
mock_run_install.mockResolvedValue(report());
await capture(()=>handle_install({json: true}));
expect(mock_run_install.mock.calls[0][1]).toEqual({progress: undefined});
});

it('marks a dry run as having changed nothing, and still exits 1', async()=>{
mock_run_install.mockResolvedValue(report({action: 'manual'}));
let thrown: unknown;
const {err} = await capture(async()=>{
await handle_install({dryRun: true}).catch((e: unknown)=>{thrown = e;});
});
expect(mock_run_install.mock.calls[0][0]).toEqual({dry_run: true});
expect(err).toContain('--dry-run');
expect(thrown).toBeInstanceOf(RuntimeError);
});

it('puts the report on stdout under --json and no prose anywhere', async()=>{
mock_run_install.mockResolvedValue(report());
const {out, err} = await capture(()=>handle_install({json: true}));
expect(JSON.parse(out)).toMatchObject({action: 'updated', current: '0.4.0', latest: '0.5.0'});
expect(err).toBe('');
});

it('indents the report under --pretty', async()=>{
mock_run_install.mockResolvedValue(report());
const {out} = await capture(()=>handle_install({pretty: true}));
expect(out).toContain('\n "action": "updated"');
});
});

describe('the install command surface', ()=>{
it('answers to update as well, so muscle memory works', ()=>{
expect(install_command.name()).toBe('install');
expect(install_command.aliases()).toContain('update');
});

it('offers --dry-run', ()=>{
expect(install_command.options.map(o=>o.long)).toContain('--dry-run');
});
});
87 changes: 87 additions & 0 deletions src/__tests__/selfupdate/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {describe, it, expect} from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {update_check_file} from '../../config';
import {
cache_is_fresh,
read_check_cache,
write_check_cache,
type Check_cache,
} from '../../selfupdate/cache';

// Every test gets its own config dir, so nothing reads or writes the real one.
const sandbox = (): Record<string, string>=>
({REPLY_CONFIG_DIR: fs.mkdtempSync(path.join(os.tmpdir(), 'reply-update-cache-'))});

const at = (iso: string): Date=>new Date(iso);
const NOW = at('2026-08-01T12:00:00.000Z');
const entry = (over: Partial<Check_cache> = {}): Check_cache=>
({version: 1, channel: 'public', latest: '0.5.0', checked_at: NOW.toISOString(), ...over});

describe('the update-check cache', ()=>{
it('returns what was written', ()=>{
const env = sandbox();
write_check_cache(entry(), env);
expect(read_check_cache(env)).toEqual(entry());
});

it('reports nothing when the file has never been written', ()=>{
expect(read_check_cache(sandbox())).toBeUndefined();
});

it('treats a corrupt file as never checked instead of throwing', ()=>{
const env = sandbox();
fs.writeFileSync(update_check_file(env), '{ not json', 'utf8');
expect(read_check_cache(env)).toBeUndefined();
});

it('treats an unexpected shape as never checked', ()=>{
const env = sandbox();
fs.writeFileSync(update_check_file(env), '[]', 'utf8');
expect(read_check_cache(env)).toBeUndefined();
});

it('leaves no temporary file behind', ()=>{
const env = sandbox();
write_check_cache(entry(), env);
const left = fs.readdirSync(env.REPLY_CONFIG_DIR);
expect(left).toEqual(['update-check.json']);
});

it('creates the config directory when it does not exist yet', ()=>{
const env = {REPLY_CONFIG_DIR: path.join(os.tmpdir(), `reply-cache-new-${process.pid}-${Math.trunc(NOW.getTime())}`)};
fs.rmSync(env.REPLY_CONFIG_DIR, {recursive: true, force: true});
write_check_cache(entry(), env);
expect(read_check_cache(env)?.latest).toBe('0.5.0');
fs.rmSync(env.REPLY_CONFIG_DIR, {recursive: true, force: true});
});
});

describe('cache_is_fresh', ()=>{
it('is fresh inside the success window and stale past it', ()=>{
expect(cache_is_fresh(entry(), 'public', at('2026-08-02T11:00:00.000Z'))).toBe(true);
expect(cache_is_fresh(entry(), 'public', at('2026-08-02T13:00:00.000Z'))).toBe(false);
});

it('backs off for an hour after a failure, not a day', ()=>{
const failed = entry({checked_at: undefined, failed_at: NOW.toISOString()});
expect(cache_is_fresh(failed, 'public', at('2026-08-01T12:30:00.000Z'))).toBe(true);
expect(cache_is_fresh(failed, 'public', at('2026-08-01T14:00:00.000Z'))).toBe(false);
});

it('uses the failure window even when a last known version is kept', ()=>{
const failed = entry({failed_at: NOW.toISOString()});
expect(cache_is_fresh(failed, 'public', at('2026-08-01T14:00:00.000Z'))).toBe(false);
});

it('is stale when the cached channel is not the one being asked about', ()=>{
expect(cache_is_fresh(entry(), 'internal', NOW)).toBe(false);
});

it('is stale with no entry, no timestamp, or an unparseable one', ()=>{
expect(cache_is_fresh(undefined, 'public', NOW)).toBe(false);
expect(cache_is_fresh(entry({checked_at: undefined}), 'public', NOW)).toBe(false);
expect(cache_is_fresh(entry({checked_at: 'yesterday'}), 'public', NOW)).toBe(false);
});
});
Loading
Loading