feat(cli): add managed update and rollback

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-22 19:38:47 +00:00
1 parent 2229aac205
commit 7484df71f4
9 files changed
+263 -4

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flipCurrentAtomic, initializeInstallStore, payloadPathFor, readInstallManifest, resolveInstallStorePaths, writeInstallManifestAtomic, type InstallManifest, type InstallRecord } from "../install-store.js";
import { detectInstallMode, resolveUpdateRequest, rollbackManagedInstall, updateCommand } from "../commands/update.js";
let root: string;
let previousHome: string | undefined;
let previousPaperclipHome: string | undefined;
function record(payloadPath: string, version: string, channel: "latest" | "canary" | "pinned" = "latest"): InstallRecord {
return { source: "npm", version, channel, payloadPath, installedAt: `2026-07-22T00:00:0${version}.000Z` };
}
function createPayload(payloadPath: string, version: string): string {
const entrypoint = path.join(payloadPath, "node_modules", "paperclipai", "dist", "index.js");
fs.mkdirSync(path.dirname(entrypoint), { recursive: true });
fs.writeFileSync(entrypoint, version);
return entrypoint;
}
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-update-"));
previousHome = process.env.HOME;
previousPaperclipHome = process.env.PAPERCLIP_HOME;
process.env.HOME = path.join(root, "home");
process.env.PAPERCLIP_HOME = path.join(root, "paperclip");
});
afterEach(() => {
if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome;
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousPaperclipHome;
fs.rmSync(root, { recursive: true, force: true });
process.exitCode = undefined;
});
describe("update command", () => {
it("detects managed, global npm, npx, and source modes", () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const payload = payloadPathFor(paths, "npm", "1.0.0"); const entrypoint = createPayload(payload, "1.0.0");
flipCurrentAtomic(payload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, ...record(payload, "1.0.0"), previous: [] }, paths);
expect(detectInstallMode(entrypoint, paths)).toBe("managed");
expect(detectInstallMode(path.join(root, "lib", "node_modules", "paperclipai", "dist", "index.js"), paths)).toBe("global-npm");
expect(detectInstallMode(path.join(root, ".npm", "_npx", "abc", "node_modules", "paperclipai", "dist", "index.js"), paths)).toBe("npx");
const source = path.join(root, "source"); fs.mkdirSync(path.join(source, ".git"), { recursive: true });
expect(detectInstallMode(path.join(source, "cli", "src", "index.ts"), paths)).toBe("source");
});
it("resolves channels and keeps pinned installs pinned by default", () => {
const manifest = { channel: "pinned", version: "1.2.3" } as InstallManifest;
expect(resolveUpdateRequest(manifest, {})).toEqual({ spec: "1.2.3", channel: "pinned", explicit: false });
expect(resolveUpdateRequest(manifest, { latest: true })).toEqual({ spec: "latest", channel: "latest", explicit: true });
expect(() => resolveUpdateRequest(manifest, { latest: true, canary: true })).toThrow("only one");
});
it("requires explicit confirmation before downgrading", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const payload = payloadPathFor(paths, "npm", "2.0.0"); const entrypoint = createPayload(payload, "2.0.0"); flipCurrentAtomic(payload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, ...record(payload, "2.0.0"), previous: [] }, paths);
const runCommand = vi.fn(async () => ({ stdout: '"1.0.0"\n', stderr: "" }));
await expect(updateCommand({ version: "1.0.0", dryRun: true }, { paths, executablePath: entrypoint, runCommand, confirm: async () => false })).rejects.toThrow("Downgrade cancelled");
});
it("backs up, installs side-by-side, flips, and rolls back instantly", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); const executable = createPayload(oldPayload, "1.0.0"); flipCurrentAtomic(oldPayload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, ...record(oldPayload, "1.0.0"), previous: [] }, paths);
const backup = vi.fn(async () => undefined);
const runCommand = vi.fn(async (file: string, args: string[]) => {
if (args[0] === "view") return { stdout: '"2.0.0"\n', stderr: "" };
if (file === "npm" && args[0] === "install") { const prefix = args[args.indexOf("--prefix") + 1]; createPayload(prefix, "2.0.0"); return { stdout: "", stderr: "" }; }
return { stdout: "2.0.0\n", stderr: "" };
});
await updateCommand({}, { paths, executablePath: executable, runCommand, backup, now: () => new Date("2026-07-22T12:00:00Z") });
expect(backup).toHaveBeenCalledOnce();
expect(readInstallManifest(paths)?.version).toBe("2.0.0");
expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(payloadPathFor(paths, "npm", "2.0.0")));
const rolledBack = rollbackManagedInstall(paths);
expect(rolledBack.version).toBe("1.0.0");
expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(oldPayload));
});
});
+20
View File
@@ -0,0 +1,20 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { checkForUpdateNotice, isUpdateNoticeEnabled } from "../update-notice.js";
let root: string; let previous: string | undefined;
beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-notice-")); previous = process.env.PAPERCLIP_UPDATE_CHECK; delete process.env.PAPERCLIP_UPDATE_CHECK; });
afterEach(() => { if (previous === undefined) delete process.env.PAPERCLIP_UPDATE_CHECK; else process.env.PAPERCLIP_UPDATE_CHECK = previous; fs.rmSync(root, { recursive: true, force: true }); });
describe("update notice", () => {
it("honors the environment and config kill switches", () => {
process.env.PAPERCLIP_UPDATE_CHECK = "0"; expect(isUpdateNoticeEnabled(path.join(root, "missing.json"))).toBe(false);
delete process.env.PAPERCLIP_UPDATE_CHECK; const config = path.join(root, "config.json"); fs.writeFileSync(config, JSON.stringify({ updates: { checkEnabled: false } })); expect(isUpdateNoticeEnabled(config)).toBe(false);
});
it("throttles registry checks for 24 hours", async () => {
const cachePath = path.join(root, "cache.json"); const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ "dist-tags": { latest: "99.0.0" } }), { status: 200 }));
expect(await checkForUpdateNotice({ cachePath, now: 1000, fetchImpl })).toBe("99.0.0");
expect(await checkForUpdateNotice({ cachePath, now: 2000, fetchImpl })).toBe("99.0.0");
expect(fetchImpl).toHaveBeenCalledOnce();
});
});
+2
View File
@@ -19,6 +19,7 @@ import {
} from "../checks/index.js";
import { loadPaperclipEnvFile } from "../config/env.js";
import { printPaperclipCliBanner } from "../utils/banner.js";
import { printUpdateNotice } from "../update-notice.js";
const STATUS_ICON = {
pass: pc.green("✓"),
@@ -31,6 +32,7 @@ export async function doctor(opts: {
repair?: boolean;
yes?: boolean;
}): Promise<{ passed: number; warned: number; failed: number }> {
await printUpdateNotice(opts.config);
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclip doctor ")));
+4 -4
View File
@@ -26,7 +26,7 @@ const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
export type InstallOptions = { canary?: boolean; version?: string; yes?: boolean };
type CommandRunner = (
export type CommandRunner = (
file: string,
args: string[],
options?: Parameters<typeof execFileAsync>[2],
@@ -66,7 +66,7 @@ function parseResolvedVersion(stdout: string): string {
throw new Error(`npm returned an unexpected version response: ${trimmed}`);
}
async function resolvePublishedVersion(spec: string, runCommand: CommandRunner): Promise<string> {
export async function resolvePublishedVersion(spec: string, runCommand: CommandRunner): Promise<string> {
const result = await runCommand(
"npm",
["view", `paperclipai@${spec}`, "version", "--json", `--registry=${PUBLIC_NPM_REGISTRY}`],
@@ -79,7 +79,7 @@ function payloadEntrypoint(payloadPath: string): string {
return path.join(payloadPath, "node_modules", "paperclipai", "dist", "index.js");
}
async function smokePayload(payloadPath: string, expectedVersion: string, runCommand: CommandRunner): Promise<void> {
export async function smokePayload(payloadPath: string, expectedVersion: string, runCommand: CommandRunner): Promise<void> {
const entrypoint = payloadEntrypoint(payloadPath);
if (!fs.existsSync(entrypoint)) throw new Error(`Installed package is missing its CLI entrypoint: ${entrypoint}`);
const result = await runCommand(process.execPath, [entrypoint, "--version"], { maxBuffer: 1024 * 1024 });
@@ -89,7 +89,7 @@ async function smokePayload(payloadPath: string, expectedVersion: string, runCom
}
}
async function installNpmPayload(
export async function installNpmPayload(
version: string,
runCommand: CommandRunner,
paths = resolveInstallStorePaths(),
+2
View File
@@ -17,6 +17,7 @@ import {
resolvePaperclipInstanceId,
} from "../config/home.js";
import { assertForegroundRunAllowed } from "../services/service-manager.js";
import { printUpdateNotice } from "../update-notice.js";
interface RunOptions {
config?: string;
@@ -48,6 +49,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
const configPath = resolveConfigPath(opts.config);
process.env.PAPERCLIP_CONFIG = configPath;
loadPaperclipEnvFile(configPath);
await printUpdateNotice(configPath);
p.intro(pc.bgCyan(pc.black(" paperclipai run ")));
p.log.message(pc.dim(`Home: ${paths.homeDir}`));
+112
View File
@@ -0,0 +1,112 @@
import fs from "node:fs";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { buildNextManifest, flipCurrentAtomic, isManagedExecutable, pruneInstallPayloads, readInstallManifest, resolveInstallStorePaths, withInstallStoreLock, writeInstallManifestAtomic, type InstallChannel, type InstallManifest, type InstallRecord, type InstallStorePaths } from "../install-store.js";
import { dbBackupCommand } from "./db-backup.js";
import { installNpmPayload, resolvePublishedVersion, type CommandRunner } from "./install.js";
const execFileAsync = promisify(execFile);
export type InstallMode = "managed" | "global-npm" | "npx" | "source" | "unknown";
export type UpdateOptions = { canary?: boolean; latest?: boolean; version?: string; rollback?: boolean; check?: boolean; dryRun?: boolean; json?: boolean; yes?: boolean; backup?: boolean };
type Dependencies = { executablePath: string; runCommand: CommandRunner; backup: () => Promise<void>; confirm: (message: string) => Promise<boolean>; now: () => Date; paths: InstallStorePaths };
export function detectInstallMode(executablePath = process.argv[1] ?? "", paths = resolveInstallStorePaths()): InstallMode {
const resolved = path.resolve(executablePath || ".");
const manifest = readInstallManifest(paths);
if (manifest && isManagedExecutable(resolved, manifest, paths)) return "managed";
const normalized = resolved.split(path.sep).join("/");
if (normalized.includes("/.npm/_npx/") || normalized.includes("/node_modules/.cache/npx/")) return "npx";
if (normalized.includes("/node_modules/paperclipai/")) return "global-npm";
let cursor = path.dirname(resolved);
while (cursor !== path.dirname(cursor)) {
if (fs.existsSync(path.join(cursor, ".git"))) return "source";
cursor = path.dirname(cursor);
}
return "unknown";
}
export function compareVersions(left: string, right: string): number {
const parse = (value: string) => { const [core, prerelease = ""] = value.replace(/^v/, "").split("-", 2); return { numbers: core.split(".").map((part) => Number(part) || 0), prerelease }; };
const a = parse(left); const b = parse(right);
for (let index = 0; index < Math.max(a.numbers.length, b.numbers.length); index += 1) { const delta = (a.numbers[index] ?? 0) - (b.numbers[index] ?? 0); if (delta !== 0) return Math.sign(delta); }
if (a.prerelease === b.prerelease) return 0;
if (!a.prerelease) return 1;
if (!b.prerelease) return -1;
return a.prerelease.localeCompare(b.prerelease);
}
export function resolveUpdateRequest(manifest: InstallManifest | null, options: Pick<UpdateOptions, "canary" | "latest" | "version">): { spec: string; channel: InstallChannel; explicit: boolean } {
const selected = Number(Boolean(options.canary)) + Number(Boolean(options.latest)) + Number(Boolean(options.version));
if (selected > 1) throw new Error("Choose only one of --latest, --canary, or --version.");
if (options.version) return { spec: options.version.trim(), channel: "pinned", explicit: true };
if (options.canary) return { spec: "canary", channel: "canary", explicit: true };
if (options.latest) return { spec: "latest", channel: "latest", explicit: true };
if (manifest?.channel === "pinned") return { spec: manifest.version, channel: "pinned", explicit: false };
const channel = manifest?.channel === "canary" ? "canary" : "latest";
return { spec: channel, channel, explicit: false };
}
export function rollbackManagedInstall(paths = resolveInstallStorePaths()): InstallManifest {
const manifest = readInstallManifest(paths);
if (!manifest) throw new Error("No managed install was found to roll back.");
const target = manifest.previous[0];
if (!target) throw new Error("No previous managed payload is available for rollback.");
if (!fs.existsSync(target.payloadPath)) throw new Error(`Previous payload is missing: ${target.payloadPath}`);
const current: InstallRecord = { source: manifest.source, version: manifest.version, channel: manifest.channel, payloadPath: manifest.payloadPath, repo: manifest.repo, ref: manifest.ref, sha: manifest.sha, installedAt: manifest.installedAt };
const next: InstallManifest = { schemaVersion: manifest.schemaVersion, ...target, previous: [current, ...manifest.previous.slice(1)].slice(0, 2) };
const oldTarget = fs.readlinkSync(paths.currentPath);
flipCurrentAtomic(target.payloadPath, paths);
try { writeInstallManifestAtomic(next, paths); } catch (error) { flipCurrentAtomic(path.resolve(paths.cliRoot, oldTarget), paths); throw error; }
return next;
}
async function defaultConfirm(message: string): Promise<boolean> {
if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
const answer = await p.confirm({ message, initialValue: false });
return !p.isCancel(answer) && answer === true;
}
function emit(options: UpdateOptions, value: Record<string, unknown>, message: string): void { if (options.json) console.log(JSON.stringify(value, null, 2)); else console.log(message); }
export async function updateCommand(options: UpdateOptions, overrides: Partial<Dependencies> = {}): Promise<void> {
const paths = overrides.paths ?? resolveInstallStorePaths();
const executablePath = overrides.executablePath ?? process.argv[1] ?? "";
const runCommand = overrides.runCommand ?? execFileAsync;
const mode = detectInstallMode(executablePath, paths);
const manifest = readInstallManifest(paths);
if (options.rollback) {
if (mode !== "managed") throw new Error("--rollback is only available for managed installs.");
if (options.dryRun) { emit(options, { mode, action: "rollback", dryRun: true, target: manifest?.previous[0]?.version ?? null }, `Would roll back to ${manifest?.previous[0]?.version ?? "the previous payload"}.`); return; }
const next = await withInstallStoreLock(async () => rollbackManagedInstall(paths), paths);
emit(options, { mode, action: "rollback", version: next.version }, pc.green(`Rolled back instantly to paperclipai ${next.version}. Database migrations are not reversed; restore the pre-update backup if needed.`));
return;
}
const request = resolveUpdateRequest(manifest, options);
if (mode === "npx") { emit(options, { mode, action: "install" }, "This is an ephemeral npx install. Run `paperclipai install`, then use `paperclipai update` from the managed shim."); return; }
if (mode === "source" || mode === "unknown") { emit(options, { mode, action: "manual" }, "This appears to be a source checkout. Update it with `git pull` followed by `pnpm install`; Paperclip will not mutate the repository."); return; }
if (mode === "managed" && manifest?.source === "git") { emit(options, { mode, source: "git", action: "manual", ref: manifest.ref ?? manifest.sha }, "Managed git payload updates require reinstalling the desired ref; the current payload was left unchanged."); return; }
const targetVersion = await resolvePublishedVersion(request.spec, runCommand);
const currentVersion = manifest?.version;
const comparison = currentVersion ? compareVersions(targetVersion, currentVersion) : 1;
if (options.check) { emit(options, { mode, currentVersion: currentVersion ?? null, targetVersion, updateAvailable: comparison > 0, downgrade: comparison < 0, channel: request.channel }, comparison > 0 ? `Update available: ${targetVersion}` : comparison < 0 ? `Target ${targetVersion} is older than ${currentVersion}.` : `paperclipai ${targetVersion} is current.`); if (comparison > 0) process.exitCode = 10; return; }
if (mode === "global-npm") {
const args = ["install", "-g", `paperclipai@${targetVersion}`]; console.log(`Running: npm ${args.join(" ")}`);
if (!options.dryRun) await runCommand("npm", args, { maxBuffer: 16 * 1024 * 1024 });
emit(options, { mode, action: "update", targetVersion, dryRun: Boolean(options.dryRun), command: ["npm", ...args] }, options.dryRun ? "Dry run complete." : pc.green(`Updated global npm install to ${targetVersion}.`)); return;
}
if (!manifest) throw new Error("Managed install metadata is missing.");
if (comparison === 0) { emit(options, { mode, currentVersion, targetVersion, changed: false }, `paperclipai ${targetVersion} is already active.`); return; }
if (comparison < 0 && options.yes !== true) { const confirmed = await (overrides.confirm ?? defaultConfirm)(`Downgrade paperclipai from ${currentVersion} to ${targetVersion}?`); if (!confirmed) throw new Error("Downgrade cancelled. Re-run with --yes to confirm explicitly."); }
if (options.dryRun) { emit(options, { mode, currentVersion, targetVersion, action: comparison < 0 ? "downgrade" : "update", backup: options.backup !== false, dryRun: true }, `Would ${comparison < 0 ? "downgrade" : "update"} paperclipai ${currentVersion} → ${targetVersion}${options.backup === false ? " without a backup" : " after a database backup"}.`); return; }
if (options.backup !== false) await (overrides.backup ?? (() => dbBackupCommand({})))();
const installed = await withInstallStoreLock(async () => {
const payload = await installNpmPayload(targetVersion, runCommand, paths);
const record: InstallRecord = { source: "npm", version: targetVersion, channel: request.channel, payloadPath: payload.payloadPath, installedAt: (overrides.now?.() ?? new Date()).toISOString() };
const next = buildNextManifest(record, manifest); const oldTarget = fs.readlinkSync(paths.currentPath); flipCurrentAtomic(payload.payloadPath, paths);
try { writeInstallManifestAtomic(next, paths); } catch (error) { flipCurrentAtomic(path.resolve(paths.cliRoot, oldTarget), paths); throw error; }
pruneInstallPayloads(next, paths); return payload;
}, paths);
emit(options, { mode, currentVersion, targetVersion, changed: true, reused: installed.reused }, pc.green(`Updated paperclipai ${currentVersion} → ${targetVersion}. Run \`paperclipai update --rollback\` for an instant payload rollback.`));
}
+16
View File
@@ -45,6 +45,7 @@ import { registerSkillCommands } from "./commands/client/skill.js";
import { cliVersion } from "./version.js";
import { installCommand } from "./commands/install.js";
import { uninstallCommand } from "./commands/uninstall.js";
import { updateCommand } from "./commands/update.js";
import { registerServiceCommands } from "./commands/service.js";
const program = new Command();
@@ -71,6 +72,21 @@ program
.description("Remove the managed CLI install while preserving user data")
.action(uninstallCommand);
program
.command("update")
.alias("upgrade")
.description("Check, update, or roll back the Paperclip CLI")
.option("--latest", "Switch to the latest stable channel")
.option("--canary", "Switch to the canary channel")
.option("--version <version>", "Install an exact published version")
.option("--rollback", "Flip back to the retained previous managed payload")
.option("--check", "Check for an available update without applying it")
.option("--dry-run", "Print the action without changing anything")
.option("--json", "Print machine-readable output")
.option("-y, --yes", "Confirm an explicit downgrade")
.option("--no-backup", "Skip the pre-update database backup")
.action(updateCommand);
program.hook("preAction", (_thisCommand, actionCommand) => {
const options = actionCommand.optsWithGlobals() as DataDirOptionLike;
const optionNames = new Set(actionCommand.options.map((option) => option.attributeName()));
+19
View File
@@ -0,0 +1,19 @@
import fs from "node:fs";
import path from "node:path";
import { packageVersion } from "./version.js";
import { compareVersions } from "./commands/update.js";
import { readInstallManifest, resolveInstallStorePaths } from "./install-store.js";
import { resolveConfigPath } from "./config/store.js";
const NOTICE_INTERVAL_MS = 24 * 60 * 60 * 1000;
export function isUpdateNoticeEnabled(configPath?: string): boolean {
if (process.env.PAPERCLIP_UPDATE_CHECK === "0") return false;
try { const raw = JSON.parse(fs.readFileSync(resolveConfigPath(configPath), "utf8")) as { updates?: { checkEnabled?: boolean } }; return raw.updates?.checkEnabled !== false; } catch { return true; }
}
export async function checkForUpdateNotice(options: { configPath?: string; now?: number; fetchImpl?: typeof fetch; cachePath?: string } = {}): Promise<string | null> {
if (!isUpdateNoticeEnabled(options.configPath)) return null;
const paths = resolveInstallStorePaths(); const cachePath = options.cachePath ?? path.join(paths.cliRoot, "update-check.json"); const now = options.now ?? Date.now();
try { const cache = JSON.parse(fs.readFileSync(cachePath, "utf8")) as { checkedAt?: number; latest?: string }; if (cache.checkedAt && now - cache.checkedAt < NOTICE_INTERVAL_MS) return cache.latest && compareVersions(cache.latest, packageVersion) > 0 ? cache.latest : null; } catch {}
const tag = readInstallManifest(paths)?.channel === "canary" ? "canary" : "latest";
try { const response = await (options.fetchImpl ?? fetch)("https://registry.npmjs.org/paperclipai", { signal: AbortSignal.timeout(2500) }); if (!response.ok) return null; const body = await response.json() as { ["dist-tags"]?: Record<string, string> }; const latest = body["dist-tags"]?.[tag]; fs.mkdirSync(path.dirname(cachePath), { recursive: true, mode: 0o700 }); fs.writeFileSync(cachePath, JSON.stringify({ checkedAt: now, latest: latest ?? null }) + "\n", { mode: 0o600 }); return latest && compareVersions(latest, packageVersion) > 0 ? latest : null; } catch { return null; }
}
export async function printUpdateNotice(configPath?: string): Promise<void> { const latest = await checkForUpdateNotice({ configPath }); if (latest) console.log(`Update available: ${latest} — run \`paperclipai update\``); }
+6
View File
@@ -103,6 +103,10 @@ export const telemetryConfigSchema = z.object({
enabled: z.boolean().default(true),
}).default({});
export const updatesConfigSchema = z.object({
checkEnabled: z.boolean().default(true),
}).default({});
export const paperclipConfigSchema = z
.object({
$meta: configMetaSchema,
@@ -111,6 +115,7 @@ export const paperclipConfigSchema = z
logging: loggingConfigSchema,
server: serverConfigSchema,
telemetry: telemetryConfigSchema,
updates: updatesConfigSchema.optional(),
auth: authConfigSchema.default({
baseUrlMode: "auto",
disableSignUp: false,
@@ -195,5 +200,6 @@ export type SecretsConfig = z.infer<typeof secretsConfigSchema>;
export type SecretsLocalEncryptedConfig = z.infer<typeof secretsLocalEncryptedConfigSchema>;
export type AuthConfig = z.infer<typeof authConfigSchema>;
export type TelemetryConfig = z.infer<typeof telemetryConfigSchema>;
export type UpdatesConfig = z.infer<typeof updatesConfigSchema>;
export type ConfigMeta = z.infer<typeof configMetaSchema>;
export type DatabaseBackupConfig = z.infer<typeof databaseBackupConfigSchema>;