Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ea28425
feat: add upgrade notification when newer CLI version is available
claude Jan 30, 2026
49a22bb
test: add unit tests for version check utility
claude Feb 1, 2026
16eb79f
chore: remove comment from runCommand.ts
github-actions[bot] Feb 1, 2026
95bc93d
refactor: move version-check from src/core to src/cli
github-actions[bot] Feb 1, 2026
e1b4f96
refactor: move upgrade notification to beginning of command
github-actions[bot] Feb 1, 2026
e58f957
test: rewrite version-check tests to use testkit pattern
github-actions[bot] Feb 1, 2026
9b9da8d
Merge branch 'main' into claude/add-upgrade-notification-IF6u3
gonengar Feb 2, 2026
fe39b9d
fix: add shell flag to npm version check execa call
github-actions[bot] Feb 2, 2026
4ff5804
fix: Make version-check tests work with bundled dist
gonengar Feb 2, 2026
d7fd7fa
docs: Document test overrides mechanism in AGENTS.md
gonengar Feb 2, 2026
f24f127
chore: Remove unnecessary comments
gonengar Feb 2, 2026
f903c19
chore: Remove JSDoc comments from givenLatestVersion
gonengar Feb 2, 2026
4984b96
refactor: Replace NegatedCLIResultMatcher with toNotContain method
gonengar Feb 2, 2026
76f0980
refactor: Simplify toContain with expected parameter
gonengar Feb 2, 2026
f7a4a7d
refactor: Use separate toNotContain method for readability
gonengar Feb 2, 2026
0029e0e
refactor: Extract shared getTestOverrides utility
gonengar Feb 2, 2026
c3228b3
fix: Remove unnecessary optional chain
gonengar Feb 2, 2026
0fca9ec
Merge branch 'main' into claude/add-upgrade-notification-IF6u3
kfirstri Feb 2, 2026
dc0da09
chore: reduce version check timeout to 500ms and add CI env var
github-actions[bot] Feb 2, 2026
62049c4
refactor: move TestOverrides to Zod schema with validation
github-actions[bot] Feb 2, 2026
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
44 changes: 44 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -841,9 +841,53 @@ t.api.mockAgentsPushError({ status: 401, body: { error: "..." } });
t.api.mockSiteDeployError({ status: 413, body: { error: "..." } });
```

### Test Overrides (`BASE44_CLI_TEST_OVERRIDES`)

The CLI uses a centralized JSON-based override mechanism for tests. When adding new testable behaviors that need mocking, **extend this existing mechanism** rather than creating new environment variables.

**Current overrides:**
- `appConfig` - Mock app configuration (id, projectRoot)
- `latestVersion` - Mock version check response (string for newer version, null for no update)

**Adding new overrides:**

1. Add the field to `TestOverrides` interface in `CLITestkit.ts`:
```typescript
interface TestOverrides {
appConfig?: { id: string; projectRoot: string };
latestVersion?: string | null;
myNewOverride?: MyType; // Add here
}
```

2. Add a `given*` method to `CLITestkit`:
```typescript
givenMyOverride(value: MyType): void {
this.testOverrides.myNewOverride = value;
}
```

3. Expose it in `testkit/index.ts` `TestContext` interface and implementation.

4. Read the override in your source code:
```typescript
function getTestOverride(): MyType | undefined {
const overrides = process.env.BASE44_CLI_TEST_OVERRIDES;
if (!overrides) return undefined;
try {
return JSON.parse(overrides).myNewOverride;
} catch {
return undefined;
}
}
```

**Why not vi.mock()?** Tests run against the bundled `dist/index.js` where path aliases are resolved. `vi.mock("@/some/path.js")` won't match the bundled code.

### Testing Rules

1. **Build first** - Run `npm run build` before `npm test`
2. **Use fixtures** - Don't create project structures in tests
3. **Fixtures need `.app.jsonc`** - Add `base44/.app.jsonc` with `{ "id": "test-app-id" }`
4. **Interactive prompts can't be tested** - Only test via non-interactive flags
5. **Use test overrides** - Extend `BASE44_CLI_TEST_OVERRIDES` for new testable behaviors; don't create new env vars
3 changes: 3 additions & 0 deletions src/cli/utils/runCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { isCLIError } from "@/core/errors.js";
import { login } from "@/cli/commands/auth/login-flow.js";
import { printBanner } from "@/cli/utils/banner.js";
import { theme } from "@/cli/utils/theme.js";
import { printUpgradeNotificationIfAvailable } from "@/cli/utils/upgradeNotification.js";

export interface RunCommandOptions {
/**
Expand Down Expand Up @@ -73,6 +74,8 @@ export async function runCommand(
intro(theme.colors.base44OrangeBackground(" Base 44 "));
}

await printUpgradeNotificationIfAvailable();

try {
// Check authentication if required
if (options?.requireAuth) {
Expand Down
25 changes: 25 additions & 0 deletions src/cli/utils/upgradeNotification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { log } from "@clack/prompts";
import type { UpgradeInfo } from "@/cli/utils/version-check.js";
import { checkForUpgrade } from "@/cli/utils/version-check.js";
import { theme } from "@/cli/utils/theme.js";

function formatUpgradeMessage(info: UpgradeInfo): string {
const { shinyOrange } = theme.colors;
const { bold } = theme.styles;

return `${shinyOrange("Update available!")} ${shinyOrange(`${info.currentVersion} β†’ ${info.latestVersion}`)} ${shinyOrange("Run:")} ${bold(shinyOrange("npm update -g base44"))}`;
}

/**
* Checks for available upgrades and prints a notification if one exists.
*/
export async function printUpgradeNotificationIfAvailable(): Promise<void> {
try {
const upgradeInfo = await checkForUpgrade();
if (upgradeInfo) {
log.message(formatUpgradeMessage(upgradeInfo));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude i see were using log.message, and the printUpgradeNotificationIfAvailable is called in runCommand but after we already do outro meaning it will be a weird hevaior, maybe we can do it at the begining?

Copy link

@claude claude bot Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude finished @kfirstri's task in 47s β€”β€” View job


Completed

  • Read runCommand.ts to understand current placement
  • Read upgradeNotification.ts to understand implementation
  • Move notification call to beginning of command
  • Commit and push changes

Moved the upgrade notification to display right after the intro/banner, before the main command logic runs. This prevents the notification from appearing after the outro, which would have created strange behavior where messages appear after the command completion message.

The notification now appears at the beginning of the command flow, making it more visible and avoiding any confusion with the outro message.


| Branch

}
} catch {
// Silently ignore errors
}
}
39 changes: 39 additions & 0 deletions src/cli/utils/version-check.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude this whole file should be inside the src/cli folder because it's not related to the core/ of the product

Copy link

@claude claude bot Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude finished @kfirstri's task in 1m 34s β€”β€” View job


Completed

  • Read current file structure and imports
  • Move src/core/utils/version-check.ts to src/cli/utils/version-check.ts
  • Update import path in upgradeNotification.ts
  • Move test file to tests/cli/version-check.spec.ts
  • Commit and push changes

Moved version-check.ts from src/core/utils to src/cli/utils and updated all related imports. The test file has also been moved from tests/core to tests/cli to match the new structure.

--- | Branch

Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { execa } from "execa";
import packageJson from "../../../package.json";
import { getTestOverrides } from "@/core/config.js";

export interface UpgradeInfo {
currentVersion: string;
latestVersion: string;
}

export async function checkForUpgrade(): Promise<UpgradeInfo | null> {
const testLatestVersion = getTestOverrides()?.latestVersion;
if (testLatestVersion !== undefined) {
if (testLatestVersion === null) {
return null;
}
const currentVersion = packageJson.version;
if (testLatestVersion !== currentVersion) {
return { currentVersion, latestVersion: testLatestVersion };
}
return null;
}

try {
const { stdout } = await execa("npm", ["view", "base44", "version"], {
timeout: 500,
shell: true,
env: { CI: "1" },
});
const latestVersion = stdout.trim();
const currentVersion = packageJson.version;

if (latestVersion !== currentVersion) {
return { currentVersion, latestVersion };
}
return null;
} catch {
return null;
}
}
15 changes: 15 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
import { PROJECT_SUBDIR } from "@/core/consts.js";
import { TestOverridesSchema, type TestOverrides } from "@/core/project/schema.js";

Check failure on line 5 in src/core/config.ts

View workflow job for this annotation

GitHub Actions / lint

Prefer using a top-level type-only import instead of inline type specifiers

// After bundling, import.meta.url points to dist/cli/index.js
// Templates are copied to dist/cli/templates/
Expand Down Expand Up @@ -30,3 +31,17 @@
export function getBase44ApiUrl(): string {
return process.env.BASE44_API_URL || "https://app.base44.com";
}

export function getTestOverrides(): TestOverrides | null {
const raw = process.env.BASE44_CLI_TEST_OVERRIDES;
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw);
const result = TestOverridesSchema.safeParse(parsed);
return result.success ? result.data : null;
} catch {
return null;
}
}
23 changes: 5 additions & 18 deletions src/core/project/app-config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { globby } from "globby";
import { getAppConfigPath } from "@/core/config.js";
import { getAppConfigPath, getTestOverrides } from "@/core/config.js";
import { writeFile, readJsonFile } from "@/core/utils/fs.js";
import { APP_CONFIG_PATTERN } from "@/core/consts.js";
import { AppConfigSchema } from "@/core/project/schema.js";
Expand All @@ -18,24 +18,11 @@ export interface CachedAppConfig {

let cache: CachedAppConfig | null = null;

/**
* Load app config from BASE44_CLI_TEST_OVERRIDES env var.
* @returns true if override was applied, false otherwise
*/
function loadFromTestOverrides(): boolean {
const overrides = process.env.BASE44_CLI_TEST_OVERRIDES;
if (!overrides) {
return false;
}

try {
const data = JSON.parse(overrides);
if (data.appConfig?.id && data.appConfig?.projectRoot) {
cache = { id: data.appConfig.id, projectRoot: data.appConfig.projectRoot };
return true;
}
} catch {
// Invalid JSON, ignore
const appConfig = getTestOverrides()?.appConfig;
if (appConfig?.id && appConfig.projectRoot) {
cache = { id: appConfig.id, projectRoot: appConfig.projectRoot };
return true;
}
return false;
}
Expand Down
10 changes: 10 additions & 0 deletions src/core/project/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,13 @@ export type Project = z.infer<typeof ProjectSchema>;
export const ProjectsResponseSchema = z.array(ProjectSchema);

export type ProjectsResponse = z.infer<typeof ProjectsResponseSchema>;

export const TestOverridesSchema = z.object({
appConfig: z.object({
id: z.string(),
projectRoot: z.string(),
}).optional(),
latestVersion: z.string().nullable().optional(),
});

export type TestOverrides = z.infer<typeof TestOverridesSchema>;
11 changes: 11 additions & 0 deletions tests/cli/testkit/CLIResultMatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ export class CLIResultMatcher {
}
}

toNotContain(text: string): void {
const output = this.result.stdout + this.result.stderr;
if (output.includes(text)) {
throw new Error(
`Expected output NOT to contain "${text}"\n` +
`stdout: ${stripAnsi(this.result.stdout)}\n` +
`stderr: ${stripAnsi(this.result.stderr)}`
);
}
}

toContainInStdout(text: string): void {
if (!this.result.stdout.includes(text)) {
throw new Error(
Expand Down
35 changes: 23 additions & 12 deletions tests/cli/testkit/CLITestkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,18 @@ interface ProgramModule {
CLIExitError: new (code: number) => Error & { code: number };
}

/** Test overrides that get serialized to BASE44_CLI_TEST_OVERRIDES */
interface TestOverrides {
appConfig?: { id: string; projectRoot: string };
latestVersion?: string | null;
}

export class CLITestkit {
private tempDir: string;
private cleanupFn: () => Promise<void>;
private env: Record<string, string> = {};
private projectDir?: string;
private testOverrides: TestOverrides = {};

/** Typed API mock for Base44 endpoints */
readonly api: Base44APIMock;
Expand Down Expand Up @@ -79,6 +86,10 @@ export class CLITestkit {
await cp(fixturePath, this.projectDir, { recursive: true });
}

givenLatestVersion(version: string | null): void {
this.testOverrides.latestVersion = version;
}

// ─── WHEN METHODS ─────────────────────────────────────────────

/** Execute CLI command */
Expand Down Expand Up @@ -159,25 +170,25 @@ export class CLITestkit {

private setupEnvOverrides(): void {
if (this.projectDir) {
this.env.BASE44_CLI_TEST_OVERRIDES = JSON.stringify({
appConfig: { id: this.api.appId, projectRoot: this.projectDir },
});
this.testOverrides.appConfig = { id: this.api.appId, projectRoot: this.projectDir };
}
if (Object.keys(this.testOverrides).length > 0) {
this.env.BASE44_CLI_TEST_OVERRIDES = JSON.stringify(this.testOverrides);
}
}

/** Save original values of env vars we're about to modify */
private captureEnvSnapshot(): { HOME?: string; BASE44_CLI_TEST_OVERRIDES?: string; CI?: string; BASE44_DISABLE_TELEMETRY?: string } {
return {
HOME: process.env.HOME,
BASE44_CLI_TEST_OVERRIDES: process.env.BASE44_CLI_TEST_OVERRIDES,
CI: process.env.CI,
BASE44_DISABLE_TELEMETRY: process.env.BASE44_DISABLE_TELEMETRY,
};
private captureEnvSnapshot(): Record<string, string | undefined> {
const snapshot: Record<string, string | undefined> = {};
for (const key of Object.keys(this.env)) {
snapshot[key] = process.env[key];
}
return snapshot;
}

/** Restore env vars to their original values (or delete if they didn't exist) */
private restoreEnvSnapshot(snapshot: { HOME?: string; BASE44_CLI_TEST_OVERRIDES?: string; CI?: string; BASE44_DISABLE_TELEMETRY?: string }): void {
for (const key of ["HOME", "BASE44_CLI_TEST_OVERRIDES", "CI", "BASE44_DISABLE_TELEMETRY"] as const) {
private restoreEnvSnapshot(snapshot: Record<string, string | undefined>): void {
for (const key of Object.keys(snapshot)) {
if (snapshot[key] === undefined) {
delete process.env[key];
} else {
Expand Down
3 changes: 3 additions & 0 deletions tests/cli/testkit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export interface TestContext {
/** Combined: login + project setup (most common pattern) */
givenLoggedInWithProject: (fixturePath: string, user?: { email: string; name: string }) => Promise<void>;

givenLatestVersion: (version: string | null) => void;

// ─── WHEN METHODS ──────────────────────────────────────────

/** Execute CLI command */
Expand Down Expand Up @@ -112,6 +114,7 @@ export function setupCLITests(): TestContext {
await getKit().givenLoggedIn(user);
await getKit().givenProject(fixturePath);
},
givenLatestVersion: (version) => getKit().givenLatestVersion(version),

// When methods
run: (...args) => getKit().run(...args),
Expand Down
36 changes: 36 additions & 0 deletions tests/cli/version-check.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, it } from "vitest";
import { setupCLITests } from "./testkit/index.js";

describe("upgrade notification", () => {
const t = setupCLITests();

it("displays upgrade notification when newer version is available", async () => {
t.givenLatestVersion("1.0.0");
await t.givenLoggedIn({ email: "[email protected]", name: "Test User" });

const result = await t.run("whoami");

t.expectResult(result).toSucceed();
t.expectResult(result).toContain("Update available!");
t.expectResult(result).toContain("1.0.0");
t.expectResult(result).toContain("npm update -g base44");
});

it("does not display notification when version is current", async () => {
t.givenLatestVersion(null);
await t.givenLoggedIn({ email: "[email protected]", name: "Test User" });

const result = await t.run("whoami");

t.expectResult(result).toSucceed();
t.expectResult(result).toNotContain("Update available!");
});

it("does not display notification when check is not overridden", async () => {
await t.givenLoggedIn({ email: "[email protected]", name: "Test User" });

const result = await t.run("whoami");

t.expectResult(result).toSucceed();
});
});
Loading