feat(cli): async update checking

This commit is contained in:
saberzero1
2026-03-31 19:22:17 +02:00
parent 04912ac2e5
commit 3012aa01ea
3 changed files with 252 additions and 81 deletions

View File

@@ -14,6 +14,7 @@ import {
handlePluginAdd, handlePluginAdd,
handlePluginRemove, handlePluginRemove,
handlePluginList, handlePluginList,
handlePluginStatus,
handlePluginEnable, handlePluginEnable,
handlePluginDisable, handlePluginDisable,
handlePluginConfig, handlePluginConfig,
@@ -70,9 +71,14 @@ yargs(hideBin(process.argv))
.command("create", "Initialize Quartz", CreateArgv, async (argv) => { .command("create", "Initialize Quartz", CreateArgv, async (argv) => {
await handleCreate(argv) await handleCreate(argv)
}) })
.command("upgrade", "Upgrade Quartz to the latest version", CommonArgv, async (argv) => { .command(
await handleUpgrade(argv) ["upgrade", "update"],
}) "Upgrade Quartz to the latest version",
CommonArgv,
async (argv) => {
await handleUpgrade(argv)
},
)
.command( .command(
"restore", "restore",
"Try to restore your content folder from the cache", "Try to restore your content folder from the cache",
@@ -256,7 +262,7 @@ yargs(hideBin(process.argv))
}, },
async (argv) => { async (argv) => {
if (!argv._.includes("plugin") || argv._.length > 1) return if (!argv._.includes("plugin") || argv._.length > 1) return
await launchTui() await handlePluginStatus()
}, },
) )
.showHelpOnFail(false) .showHelpOnFail(false)

View File

@@ -117,30 +117,3 @@ export const BuildArgv = {
describe: "how many threads to use to parse notes", describe: "how many threads to use to parse notes",
}, },
} }
export const PluginInstallArgv = {
...CommonArgv,
_: {
type: "string",
demandOption: true,
describe: "package names to install",
},
}
export const PluginUninstallArgv = {
...CommonArgv,
_: {
type: "string",
demandOption: true,
describe: "package names to uninstall",
},
}
export const PluginSearchArgv = {
...CommonArgv,
query: {
string: true,
alias: ["q"],
describe: "search query for plugins",
},
}

View File

