import { existsSync } from "node:fs" import { mkdir, readFile, rename, writeFile } from "node:fs/promises" import { homedir } from "node:os" import { join, parse } from "node:path" import consola from "consola" import inquirer from "inquirer" import { execAsync } from "soda-nodejs" import { CommitType } from "@/constant" import { addGitCommit } from "./addGitCommit" import { getCommitMessage } from "./getCommitMessage" import { canGetEditorExtensions, getEditorExtensionCommand, getEditorExtensions } from "./getEditorExtensions" import { hasAntiGravity } from "./hasAntiGravity" import { hasChangeNoCommit } from "./hasChangeNoCommit" import { hasCode } from "./hasCode" import { hasCursor } from "./hasCursor" import { modifyJsonc } from "./modifyJsonc" import { readZixuluSetting } from "./readZixuluSetting" import { writeZixuluSetting } from "./writeZixuluSetting" export type Editor = "Code" | "Cursor" | "Antigravity" export type SyncEditorSettingSource = Editor | "Online" export type EditorFileType = "settings" | "snippets" | "PowerShell" export type EditorConfigType = EditorFileType | "extensions" export type EditorFileSourceMap = Record> const userDir = homedir() const powerShellProfileFilename = "Microsoft.PowerShell_profile.ps1" const powerShellProfilePath = join(userDir, "Documents/PowerShell", powerShellProfileFilename) const onlineFileNameMap: Record = { settings: "settings.json", snippets: "global.code-snippets", PowerShell: powerShellProfileFilename, } const fileSourceMap: EditorFileSourceMap = { settings: { Code: join(userDir, "AppData/Roaming/Code/User/settings.json"), Cursor: join(userDir, "AppData/Roaming/Cursor/User/settings.json"), Antigravity: join(userDir, "AppData/Roaming/Antigravity/User/settings.json"), Online: "https://luzixu.geskj.com/settings.json", }, snippets: { Code: join(userDir, "AppData/Roaming/Code/User/snippets/global.code-snippets"), Cursor: join(userDir, "AppData/Roaming/Cursor/User/snippets/global.code-snippets"), Antigravity: join(userDir, "AppData/Roaming/Antigravity/User/snippets/global.code-snippets"), Online: "https://luzixu.geskj.com/global.code-snippets", }, PowerShell: { Code: powerShellProfilePath, Cursor: powerShellProfilePath, Antigravity: powerShellProfilePath, Online: `https://luzixu.geskj.com/${powerShellProfileFilename}`, }, } export interface SyncEditorSettingParams { source: SyncEditorSettingSource target: Editor } export interface SyncEditorFileItem { type: SyncEditorSettingSource value: string } export interface SyncEditorFileParams { type: EditorFileType source: SyncEditorFileItem target: SyncEditorFileItem } export interface SyncEditorExtensionsParams { editor: Editor sourceExtensions: Set targetExtensions: Set } async function getFile(source: string) { if (source.startsWith("http")) { const response = await fetch(source) return await response.text() } return await readFile(source, "utf-8") } export async function syncEditorFile({ type, source: { value: sourceValue }, target: { type: targetType, value: targetValue } }: SyncEditorFileParams) { const { dir, base } = parse(targetValue) await mkdir(dir, { recursive: true }) const setting = await readZixuluSetting() let code = await getFile(sourceValue) if (type === "settings") { code = modifyJsonc(code, ["extensions.gallery.serviceUrl"], undefined) code = modifyJsonc(code, ["antigravity.marketplaceExtensionGalleryServiceURL"], undefined) code = modifyJsonc(code, ["antigravity.marketplaceGalleryItemURL"], undefined) if (targetType === "Antigravity") { code = modifyJsonc(code, ["antigravity.marketplaceExtensionGalleryServiceURL"], "https://marketplace.visualstudio.com/_apis/public/gallery") code = modifyJsonc(code, ["antigravity.marketplaceGalleryItemURL"], "https://marketplace.visualstudio.com/items") } } if (existsSync(targetValue)) { const text = await readFile(targetValue, "utf-8") if (text === code) { consola.success(`${targetValue} 已是最新`) return } else { type Answer = { backup: boolean } let backup = false if (targetType !== "Online") { const answer = await inquirer.prompt({ type: "confirm", name: "backup", message: `是否备份原文件(${targetValue})`, default: setting.syncEditor?.fileConfigs?.[targetValue]?.backup ?? true, }) backup = answer.backup } setting.syncEditor ??= {} setting.syncEditor.fileConfigs ??= {} setting.syncEditor.fileConfigs[targetValue] ??= {} setting.syncEditor.fileConfigs[targetValue].backup = backup await writeZixuluSetting(setting) if (backup) await rename(targetValue, join(dir, `${base}.${Date.now()}.bak`)) } } await writeFile(targetValue, code, "utf-8") consola.success(`${targetValue} 同步完成`) } export async function syncEditorExtensions({ editor, sourceExtensions, targetExtensions }: SyncEditorExtensionsParams) { const command = await getEditorExtensionCommand({ editor }) const installExtensions = sourceExtensions.difference(targetExtensions) for (const ext of installExtensions) { try { console.log(`${command} --install-extension ${ext}`) await execAsync(`${command} --install-extension ${ext}`) } catch { console.error(`${ext} 安装失败`) } } const uninstallExtensions = targetExtensions.difference(sourceExtensions) for (const ext of uninstallExtensions) { try { console.log(`${command} --uninstall-extension ${ext}`) await execAsync(`${command} --uninstall-extension ${ext}`) } catch { console.error(`${ext} 卸载失败`) } } } export async function syncEditorSetting() { const setting = await readZixuluSetting() interface Answer { source: SyncEditorSettingSource types: EditorConfigType[] targets: SyncEditorSettingSource[] } const sourceChoices = ["Online"] if (hasCode()) sourceChoices.unshift("Code") if (hasCursor()) sourceChoices.unshift("Cursor") if (hasAntiGravity()) sourceChoices.unshift("Antigravity") const { source } = await inquirer.prompt([ { type: "list", name: "source", message: "选择同步来源", choices: sourceChoices, default: setting.syncEditor?.source ?? "Cursor", }, ]) setting.syncEditor ??= {} setting.syncEditor.source = source const targetChoices = sourceChoices.filter(v => v !== source) if (targetChoices.length === 0) return consola.info("没有可同步的目标") const { targets, types } = await inquirer.prompt([ { type: "checkbox", name: "targets", message: "选择同步目标", choices: targetChoices, default: setting.syncEditor?.targets?.filter(item => targetChoices.includes(item)) ?? targetChoices, }, { type: "checkbox", name: "types", message: "选择的配置类型", choices: ["settings", "snippets", "extensions", "PowerShell"], default: setting.syncEditor?.types ?? ["settings", "snippets", "extensions", "PowerShell"], }, ]) setting.syncEditor.targets = targets setting.syncEditor.types = types if (targets.includes("Online")) { interface Answer { onlinePath: string } const { onlinePath } = await inquirer.prompt({ type: "input", name: "onlinePath", message: "请输入 blog 文件夹的路径(留空则跳过)", default: setting.syncEditor?.onlinePath, }) setting.syncEditor.onlinePath = onlinePath.trim() ?? undefined } if (!setting.syncEditor.onlinePath) setting.syncEditor.targets = targets.filter(v => v !== "Online") const onlinePath = setting.syncEditor.onlinePath! const configs: SyncEditorFileParams[] = types .filter(item => item !== "extensions" && item !== "PowerShell") .map(fileType => targets.map(target => ({ type: fileType, source: { type: source, value: fileSourceMap[fileType][source] }, target: { type: target, value: target === "Online" ? join(onlinePath, "static", onlineFileNameMap[fileType]) : fileSourceMap[fileType][target], }, }))) .flat() if (types.includes("PowerShell")) { const powerShellLocalTarget = targets.find(target => target !== "Online") if (targets.includes("Online")) { configs.push({ type: "PowerShell", source: { type: source, value: powerShellProfilePath, }, target: { type: "Online", value: join(onlinePath, "static", onlineFileNameMap.PowerShell), }, }) } if (source === "Online" && powerShellLocalTarget) { configs.push({ type: "PowerShell", source: { type: "Online", value: fileSourceMap.PowerShell.Online, }, target: { type: powerShellLocalTarget, value: powerShellProfilePath, }, }) } } for (const config of configs) await syncEditorFile(config) if (types.includes("extensions")) { const extensionMap = new Map>() extensionMap.set("Online", await getEditorExtensions({ source: "Online" })) if (source !== "Online") { const canReadSourceExtensions = await canGetEditorExtensions({ editor: source }) if (!canReadSourceExtensions) throw new Error(`${source} 命令不可用,无法读取扩展列表,请确认已安装并加入 PATH`) extensionMap.set(source, await getEditorExtensions({ source })) } const sourceExtensions = extensionMap.get(source)! if (targets.includes("Code")) { const canReadCodeExtensions = await canGetEditorExtensions({ editor: "Code" }) if (!canReadCodeExtensions) consola.warn("Code 命令不可用,已跳过 VS Code 扩展同步") else { extensionMap.set("Code", await getEditorExtensions({ source: "Code" })) await syncEditorExtensions({ editor: "Code", sourceExtensions, targetExtensions: extensionMap.get("Code")!, }) } } if (targets.includes("Cursor")) { const canReadCursorExtensions = await canGetEditorExtensions({ editor: "Cursor" }) if (!canReadCursorExtensions) consola.warn("Cursor 命令不可用,已跳过 Cursor 扩展同步") else { extensionMap.set("Cursor", await getEditorExtensions({ source: "Cursor" })) await syncEditorExtensions({ editor: "Cursor", sourceExtensions, targetExtensions: extensionMap.get("Cursor")!, }) } } if (targets.includes("Antigravity")) { const canReadAntigravityExtensions = await canGetEditorExtensions({ editor: "Antigravity" }) if (!canReadAntigravityExtensions) consola.warn("Antigravity 命令不可用,已跳过 Antigravity 扩展同步") else { extensionMap.set("Antigravity", await getEditorExtensions({ source: "Antigravity" })) await syncEditorExtensions({ editor: "Antigravity", sourceExtensions, targetExtensions: extensionMap.get("Antigravity")!, }) } } if (targets.includes("Online")) await writeFile(join(onlinePath, "static", "extensions.json"), JSON.stringify(Array.from(sourceExtensions), null, 4)) } if (targets.includes("Online")) { await execAsync("npm run format", { cwd: onlinePath }) if (await hasChangeNoCommit(onlinePath)) { await addGitCommit({ message: getCommitMessage(CommitType.feat, "sync editor setting"), cwd: onlinePath, }) await execAsync(`git push`, { cwd: onlinePath, }) } } await writeZixuluSetting(setting) }