toiljs 0.0.31 → 0.0.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli/index.ts CHANGED
@@ -9,6 +9,7 @@ import { build, dev, start } from 'toiljs/compiler';
9
9
  import { runConfigure } from './configure.js';
10
10
  import { runCreate, type Template } from './create.js';
11
11
  import { runDoctor } from './doctor.js';
12
+ import { notifyIfOutdated } from './notify.js';
12
13
  import { runUpdate } from './update.js';
13
14
  import { type Preprocessor, PREPROCESSORS } from './features.js';
14
15
  import { accent, banner, bold, danger, dim, success, version } from './ui.js';
@@ -147,6 +148,9 @@ function printHelp(): void {
147
148
  cmd('--target <t>', 'update: latest | minor | patch | newest | greatest'),
148
149
  cmd('-v, --version', 'print the toiljs version'),
149
150
  '',
151
+ dim(' Every command checks npm for a newer toiljs and warns when outdated.'),
152
+ dim(' Set TOILJS_NO_UPDATE_CHECK=1 to opt out.'),
153
+ '',
150
154
  ].join('\n') + '\n',
151
155
  );
152
156
  }
@@ -161,6 +165,11 @@ async function main(): Promise<void> {
161
165
 
162
166
  const flags = parseArgs(rest);
163
167
 
168
+ // Every invocation (including `npm run dev` / `npm run build`, which call this CLI)
169
+ // checks that the installed toiljs is the latest release. Warns on stderr, never blocks
170
+ // the command; opt out with TOILJS_NO_UPDATE_CHECK=1.
171
+ await notifyIfOutdated(flags.root);
172
+
164
173
  switch (command) {
165
174
  case 'create':
166
175
  banner();
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Automatic update check, run on every CLI invocation (so `npm run dev` / `npm run build`,
3
+ * which call the toiljs CLI, are covered too). Asks the npm registry for the latest toiljs
4
+ * release (answer cached for an hour in the user's cache dir, network capped at 2 seconds),
5
+ * then warns on stderr when the project's installed toiljs or the running CLI is behind.
6
+ * Never throws and never fails the command. Opt out with TOILJS_NO_UPDATE_CHECK=1 (also
7
+ * honors NO_UPDATE_NOTIFIER and CI).
8
+ */
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ import { detectPackageManager } from './update.js';
15
+ import { accent, bold, box, dim, version as cliVersion, warn } from './ui.js';
16
+ import {
17
+ findOutdated,
18
+ isCacheFresh,
19
+ type OutdatedRow,
20
+ parseCheckCache,
21
+ } from './version-check.js';
22
+
23
+ const REGISTRY_URL = 'https://registry.npmjs.org/toiljs/latest';
24
+ const FETCH_TIMEOUT_MS = 2000;
25
+
26
+ /** Where the registry answer is cached: `$XDG_CACHE_HOME`/`~/.cache` + `toiljs/`. */
27
+ function cacheFile(): string {
28
+ const base = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache');
29
+ return path.join(base, 'toiljs', 'update-check.json');
30
+ }
31
+
32
+ /** Asks the npm registry for the latest published version; null on any failure. */
33
+ async function fetchLatest(): Promise<string | null> {
34
+ const ctrl = new AbortController();
35
+ const timer = setTimeout(() => {
36
+ ctrl.abort();
37
+ }, FETCH_TIMEOUT_MS);
38
+ try {
39
+ const res = await fetch(REGISTRY_URL, {
40
+ signal: ctrl.signal,
41
+ headers: { accept: 'application/json' },
42
+ });
43
+ if (!res.ok) return null;
44
+ const data: unknown = await res.json();
45
+ if (typeof data !== 'object' || data === null) return null;
46
+ const v = (data as Record<string, unknown>).version;
47
+ return typeof v === 'string' ? v : null;
48
+ } catch {
49
+ return null;
50
+ } finally {
51
+ clearTimeout(timer);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * The latest published toiljs version, from the cache when fresh, otherwise from the registry.
57
+ * Failures are cached too (as null) so an offline machine backs off for the TTL instead of
58
+ * paying the fetch timeout on every command.
59
+ */
60
+ async function resolveLatest(): Promise<string | null> {
61
+ const file = cacheFile();
62
+ try {
63
+ const cached = parseCheckCache(fs.readFileSync(file, 'utf8'));
64
+ if (cached && isCacheFresh(cached, Date.now())) return cached.latest;
65
+ } catch {}
66
+ const latest = await fetchLatest();
67
+ try {
68
+ fs.mkdirSync(path.dirname(file), { recursive: true });
69
+ fs.writeFileSync(file, JSON.stringify({ latest, checkedAt: Date.now() }));
70
+ } catch {}
71
+ return latest;
72
+ }
73
+
74
+ /** Reads the version of the toiljs resolved in the project's node_modules, if any. */
75
+ function projectToiljsVersion(root: string): string | null {
76
+ try {
77
+ const raw = fs.readFileSync(
78
+ path.join(root, 'node_modules', 'toiljs', 'package.json'),
79
+ 'utf8',
80
+ );
81
+ const parsed: unknown = JSON.parse(raw);
82
+ if (typeof parsed !== 'object' || parsed === null) return null;
83
+ const v = (parsed as Record<string, unknown>).version;
84
+ return typeof v === 'string' ? v : null;
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ function noticeLines(latest: string, rows: OutdatedRow[]): string {
91
+ const header = warn('⚠ ') + bold(`a newer toiljs is available: ${accent(latest)}`);
92
+ const body = rows.map((row) => {
93
+ const where = row.scope === 'project' ? 'this project has' : 'your global CLI is';
94
+ return `${where} ${row.installed}${dim(', update with')} ${accent(row.command)}`;
95
+ });
96
+ return '\n' + box([header, '', ...body], warn) + '\n';
97
+ }
98
+
99
+ /**
100
+ * Checks the project install and the running CLI against the latest release and prints a
101
+ * warning to stderr when either is behind. Safe to await unconditionally: bounded by the
102
+ * cache TTL + fetch timeout, and it swallows every error.
103
+ */
104
+ export async function notifyIfOutdated(rootArg: string | undefined): Promise<void> {
105
+ try {
106
+ const env = process.env;
107
+ if (env.TOILJS_NO_UPDATE_CHECK || env.NO_UPDATE_NOTIFIER || env.CI) return;
108
+
109
+ const latest = await resolveLatest();
110
+ if (!latest) return;
111
+
112
+ const root = path.resolve(rootArg ?? process.cwd());
113
+ const cliDir = path.dirname(fileURLToPath(import.meta.url));
114
+ const cliIsLocal = cliDir.startsWith(path.join(root, 'node_modules') + path.sep);
115
+ const rows = findOutdated(
116
+ latest,
117
+ projectToiljsVersion(root),
118
+ cliVersion(),
119
+ cliIsLocal,
120
+ detectPackageManager(root).name,
121
+ );
122
+ if (rows.length === 0) return;
123
+ process.stderr.write(noticeLines(latest, rows));
124
+ } catch {}
125
+ }
package/src/cli/ui.ts CHANGED
@@ -102,10 +102,69 @@ export function version(): string {
102
102
  return '0.0.0';
103
103
  }
104
104
 
105
- /** Prints the brand banner: gradient logo + tagline + version. */
105
+ // eslint-disable-next-line no-control-regex -- matching our own escape sequences is the point
106
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
107
+
108
+ /** The on-screen width of `s`, ignoring ANSI color codes. */
109
+ function visibleWidth(s: string): number {
110
+ return s.replace(ANSI_RE, '').length;
111
+ }
112
+
113
+ /**
114
+ * Frames already-colored lines in a rounded box sized to the widest line. `paint` colors the
115
+ * border (the content keeps its own colors); padding is measured on the visible text, so ANSI
116
+ * codes inside the lines never skew the right edge. Returns the box without a trailing newline.
117
+ */
118
+ export function box(lines: readonly string[], paint: (s: string) => string = (s) => s): string {
119
+ const width = lines.reduce((w, l) => Math.max(w, visibleWidth(l)), 0);
120
+ const side = paint('│');
121
+ const body = lines.map(
122
+ (l) => ` ${side} ${l}${' '.repeat(width - visibleWidth(l))} ${side}`,
123
+ );
124
+ return [
125
+ ' ' + paint(`╭${'─'.repeat(width + 4)}╮`),
126
+ ...body,
127
+ ' ' + paint(`╰${'─'.repeat(width + 4)}╯`),
128
+ ].join('\n');
129
+ }
130
+
131
+ /**
132
+ * Banner taglines, one is picked at random per invocation. Each is a function so the accented
133
+ * words pick up the brand color (or stay plain when color is disabled). The theme: TOIL is the
134
+ * first full-stack framework for a globally distributed application delivery network.
135
+ */
136
+ const TAGLINES: ReadonlyArray<(a: (s: string) => string) => string> = [
137
+ (a) => `the most performant ${a('react')} framework`,
138
+ (a) => `bringing ${a('hyper scale')} to anyone`,
139
+ (a) => `the first full-stack ${a('application delivery network')}`,
140
+ (a) => `your app, ${a('globally distributed')} by default`,
141
+ (a) => `one build, ${a('the whole planet')}`,
142
+ (a) => `full stack, ${a('zero distance')} to your users`,
143
+ (a) => `${a('react')} up front, ${a('wasm')} at every edge`,
144
+ (a) => `deployed where your ${a('users')} are`,
145
+ (a) => `the framework with a ${a('delivery network')} built in`,
146
+ (a) => `no regions, just ${a('the world')}`,
147
+ (a) => `${a('planet-scale')} apps from a single repo`,
148
+ (a) => `every request served ${a('next door')}`,
149
+ (a) => `frontend, backend, ${a('worldwide')}`,
150
+ (a) => `${a('hyper scale')} without the ops team`,
151
+ (a) => `your backend, ${a('compiled to wasm')}, running everywhere`,
152
+ (a) => `the internet is your ${a('runtime')}`,
153
+ (a) => `the speed of light is the ${a('only bottleneck')}`,
154
+ (a) => `static speed, ${a('dynamic everything')}`,
155
+ (a) => `scale to ${a('millions')} before lunch`,
156
+ (a) => `latency is a choice, choose ${a('zero')}`,
157
+ (a) => `build ${a('better')}, ship ${a('faster')}`,
158
+ ];
159
+
160
+ /** A random brand tagline, accent words colored. */
161
+ export function tagline(): string {
162
+ return TAGLINES[Math.floor(Math.random() * TAGLINES.length)](brand);
163
+ }
164
+
165
+ /** Prints the brand banner: gradient logo + random tagline + version. */
106
166
  export function banner(): void {
107
167
  const lines = colorEnabled() ? ART.map(gradientLine) : ART.slice();
108
- const tagline = ` the most performant ${brand('react')} framework`;
109
168
  const ver = `${dim(' v')}${brand(version())}`;
110
- process.stdout.write('\n' + lines.join('\n') + '\n\n' + tagline + ' ' + ver + '\n\n');
169
+ process.stdout.write('\n' + lines.join('\n') + '\n\n ' + tagline() + ' ' + ver + '\n\n');
111
170
  }
package/src/cli/update.ts CHANGED
@@ -22,13 +22,13 @@ export interface UpdateOptions {
22
22
  readonly target?: string;
23
23
  }
24
24
 
25
- interface PackageManager {
25
+ export interface PackageManager {
26
26
  readonly name: string;
27
27
  readonly ncuName: string;
28
28
  }
29
29
 
30
30
  /** Detects the package manager from the project's lockfile (defaults to npm). */
31
- function detectPackageManager(root: string): PackageManager {
31
+ export function detectPackageManager(root: string): PackageManager {
32
32
  if (fs.existsSync(path.join(root, 'pnpm-lock.yaml'))) return { name: 'pnpm', ncuName: 'pnpm' };
33
33
  if (fs.existsSync(path.join(root, 'yarn.lock'))) return { name: 'yarn', ncuName: 'yarn' };
34
34
  if (fs.existsSync(path.join(root, 'bun.lockb'))) return { name: 'bun', ncuName: 'bun' };
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Pure helpers for the automatic toiljs update check that runs on every CLI invocation
3
+ * (and therefore on `npm run dev` / `npm run build`, which call the CLI). IO-free so it
4
+ * can be unit-tested; the registry fetch, cache file, and printing live in `notify.ts`.
5
+ */
6
+
7
+ /** How long a registry answer is trusted before we ask npm again. */
8
+ export const CHECK_TTL_MS = 60 * 60 * 1000;
9
+
10
+ /** What we persist between runs: the latest known version and when we asked. */
11
+ export interface CheckCache {
12
+ readonly latest: string | null;
13
+ readonly checkedAt: number;
14
+ }
15
+
16
+ /** Parses the cache file contents; returns null when malformed (forces a fresh check). */
17
+ export function parseCheckCache(raw: string): CheckCache | null {
18
+ try {
19
+ const parsed: unknown = JSON.parse(raw);
20
+ if (typeof parsed !== 'object' || parsed === null) return null;
21
+ const o = parsed as Record<string, unknown>;
22
+ const latest = typeof o.latest === 'string' ? o.latest : null;
23
+ const checkedAt = typeof o.checkedAt === 'number' ? o.checkedAt : NaN;
24
+ if (!Number.isFinite(checkedAt)) return null;
25
+ return { latest, checkedAt };
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /** True when the cached answer is still trustworthy (also stale if the clock went backwards). */
32
+ export function isCacheFresh(cache: CheckCache, now: number, ttlMs: number = CHECK_TTL_MS): boolean {
33
+ return cache.checkedAt <= now && now - cache.checkedAt < ttlMs;
34
+ }
35
+
36
+ interface Parsed {
37
+ readonly nums: readonly [number, number, number];
38
+ readonly pre: string | null;
39
+ }
40
+
41
+ function parseSemver(v: string): Parsed {
42
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(v.trim());
43
+ if (!m) return { nums: [0, 0, 0], pre: null };
44
+ return { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null };
45
+ }
46
+
47
+ /**
48
+ * Compares two semver strings: negative when `a < b`, 0 when equal, positive when `a > b`.
49
+ * A prerelease sorts below its release (`0.1.0-beta.1 < 0.1.0`); two prereleases compare
50
+ * lexicographically, which is enough for an update nudge.
51
+ */
52
+ export function compareSemver(a: string, b: string): number {
53
+ const pa = parseSemver(a);
54
+ const pb = parseSemver(b);
55
+ for (let i = 0; i < 3; i++) {
56
+ if (pa.nums[i] !== pb.nums[i]) return pa.nums[i] < pb.nums[i] ? -1 : 1;
57
+ }
58
+ if (pa.pre === pb.pre) return 0;
59
+ if (pa.pre === null) return 1;
60
+ if (pb.pre === null) return -1;
61
+ return pa.pre < pb.pre ? -1 : 1;
62
+ }
63
+
64
+ /** True when `installed` is behind `latest`. */
65
+ export function isOutdated(installed: string, latest: string): boolean {
66
+ return compareSemver(installed, latest) < 0;
67
+ }
68
+
69
+ /** The command that updates toiljs for the project's package manager, or globally. */
70
+ export function installCommand(pm: string, scope: 'project' | 'global'): string {
71
+ if (scope === 'global') {
72
+ if (pm === 'pnpm') return 'pnpm add -g toiljs@latest';
73
+ if (pm === 'yarn') return 'yarn global add toiljs@latest';
74
+ if (pm === 'bun') return 'bun add -g toiljs@latest';
75
+ return 'npm install -g toiljs@latest';
76
+ }
77
+ if (pm === 'pnpm') return 'pnpm add toiljs@latest';
78
+ if (pm === 'yarn') return 'yarn add toiljs@latest';
79
+ if (pm === 'bun') return 'bun add toiljs@latest';
80
+ return 'npm install toiljs@latest';
81
+ }
82
+
83
+ /** One out-of-date install we want to nag about. */
84
+ export interface OutdatedRow {
85
+ /** Where the stale copy lives: the project's node_modules or the global CLI. */
86
+ readonly scope: 'project' | 'global';
87
+ readonly installed: string;
88
+ readonly command: string;
89
+ }
90
+
91
+ /**
92
+ * Decides what (if anything) to warn about. `projectVersion` is the toiljs resolved in the
93
+ * project's node_modules (null when not installed there), `cliVersion` is the copy of the CLI
94
+ * actually running, and `cliIsLocal` tells us whether those are the same install.
95
+ */
96
+ export function findOutdated(
97
+ latest: string,
98
+ projectVersion: string | null,
99
+ cliVersion: string,
100
+ cliIsLocal: boolean,
101
+ pm: string,
102
+ ): OutdatedRow[] {
103
+ const rows: OutdatedRow[] = [];
104
+ if (projectVersion !== null && isOutdated(projectVersion, latest)) {
105
+ rows.push({
106
+ scope: 'project',
107
+ installed: projectVersion,
108
+ command: installCommand(pm, 'project'),
109
+ });
110
+ }
111
+ // The running CLI only gets its own row when it is not the project install we already
112
+ // reported (global installs, or `npx toiljs` outside a project).
113
+ if (!cliIsLocal && isOutdated(cliVersion, latest)) {
114
+ rows.push({
115
+ scope: 'global',
116
+ installed: cliVersion,
117
+ command: installCommand('npm', 'global'),
118
+ });
119
+ }
120
+ return rows;
121
+ }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { box, tagline } from '../src/cli/ui';
4
+
5
+ describe('tagline', () => {
6
+ it('always yields a non-empty line, whichever variant is drawn', () => {
7
+ for (let i = 0; i < 100; i++) {
8
+ const t = tagline();
9
+ expect(t.length).toBeGreaterThan(0);
10
+ expect(t).not.toContain('undefined');
11
+ }
12
+ });
13
+ });
14
+
15
+ describe('box', () => {
16
+ it('frames lines in a rounded box sized to the widest line', () => {
17
+ expect(box(['hello', 'hi'])).toBe(
18
+ [
19
+ ' ╭─────────╮',
20
+ ' │ hello │',
21
+ ' │ hi │',
22
+ ' ╰─────────╯',
23
+ ].join('\n'),
24
+ );
25
+ });
26
+
27
+ it('pads on visible width, ignoring ANSI color codes', () => {
28
+ const colored = '\x1b[1mhello\x1b[22m';
29
+ const lines = box([colored, 'hi']).split('\n');
30
+ // Both content rows must end at the same column once colors are stripped.
31
+ const stripped = lines.map((l) => l.replace(/\x1b\[[0-9;]*m/g, ''));
32
+ expect(new Set(stripped.map((l) => l.length)).size).toBe(1);
33
+ });
34
+
35
+ it('paints only the border', () => {
36
+ const out = box(['hi'], (s) => `<${s}>`);
37
+ expect(out).toContain('<│> hi <│>');
38
+ expect(out).toContain('<╭──────╮>');
39
+ });
40
+ });
@@ -0,0 +1,130 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ CHECK_TTL_MS,
5
+ compareSemver,
6
+ findOutdated,
7
+ installCommand,
8
+ isCacheFresh,
9
+ isOutdated,
10
+ parseCheckCache,
11
+ } from '../src/cli/version-check';
12
+
13
+ describe('compareSemver', () => {
14
+ it('orders plain versions', () => {
15
+ expect(compareSemver('0.0.31', '0.0.32')).toBeLessThan(0);
16
+ expect(compareSemver('0.0.32', '0.0.31')).toBeGreaterThan(0);
17
+ expect(compareSemver('0.0.31', '0.0.31')).toBe(0);
18
+ expect(compareSemver('0.9.9', '0.10.0')).toBeLessThan(0);
19
+ expect(compareSemver('1.0.0', '0.99.99')).toBeGreaterThan(0);
20
+ });
21
+
22
+ it('treats a prerelease as older than its release', () => {
23
+ expect(compareSemver('0.1.0-beta.1', '0.1.0')).toBeLessThan(0);
24
+ expect(compareSemver('0.1.0', '0.1.0-beta.1')).toBeGreaterThan(0);
25
+ expect(compareSemver('0.1.0-alpha', '0.1.0-beta')).toBeLessThan(0);
26
+ });
27
+
28
+ it('tolerates a leading v and garbage input', () => {
29
+ expect(compareSemver('v1.2.3', '1.2.3')).toBe(0);
30
+ expect(compareSemver('garbage', '0.0.0')).toBe(0);
31
+ });
32
+ });
33
+
34
+ describe('isOutdated', () => {
35
+ it('is true only when installed is behind latest', () => {
36
+ expect(isOutdated('0.0.31', '0.0.32')).toBe(true);
37
+ expect(isOutdated('0.0.32', '0.0.32')).toBe(false);
38
+ // A local build ahead of the registry must not warn.
39
+ expect(isOutdated('0.0.33', '0.0.32')).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe('parseCheckCache', () => {
44
+ it('parses a valid cache entry', () => {
45
+ expect(parseCheckCache('{"latest":"0.0.32","checkedAt":1000}')).toEqual({
46
+ latest: '0.0.32',
47
+ checkedAt: 1000,
48
+ });
49
+ });
50
+
51
+ it('keeps a cached failure (latest null) so offline machines back off', () => {
52
+ expect(parseCheckCache('{"latest":null,"checkedAt":1000}')).toEqual({
53
+ latest: null,
54
+ checkedAt: 1000,
55
+ });
56
+ });
57
+
58
+ it('rejects malformed contents', () => {
59
+ expect(parseCheckCache('not json')).toBeNull();
60
+ expect(parseCheckCache('[]')).toBeNull();
61
+ expect(parseCheckCache('{"latest":"0.0.32"}')).toBeNull();
62
+ expect(parseCheckCache('{"latest":42,"checkedAt":"soon"}')).toBeNull();
63
+ });
64
+ });
65
+
66
+ describe('isCacheFresh', () => {
67
+ const cache = { latest: '0.0.32', checkedAt: 10_000 };
68
+
69
+ it('is fresh within the TTL and stale after it', () => {
70
+ expect(isCacheFresh(cache, 10_000 + CHECK_TTL_MS - 1)).toBe(true);
71
+ expect(isCacheFresh(cache, 10_000 + CHECK_TTL_MS)).toBe(false);
72
+ });
73
+
74
+ it('is stale when the clock went backwards past checkedAt', () => {
75
+ expect(isCacheFresh(cache, 9_999)).toBe(false);
76
+ });
77
+ });
78
+
79
+ describe('installCommand', () => {
80
+ it('targets the project with the detected package manager', () => {
81
+ expect(installCommand('npm', 'project')).toBe('npm install toiljs@latest');
82
+ expect(installCommand('pnpm', 'project')).toBe('pnpm add toiljs@latest');
83
+ expect(installCommand('yarn', 'project')).toBe('yarn add toiljs@latest');
84
+ expect(installCommand('bun', 'project')).toBe('bun add toiljs@latest');
85
+ });
86
+
87
+ it('targets the global install', () => {
88
+ expect(installCommand('npm', 'global')).toBe('npm install -g toiljs@latest');
89
+ expect(installCommand('pnpm', 'global')).toBe('pnpm add -g toiljs@latest');
90
+ });
91
+ });
92
+
93
+ describe('findOutdated', () => {
94
+ it('flags an outdated project install with the project command', () => {
95
+ const rows = findOutdated('0.0.32', '0.0.31', '0.0.31', true, 'pnpm');
96
+ expect(rows).toEqual([
97
+ { scope: 'project', installed: '0.0.31', command: 'pnpm add toiljs@latest' },
98
+ ]);
99
+ });
100
+
101
+ it('flags an outdated global CLI even when the project is current', () => {
102
+ const rows = findOutdated('0.0.32', '0.0.32', '0.0.30', false, 'npm');
103
+ expect(rows).toEqual([
104
+ { scope: 'global', installed: '0.0.30', command: 'npm install -g toiljs@latest' },
105
+ ]);
106
+ });
107
+
108
+ it('flags both when a stale global CLI runs inside a stale project', () => {
109
+ const rows = findOutdated('0.0.32', '0.0.31', '0.0.30', false, 'npm');
110
+ expect(rows.map((r) => r.scope)).toEqual(['project', 'global']);
111
+ });
112
+
113
+ it('does not double-report when the running CLI is the project install', () => {
114
+ const rows = findOutdated('0.0.32', '0.0.31', '0.0.31', true, 'npm');
115
+ expect(rows).toHaveLength(1);
116
+ expect(rows[0].scope).toBe('project');
117
+ });
118
+
119
+ it('reports nothing when everything is current or ahead', () => {
120
+ expect(findOutdated('0.0.32', '0.0.32', '0.0.32', true, 'npm')).toEqual([]);
121
+ expect(findOutdated('0.0.32', null, '0.0.33', false, 'npm')).toEqual([]);
122
+ });
123
+
124
+ it('handles a missing project install (npx outside a project)', () => {
125
+ const rows = findOutdated('0.0.32', null, '0.0.31', false, 'npm');
126
+ expect(rows).toEqual([
127
+ { scope: 'global', installed: '0.0.31', command: 'npm install -g toiljs@latest' },
128
+ ]);
129
+ });
130
+ });