@@ -314,67 +314,100 @@ export async function handlePluginInstallUnified({
const nameOverrides = getNameOverrides(lockfile, pluginsJson) const nameOverrides = getNameOverrides(lockfile, pluginsJson)
console.log(styleText("bold", "Checking for plugin updates...\n")) const rows = Object.entries(lockfile.plugins)
.filter(([name]) => !nameFilter || nameFilter.has(name))
.map(([name, entry]) => ({
name,
entry,
displayName: nameOverrides.get(name) ?? name,
}))
const results = [] const isTTY = process.stdout.isTTY
for (const [name, entry] of Object.entries(lockfile.plugins)) { const nameWidth = Math.max(6, ...rows.map((row) => row.displayName.length)) + 2
if (nameFilter && !nameFilter.has(name)) continue const header = `${"Plugin".padEnd(nameWidth)}${"Installed".padEnd(12)}${"Latest".padEnd(12)}Status`
const displayName = nameOverrides.get(name) ?? name
if (entry.commit === "local") { const renderRow = ({ displayName }, installed, latest, statusLabel) =>
results.push({ `${displayName.padEnd(nameWidth)}${installed.padEnd(12)}${latest.padEnd(12)}${statusLabel}`
name: displayName,
const updateRow = (index, installed, latest, statusLabel) => {
if (!isTTY) return
const offset = rows.length - index
process.stdout.write(
`\x1b[${offset}A\x1b[2K\r${renderRow(rows[index], installed, latest, statusLabel)}\x1b[${offset}B`,
)
}
if (isTTY) {
console.log(styleText("bold", "Checking for plugin updates...\n"))
console.log(styleText("bold", header))
console.log("─".repeat(header.length))
for (const row of rows) {
if (row.entry.commit === "local") {
console.log(renderRow(row, "local", "—", styleText("green", "local")))
continue
}
console.log(renderRow(row, row.entry.commit.slice(0, 7), "—", styleText("cyan", "⋯")))
}
}
const promises = rows.map((row, index) => {
if (row.entry.commit === "local") {
return Promise.resolve({
index,
installed: "local", installed: "local",
latest: "—", latest: "—",
status: "local", status: "local",
}) })
continue
} }
try { const lsRemoteRef = row.entry.ref ? `refs/heads/${row.entry.ref}` : "HEAD"
const lsRemoteRef = entry.ref ? `refs/heads/${entry.ref}` : "HEAD" return execAsync(`git ls-remote "${row.entry.resolved}" ${lsRemoteRef}`)
const latestCommit = execSync(`git ls-remote "${entry.resolved}" ${lsRemoteRef}`, { .then(({ stdout }) => {
encoding: "utf-8", const latestCommit = stdout.split("\t")[0].trim()
const isCurrent = latestCommit === row.entry.commit
const installed = row.entry.commit.slice(0, 7)
const latest = latestCommit.slice(0, 7)
const statusLabel = isCurrent
? styleText("green", "up to date")
: styleText("yellow", "update available")
updateRow(index, installed, latest, statusLabel)
return {
index,
installed,
latest,
status: isCurrent ? "up to date" : "update available",
}
}) })
.split("\t")[0] .catch(() => {
.trim() const installed = row.entry.commit.slice(0, 7)
const latest = "?"
const statusLabel = styleText("red", "check failed")
updateRow(index, installed, latest, statusLabel)
return {
index,
installed,
latest,
status: "check failed",
}
})
})
const isCurrent = latestCommit === entry.commit const results = await Promise.all(promises)
results.push({
name: displayName, if (!isTTY) {
installed: entry.commit.slice(0, 7), console.log(styleText("bold", "Checking for plugin updates...\n"))
latest: latestCommit.slice(0, 7), console.log(styleText("bold", header))
status: isCurrent ? "up to date" : "update available", console.log("─".repeat(header.length))
}) for (const { index, installed, latest, status } of results) {
} catch { const color =
results.push({ status === "up to date" || status === "local"
name: displayName, ? "green"
installed: entry.commit.slice(0, 7), : status === "check failed"
latest: "?", ? "red"
status: "check failed", : "yellow"
}) console.log(renderRow(rows[index], installed, latest, styleText(color, status)))
} }
} }
const nameWidth = Math.max(6, ...results.map((r) => r.name.length)) + 2
const header = `${"Plugin".padEnd(nameWidth)}${"Installed".padEnd(12)}${"Latest".padEnd(12)}Status`
console.log(styleText("bold", header))
console.log("─".repeat(header.length))
for (const r of results) {
const color =
r.status === "up to date" || r.status === "local"
? "green"
: r.status === "check failed"
? "red"
: "yellow"
console.log(
`${r.name.padEnd(nameWidth)}${r.installed.padEnd(12)}${r.latest.padEnd(12)}${styleText(
color,
r.status,
)}`,
)
}
return return
} }
@@ -583,16 +616,30 @@ export async function handlePluginInstallUnified({
if (orphans.length > 0) { if (orphans.length > 0) {
console.log() console.log()
let removedOrphans = false
for (const name of orphans) { for (const name of orphans) {
const entry = lockfile.plugins[name]
if (entry?.commit === "local") {
console.log(
styleText(
"yellow",
`${name} is a local plugin not in config — skipping (remove manually with 'plugin remove')`,
),
)
continue
}
const pluginDir = path.join(PLUGINS_DIR, name) const pluginDir = path.join(PLUGINS_DIR, name)
if (fs.existsSync(pluginDir)) { if (fs.existsSync(pluginDir)) {
fs.rmSync(pluginDir, { recursive: true }) fs.rmSync(pluginDir, { recursive: true })
} }
delete lockfile.plugins[name] delete lockfile.plugins[name]
lockfileChanged = true lockfileChanged = true
removedOrphans = true
console.log(styleText("yellow", `✗ Removed ${name} (not in config)`)) console.log(styleText("yellow", `✗ Removed ${name} (not in config)`))
} }
await regeneratePluginIndex() if (removedOrphans) {
await regeneratePluginIndex()
}
} }
if (lockfileChanged) { if (lockfileChanged) {
@@ -1357,6 +1404,151 @@ export async function handlePluginList() {
} }
} }
export async function handlePluginStatus() {
const lockfile = readLockfile()
if (!lockfile || Object.keys(lockfile.plugins).length === 0) {
console.log(styleText("gray", "No plugins installed"))
return
}
const pluginsJson = readPluginsJson()
const nameOverrides = getNameOverrides(lockfile, pluginsJson)
const enabledByName = new Map(
(pluginsJson?.plugins ?? []).map((entry) => [
extractPluginName(entry.source),
entry.enabled !== false,
]),
)
const rows = Object.entries(lockfile.plugins).map(([name, entry]) => {
const pluginDir = path.join(PLUGINS_DIR, name)
const exists = fs.existsSync(pluginDir)
const displayName = nameOverrides.get(name) ?? name
const sourceLabel = formatSource(entry.source)
const commitLabel = entry.commit === "local" ? "local" : `@${entry.commit.slice(0, 7)}`
const enabled = enabledByName.get(name) ?? false
return { name, entry, exists, displayName, sourceLabel, commitLabel, enabled }
})
const nameWidth = Math.max(8, ...rows.map((row) => row.displayName.length)) + 2
const sourceWidth = Math.max(8, ...rows.map((row) => row.sourceLabel.length)) + 2
const commitWidth = Math.max(6, ...rows.map((row) => row.commitLabel.length)) + 2
const enabledWidth = Math.max("enabled".length, "disabled".length) + 2
const updateWidth =
Math.max(
"— local".length,
"⋯".length,
"✓ up to date".length,
"↑ update available".length,
"✗ check failed".length,
) + 2
const formatRow = (row, updateLabel, updateText) => {
const statusIcon = row.exists ? styleText("green", "✓") : styleText("red", "✗")
const enabledText = row.enabled ? "enabled" : "disabled"
const enabledLabel = row.enabled
? styleText("green", enabledText)
: styleText("gray", enabledText)
const enabledColumn = `${enabledLabel}${" ".repeat(enabledWidth - enabledText.length)}`
const updateColumn = `${updateLabel}${" ".repeat(Math.max(0, updateWidth - updateText.length))}`
return ` ${statusIcon} ${row.displayName.padEnd(nameWidth)}${row.sourceLabel.padEnd(
sourceWidth,
)}${row.commitLabel.padEnd(commitWidth)}${enabledColumn}${updateColumn}`
}
const updateDisplay = (status) => {
switch (status) {
case "local":
return { text: "— local", label: styleText("gray", "— local") }
case "up_to_date":
return { text: "✓ up to date", label: styleText("green", "✓ up to date") }
case "update_available":
return { text: "↑ update available", label: styleText("yellow", "↑ update available") }
case "failed":
return { text: "✗ check failed", label: styleText("red", "✗ check failed") }
default:
return { text: "⋯", label: styleText("cyan", "⋯") }
}
}
const isTTY = process.stdout.isTTY
const updateLine = (index, updateLabel, updateText) => {
if (!isTTY) return
const offset = rows.length - index
process.stdout.write(
`\x1b[${offset}A\x1b[2K\r${formatRow(rows[index], updateLabel, updateText)}\x1b[${offset}B`,
)
}
if (isTTY) {
console.log(styleText("bold", "Installed Plugins:"))
console.log()
for (const row of rows) {
const display =
row.entry.commit === "local" ? updateDisplay("local") : updateDisplay("checking")
console.log(formatRow(row, display.label, display.text))
}
}
const promises = rows.map((row, index) => {
if (row.entry.commit === "local") {
return Promise.resolve({
index,
status: "local",
name: row.displayName,
})
}
const lsRemoteRef = row.entry.ref ? `refs/heads/${row.entry.ref}` : "HEAD"
return execAsync(`git ls-remote "${row.entry.resolved}" ${lsRemoteRef}`)
.then(({ stdout }) => {
const latestCommit = stdout.split("\t")[0].trim()
const status = latestCommit === row.entry.commit ? "up_to_date" : "update_available"
const display = updateDisplay(status)
updateLine(index, display.label, display.text)
return { index, status, name: row.displayName }
})
.catch(() => {
const display = updateDisplay("failed")
updateLine(index, display.label, display.text)
return { index, status: "failed", name: row.displayName }
})
})
const results = await Promise.all(promises)
const updatesAvailable = results
.filter((result) => result.status === "update_available")
.map((result) => result.name)
const failedChecks = results
.filter((result) => result.status === "failed")
.map((result) => result.name)
if (!isTTY) {
console.log(styleText("bold", "Installed Plugins:"))
console.log()
for (const result of results) {
const row = rows[result.index]
const display = updateDisplay(result.status)
console.log(formatRow(row, display.label, display.text))
}
}
if (updatesAvailable.length === 0 && failedChecks.length === 0) {
console.log(styleText("green", "\n✓ All plugins up to date"))
return
}
if (updatesAvailable.length > 0) {
console.log(styleText("yellow", `\nUpdates available: ${updatesAvailable.join(", ")}`))
console.log(styleText("gray", "Run 'npx quartz plugin install --latest' to update."))
}
if (failedChecks.length > 0) {
console.log(styleText("red", `\nChecks failed: ${failedChecks.join(", ")}`))
}
}
export async function handlePluginRestore() { export async function handlePluginRestore() {
return handlePluginInstallUnified({ clean: true }) return handlePluginInstallUnified({ clean: true })
} }