Skip to content
Merged
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
5fe35cb
feat: add upgrade notification when newer CLI version is available
claude Jan 30, 2026
64201fa
test: add unit tests for version check utility
claude Feb 1, 2026
b2d9b60
chore: remove comment from runCommand.ts
github-actions[bot] Feb 1, 2026
b72e1eb
refactor: move version-check from src/core to src/cli
github-actions[bot] Feb 1, 2026
d01cea7
refactor: move upgrade notification to beginning of command
github-actions[bot] Feb 1, 2026
298b4bb
test: rewrite version-check tests to use testkit pattern
github-actions[bot] Feb 1, 2026
2c18844
fix: add shell flag to npm version check execa call
github-actions[bot] Feb 2, 2026
4f61569
fix: Make version-check tests work with bundled dist
gonengar Feb 2, 2026
8fb2d7b
docs: Document test overrides mechanism in AGENTS.md
gonengar Feb 2, 2026
689de72
chore: Remove unnecessary comments
gonengar Feb 2, 2026
f7826b8
chore: Remove JSDoc comments from givenLatestVersion
gonengar Feb 2, 2026
ab20341
refactor: Replace NegatedCLIResultMatcher with toNotContain method
gonengar Feb 2, 2026
a16d37f
refactor: Simplify toContain with expected parameter
gonengar Feb 2, 2026
d003ce3
refactor: Use separate toNotContain method for readability
gonengar Feb 2, 2026
5baf1da
refactor: Extract shared getTestOverrides utility
gonengar Feb 2, 2026
fe9d6a0
style: Fix lint errors
gonengar Feb 3, 2026
c562319
style: Remove JSDoc comment from printUpgradeNotificationIfAvailable
github-actions[bot] Feb 3, 2026
2826286
refactor: Move TestOverrides to Zod schema in project/schema.ts
github-actions[bot] Feb 3, 2026
63b3c57
style: Fix lint errors
gonengar Feb 3, 2026
48411c8
perf: Lower npm registry timeout to 500ms and add CI env var
github-actions[bot] Feb 3, 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 @@ -847,9 +847,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 `bun run build` before `bun 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 @@ -3,6 +3,7 @@ import { login } from "@/cli/commands/auth/login-flow.js";
import type { CLIContext } from "@/cli/types.js";
import { printBanner } from "@/cli/utils/banner.js";
import { theme } from "@/cli/utils/theme.js";
import { printUpgradeNotificationIfAvailable } from "@/cli/utils/upgradeNotification.js";
import { isLoggedIn } from "@/core/auth/index.js";
import { isCLIError } from "@/core/errors.js";
import { initAppConfig } from "@/core/project/index.js";
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
22 changes: 22 additions & 0 deletions src/cli/utils/upgradeNotification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { log } from "@clack/prompts";
import { theme } from "@/cli/utils/theme.js";
import type { UpgradeInfo } from "@/cli/utils/version-check.js";
import { checkForUpgrade } from "@/cli/utils/version-check.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"))}`;
}

export async function printUpgradeNotificationIfAvailable(): Promise<void> {
try {
const upgradeInfo = await checkForUpgrade();
if (upgradeInfo) {
log.message(formatUpgradeMessage(upgradeInfo));
Copy link
Collaborator

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
Collaborator

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 { getTestOverrides } from "@/core/config.js";
import packageJson from "../../../package.json";

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;
}
}
18 changes: 18 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { PROJECT_SUBDIR } from "@/core/consts.js";
import {
type TestOverrides,
TestOverridesSchema,
} from "@/core/project/schema.js";

// After bundling, import.meta.url points to dist/cli/index.js
// Templates are copied to dist/cli/templates/
Expand Down Expand Up @@ -30,3 +34,17 @@ export function getAppConfigPath(projectRoot: string): string {
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;
}
}
26 changes: 5 additions & 21 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 { APP_CONFIG_PATTERN } from "@/core/consts.js";
import {
ConfigInvalidError,
Expand All @@ -18,27 +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
12 changes: 12 additions & 0 deletions src/core/project/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,15 @@ 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
57 changes: 28 additions & 29 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 @@ -83,6 +90,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 @@ -170,40 +181,28 @@ 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 @@ -35,6 +35,8 @@ export interface TestContext {
user?: { email: string; name: string }
) => Promise<void>;

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

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

/** Execute CLI command */
Expand Down Expand Up @@ -119,6 +121,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: "test@example.com", 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: "test@example.com", 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: "test@example.com", name: "Test User" });

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

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