Skip to content
31 changes: 6 additions & 25 deletions core/util/GlobalContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,8 @@ export type GlobalContextType = {
isSupportedLanceDbCpuTargetForLinux: boolean;
sharedConfig: SharedConfigSchema;
failedDocs: SiteIndexingConfig[];
shownDeprecatedProviderWarnings: {
[providerTitle: string]: boolean;
};
shownDeprecatedProviderWarnings: { [providerTitle: string]: boolean };
autoUpdateCli: boolean;
mcpOauthStorage: {
[serverUrl: string]: {
clientInformation?: OAuthClientInformationFull;
Expand All @@ -66,16 +65,7 @@ export class GlobalContext {
) {
const filepath = getGlobalContextFilePath();
if (!fs.existsSync(filepath)) {
fs.writeFileSync(
filepath,
JSON.stringify(
{
[key]: value,
},
null,
2,
),
);
fs.writeFileSync(filepath, JSON.stringify({ [key]: value }, null, 2));
} else {
const data = fs.readFileSync(filepath, "utf-8");

Expand Down Expand Up @@ -113,10 +103,7 @@ export class GlobalContext {
}

// Recreate the file with salvaged values plus the new value
const newData = {
...salvaged,
[key]: value,
};
const newData = { ...salvaged, [key]: value };

fs.writeFileSync(filepath, JSON.stringify(newData, null, 2));
return;
Expand Down Expand Up @@ -174,10 +161,7 @@ export class GlobalContext {
newValues: Partial<SharedConfigSchema>,
): SharedConfigSchema {
const currentSharedConfig = this.getSharedConfig();
const updatedSharedConfig = {
...currentSharedConfig,
...newValues,
};
const updatedSharedConfig = { ...currentSharedConfig, ...newValues };
this.update("sharedConfig", updatedSharedConfig);
return updatedSharedConfig;
}
Expand All @@ -189,10 +173,7 @@ export class GlobalContext {
): GlobalContextModelSelections {
const currentSelections = this.get("selectedModelsByProfileId") ?? {};
const forProfile = currentSelections[profileId] ?? {};
const newSelections = {
...forProfile,
[role]: title,
};
const newSelections = { ...forProfile, [role]: title };

this.update("selectedModelsByProfileId", {
...currentSelections,
Expand Down
5 changes: 5 additions & 0 deletions extensions/cli/src/commands/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ export const SYSTEM_SLASH_COMMANDS: SystemCommand[] = [
description: "Sign out of your current session",
category: "system",
},
{
name: "update",
description: "Update the Continue CLI",
category: "system",
},
{
name: "whoami",
description: "Check who you're currently logged in as",
Expand Down
241 changes: 241 additions & 0 deletions extensions/cli/src/services/UpdateService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import { exec, spawn } from "child_process";
import { promisify } from "util";

import { GlobalContext } from "core/util/GlobalContext.js";

import { logger } from "src/util/logger.js";

import { compareVersions, getLatestVersion, getVersion } from "../version.js";

import { BaseService } from "./BaseService.js";
import { serviceContainer } from "./ServiceContainer.js";
import { UpdateServiceState, UpdateStatus } from "./types.js";
const execAsync = promisify(exec);

/**
* Service for checking and performing CLI updates
*/
export class UpdateService extends BaseService<UpdateServiceState> {
constructor() {
super("update", {
autoUpdate: true,
isAutoUpdate: true,
status: UpdateStatus.IDLE,
message: "",
error: null,
isUpdateAvailable: false,
latestVersion: null,
currentVersion: getVersion(),
});
}

/**
* Initialize the update service
*/
async doInitialize(headless?: boolean) {
// Don't automatically check in tests/headless
if (!headless && process.env.NODE_ENV !== "test") {
void this.checkAndAutoUpdate();
}

return this.currentState;
}

private async checkAndAutoUpdate() {
// First get auto update setting from global context
const globalContext = new GlobalContext();
const autoUpdate = globalContext.get("autoUpdateCli") ?? true;
this.setState({
autoUpdate,
});

try {
// Check for updates
this.setState({
status: UpdateStatus.CHECKING,
message: "Checking for updates",
});

const latestVersion = await getLatestVersion();
this.setState({
latestVersion,
});

if (!latestVersion) {
this.setState({
status: UpdateStatus.IDLE,
message: "Continue CLI",
isUpdateAvailable: false,
});
return;
}

const comparison = compareVersions(
this.currentState.currentVersion,
latestVersion,
);
const isUpdateAvailable = comparison === "older";
this.setState({
isUpdateAvailable,
});

if (this.currentState.currentVersion === "0.0.0-dev") {
this.setState({
status: UpdateStatus.IDLE,
message: `Continue CLI`,
isUpdateAvailable,
latestVersion,
});
return; // Uncomment to test auto-update behavior in dev
}

// If update is available, automatically update
if (
autoUpdate &&
isUpdateAvailable &&
this.currentState.status !== "updating" &&
!process.env.CONTINUE_CLI_AUTO_UPDATED //Already auto updated, preventing sequential auto-update
) {
await this.performUpdate(true);
} else {
this.setState({
status: UpdateStatus.IDLE,
message: isUpdateAvailable
? `Update available: v${latestVersion}`
: `Continue CLI v${this.currentState.currentVersion}`,
isUpdateAvailable,
latestVersion,
});
}
} catch (error: any) {
logger.error("Error checking for updates:", error);
this.setState({
status: UpdateStatus.ERROR,
message: `Continue CLI v${this.currentState.currentVersion}`,
error,
});
}
}

public async setAutoUpdate(value: boolean) {
const globalContext = new GlobalContext();
globalContext.update("autoUpdateCli", value);
this.setState({
autoUpdate: value,
});
}

// TODO this is a hack because our service state update code is broken
// Currently all things that need update use serviceContainer.set manually
// Rather than actually using the stateChanged event
setState(newState: Partial<UpdateServiceState>): void {
super.setState(newState);
serviceContainer.set("update", this.currentState);
}

async performUpdate(isAutoUpdate?: boolean) {
if (this.currentState.status === "updating") {
return;
}

try {
this.setState({
isAutoUpdate,
status: UpdateStatus.UPDATING,
message: `${isAutoUpdate ? "Auto-updating" : "Updating"} to v${this.currentState.latestVersion}`,
});

// Install the update
const { stdout, stderr } = await execAsync("npm i -g @continuedev/cli");
logger.debug("Update output:", { stdout, stderr });

if (stderr) {
const errLines = stderr.split("\n");
for (const line of errLines) {
const lower = line.toLowerCase().trim();
if (
!line ||
lower.includes("debugger") ||
lower.includes("npm warn")
) {
continue;
}
this.setState({
status: UpdateStatus.ERROR,
message: `Error updating to v${this.currentState.latestVersion}`,
error: new Error(stderr),
});
return;
}
}

this.setState({
status: UpdateStatus.UPDATED,
message: `${isAutoUpdate ? "Auto-updated to" : "Restart for"} v${this.currentState.latestVersion}`,
isUpdateAvailable: false,
});
if (isAutoUpdate) {
this.restartCLI();
}
} catch (error: any) {
logger.error("Error updating CLI:", error);
this.setState({
status: UpdateStatus.ERROR,
message: isAutoUpdate ? "Auto-update failed" : "Update failed",
error,
});
setTimeout(() => {
this.setState({
status: UpdateStatus.IDLE,
message: `/update to v${this.currentState.latestVersion}`,
});
}, 4000);
}
}

private restartCLI(): void {
try {
const entryPoint = process.argv[1];
const cliArgs = process.argv.slice(2);
const nodeExecutable = process.execPath;

logger.debug(
`Preparing for CLI restart with: ${nodeExecutable} ${entryPoint} ${cliArgs.join(
" ",
)}`,
);

// Halt/clean up parent cn process
try {
// Remove all input listeners
global.clearTimeout = () => {};
global.clearInterval = () => {};
process.stdin.removeAllListeners();
process.stdin.pause();
// console.clear(); // Don't want to clear things that were in console before cn started
} catch (e) {
logger.debug("Error cleaning up terminal:", e);
}

// Spawn a new detached cn process
const child = spawn(nodeExecutable, [entryPoint, ...cliArgs], {
detached: true,
stdio: "inherit",
env: {
...process.env,
CONTINUE_CLI_AUTO_UPDATED: "true",
},
});

// I did not find a way on existing to avoid a bug where next process has input glitches without leaving parent in place
// So instead of existing, parent will exit when child exits
// process.exit(0);
child.on("exit", (code) => {
process.exit(code);
});
child.unref();
} catch (error) {
logger.error("Failed to restart CLI:", error);
}
}
}
23 changes: 12 additions & 11 deletions extensions/cli/src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
ServiceInitOptions,
ServiceInitResult,
} from "./types.js";
import { UpdateService } from "./UpdateService.js";

// Service instances
const authService = new AuthService();
Expand All @@ -31,6 +32,7 @@ const mcpService = new MCPService();
const fileIndexService = new FileIndexService();
const resourceMonitoringService = new ResourceMonitoringService();
const chatHistoryService = new ChatHistoryService();
const updateService = new UpdateService();

/**
* Initialize all services and register them with the service container
Expand Down Expand Up @@ -132,6 +134,12 @@ export async function initializeServices(
[], // No dependencies
);

serviceContainer.register(
SERVICE_NAMES.UPDATE,
() => updateService.initialize(),
[], // No dependencies
);

serviceContainer.register(
SERVICE_NAMES.API_CLIENT,
async () => {
Expand Down Expand Up @@ -283,17 +291,9 @@ export function reloadService(serviceName: string) {
* Check if all core services are ready
*/
export function areServicesReady(): boolean {
return [
SERVICE_NAMES.TOOL_PERMISSIONS,
SERVICE_NAMES.AUTH,
SERVICE_NAMES.API_CLIENT,
SERVICE_NAMES.CONFIG,
SERVICE_NAMES.MODEL,
SERVICE_NAMES.MCP,
SERVICE_NAMES.FILE_INDEX,
SERVICE_NAMES.RESOURCE_MONITORING,
SERVICE_NAMES.CHAT_HISTORY,
].every((name) => serviceContainer.isReady(name));
return Object.values(SERVICE_NAMES).every((name) =>
serviceContainer.isReady(name),
);
}

/**
Expand All @@ -317,6 +317,7 @@ export const services = {
resourceMonitoring: resourceMonitoringService,
systemMessage: systemMessageService,
chatHistory: chatHistoryService,
updateService: updateService,
} as const;

// Export the service container for advanced usage
Expand Down
Loading
Loading