feat(cli): allow local plugins

This commit is contained in:
saberzero1 2026-03-12 00:56:25 +01:00
parent 3e08aa2375
commit 30a01a8856
No known key found for this signature in database
6 changed files with 301 additions and 45 deletions

View File

@ -83,7 +83,20 @@ export function writeLockfile(lockfile) {
fs.writeFileSync(LOCKFILE_PATH, JSON.stringify(lockfile, null, 2) + "\n") fs.writeFileSync(LOCKFILE_PATH, JSON.stringify(lockfile, null, 2) + "\n")
} }
export function isLocalSource(source) {
if (source.startsWith("./") || source.startsWith("../") || source.startsWith("/")) {
return true
}
// Windows absolute paths (e.g. C:\ or D:/)
if (/^[A-Za-z]:[\\/]/.test(source)) {
return true
}
return false
}
export function extractPluginName(source) { export function extractPluginName(source) {
if (isLocalSource(source)) {
return path.basename(source.replace(/[\/]+$/, ""))
}
if (source.startsWith("github:")) { if (source.startsWith("github:")) {
const withoutPrefix = source.replace("github:", "") const withoutPrefix = source.replace("github:", "")
const [repoPath] = withoutPrefix.split("#") const [repoPath] = withoutPrefix.split("#")
@ -110,6 +123,11 @@ export function readManifestFromPackageJson(pluginDir) {
} }
export function parseGitSource(source) { export function parseGitSource(source) {
if (isLocalSource(source)) {
const resolved = path.resolve(source)
const name = path.basename(resolved)
return { name, url: resolved, ref: undefined, local: true }
}
if (source.startsWith("github:")) { if (source.startsWith("github:")) {
const [repoPath, ref] = source.replace("github:", "").split("#") const [repoPath, ref] = source.replace("github:", "").split("#")
const [owner, repo] = repoPath.split("/") const [owner, repo] = repoPath.split("/")

View File

@ -13,6 +13,7 @@ import {
getGitCommit, getGitCommit,
PLUGINS_DIR, PLUGINS_DIR,
LOCKFILE_PATH, LOCKFILE_PATH,
isLocalSource,
} from "./plugin-data.js" } from "./plugin-data.js"
const INTERNAL_EXPORTS = new Set(["manifest", "default"]) const INTERNAL_EXPORTS = new Set(["manifest", "default"])
@ -187,6 +188,37 @@ export async function handlePluginInstall() {
for (const [name, entry] of Object.entries(lockfile.plugins)) { for (const [name, entry] of Object.entries(lockfile.plugins)) {
const pluginDir = path.join(PLUGINS_DIR, name) const pluginDir = path.join(PLUGINS_DIR, name)
// Local plugin: ensure symlink exists
if (entry.commit === "local") {
try {
if (fs.existsSync(pluginDir)) {
const stat = fs.lstatSync(pluginDir)
if (stat.isSymbolicLink() && fs.readlinkSync(pluginDir) === entry.resolved) {
console.log(styleText("gray", `${name} (local) already linked`))
installed++
continue
}
// Wrong target or not a symlink — remove and re-link
if (stat.isSymbolicLink()) fs.unlinkSync(pluginDir)
else fs.rmSync(pluginDir, { recursive: true })
}
if (!fs.existsSync(entry.resolved)) {
console.log(styleText("red", `${name}: local path missing: ${entry.resolved}`))
failed++
continue
}
fs.mkdirSync(path.dirname(pluginDir), { recursive: true })
fs.symlinkSync(entry.resolved, pluginDir, "dir")
console.log(styleText("green", `${name} (local) linked`))
pluginsToBuild.push({ name, pluginDir })
installed++
} catch {
console.log(styleText("red", `${name}: failed to link local path`))
failed++
}
continue
}
if (fs.existsSync(pluginDir)) { if (fs.existsSync(pluginDir)) {
try { try {
const currentCommit = getGitCommit(pluginDir) const currentCommit = getGitCommit(pluginDir)
@ -270,7 +302,7 @@ export async function handlePluginAdd(sources) {
for (const source of sources) { for (const source of sources) {
try { try {
const { name, url, ref } = parseGitSource(source) const { name, url, ref, local } = parseGitSource(source)
const pluginDir = path.join(PLUGINS_DIR, name) const pluginDir = path.join(PLUGINS_DIR, name)
if (fs.existsSync(pluginDir)) { if (fs.existsSync(pluginDir)) {
@ -278,25 +310,45 @@ export async function handlePluginAdd(sources) {
continue continue
} }
console.log(styleText("cyan", `→ Adding ${name} from ${url}...`)) if (local) {
// Local path: create symlink instead of git clone
if (ref) { const resolvedPath = path.resolve(url)
execSync(`git clone --depth 1 --branch ${ref} ${url} ${pluginDir}`, { stdio: "ignore" }) if (!fs.existsSync(resolvedPath)) {
console.log(styleText("red", `✗ Local path does not exist: ${resolvedPath}`))
continue
}
console.log(styleText("cyan", `→ Adding ${name} from local path ${resolvedPath}...`))
fs.mkdirSync(path.dirname(pluginDir), { recursive: true })
fs.symlinkSync(resolvedPath, pluginDir, "dir")
lockfile.plugins[name] = {
source,
resolved: resolvedPath,
commit: "local",
installedAt: new Date().toISOString(),
}
addedPlugins.push({ name, pluginDir, source })
console.log(styleText("green", `✓ Added ${name} (local symlink)`))
} else { } else {
execSync(`git clone --depth 1 ${url} ${pluginDir}`, { stdio: "ignore" }) console.log(styleText("cyan", `→ Adding ${name} from ${url}...`))
}
const commit = getGitCommit(pluginDir) if (ref) {
lockfile.plugins[name] = { execSync(`git clone --depth 1 --branch ${ref} ${url} ${pluginDir}`, { stdio: "ignore" })
source, } else {
resolved: url, execSync(`git clone --depth 1 ${url} ${pluginDir}`, { stdio: "ignore" })
commit, }
...(ref && { ref }),
installedAt: new Date().toISOString(),
}
addedPlugins.push({ name, pluginDir, source }) const commit = getGitCommit(pluginDir)
console.log(styleText("green", `✓ Added ${name}@${commit.slice(0, 7)}`)) lockfile.plugins[name] = {
source,
resolved: url,
commit,
...(ref && { ref }),
installedAt: new Date().toISOString(),
}
addedPlugins.push({ name, pluginDir, source })
console.log(styleText("green", `✓ Added ${name}@${commit.slice(0, 7)}`))
}
} catch (error) { } catch (error) {
console.log(styleText("red", `✗ Failed to add ${source}: ${error}`)) console.log(styleText("red", `✗ Failed to add ${source}: ${error}`))
} }
@ -504,6 +556,17 @@ export async function handlePluginCheck() {
const results = [] const results = []
for (const [name, entry] of Object.entries(lockfile.plugins)) { for (const [name, entry] of Object.entries(lockfile.plugins)) {
// Local plugins: show "local" status, skip git checks
if (entry.commit === "local") {
results.push({
name,
installed: "local",
latest: "—",
status: "local",
})
continue
}
try { try {
const lsRemoteRef = entry.ref ? `refs/heads/${entry.ref}` : "HEAD" const lsRemoteRef = entry.ref ? `refs/heads/${entry.ref}` : "HEAD"
const latestCommit = execSync(`git ls-remote ${entry.resolved} ${lsRemoteRef}`, { const latestCommit = execSync(`git ls-remote ${entry.resolved} ${lsRemoteRef}`, {
@ -536,7 +599,11 @@ export async function handlePluginCheck() {
for (const r of results) { for (const r of results) {
const color = const color =
r.status === "up to date" ? "green" : r.status === "check failed" ? "red" : "yellow" r.status === "up to date" || r.status === "local"
? "green"
: r.status === "check failed"
? "red"
: "yellow"
console.log( console.log(
`${r.name.padEnd(nameWidth)}${r.installed.padEnd(12)}${r.latest.padEnd(12)}${styleText( `${r.name.padEnd(nameWidth)}${r.installed.padEnd(12)}${r.latest.padEnd(12)}${styleText(
color, color,
@ -571,6 +638,13 @@ export async function handlePluginUpdate(names) {
continue continue
} }
// Local plugins: just rebuild, no git operations
if (entry.commit === "local") {
console.log(styleText("cyan", `→ Rebuilding local plugin ${name}...`))
updatedPlugins.push({ name, pluginDir })
continue
}
try { try {
console.log(styleText("cyan", `→ Updating ${name}...`)) console.log(styleText("cyan", `→ Updating ${name}...`))
const fetchRef = entry.ref || "" const fetchRef = entry.ref || ""
@ -624,6 +698,20 @@ export async function handlePluginList() {
for (const [name, entry] of Object.entries(lockfile.plugins)) { for (const [name, entry] of Object.entries(lockfile.plugins)) {
const pluginDir = path.join(PLUGINS_DIR, name) const pluginDir = path.join(PLUGINS_DIR, name)
const exists = fs.existsSync(pluginDir) const exists = fs.existsSync(pluginDir)
// Local plugins: special display
if (entry.commit === "local") {
const isLinked = exists && fs.lstatSync(pluginDir).isSymbolicLink()
const status = isLinked ? styleText("green", "✓") : styleText("red", "✗")
console.log(` ${status} ${styleText("bold", name)}`)
console.log(` Source: ${entry.source}`)
console.log(` Type: local symlink`)
console.log(` Target: ${entry.resolved}`)
console.log(` Installed: ${new Date(entry.installedAt).toLocaleDateString()}`)
console.log()
continue
}
let currentCommit = entry.commit let currentCommit = entry.commit
if (exists) { if (exists) {
@ -676,6 +764,26 @@ export async function handlePluginRestore() {
continue continue
} }
// Local plugin: re-symlink
if (entry.commit === "local") {
try {
if (!fs.existsSync(entry.resolved)) {
console.log(styleText("red", `${name}: local path missing: ${entry.resolved}`))
failed++
continue
}
fs.mkdirSync(path.dirname(pluginDir), { recursive: true })
fs.symlinkSync(entry.resolved, pluginDir, "dir")
console.log(styleText("green", `${name} restored (local symlink)`))
restoredPlugins.push({ name, pluginDir })
installed++
} catch {
console.log(styleText("red", `${name}: failed to restore local symlink`))
failed++
}
continue
}
try { try {
console.log( console.log(
styleText("cyan", `${name}: cloning ${entry.resolved}@${entry.commit.slice(0, 7)}...`), styleText("cyan", `${name}: cloning ${entry.resolved}@${entry.commit.slice(0, 7)}...`),
@ -785,13 +893,18 @@ export async function handlePluginResolve({ dryRun = false } = {}) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true }) fs.mkdirSync(PLUGINS_DIR, { recursive: true })
} }
// Find config entries whose source is a git-resolvable URL and not yet in lockfile // Find config entries whose source is a git/local-resolvable URL and not yet in lockfile
const missing = pluginsJson.plugins.filter((entry) => { const missing = pluginsJson.plugins.filter((entry) => {
const name = extractPluginName(entry.source) const name = extractPluginName(entry.source)
if (lockfile.plugins[name]) return false if (lockfile.plugins[name]) return false
// Only attempt sources that parseGitSource can handle // Only attempt sources that parseGitSource can handle (git URLs + local paths)
const src = entry.source const src = entry.source
return src.startsWith("github:") || src.startsWith("git+") || src.startsWith("https://") return (
src.startsWith("github:") ||
src.startsWith("git+") ||
src.startsWith("https://") ||
isLocalSource(src)
)
}) })
if (missing.length === 0) { if (missing.length === 0) {
@ -818,10 +931,21 @@ export async function handlePluginResolve({ dryRun = false } = {}) {
for (const entry of missing) { for (const entry of missing) {
try { try {
const { name, url, ref } = parseGitSource(entry.source) const { name, url, ref, local } = parseGitSource(entry.source)
const pluginDir = path.join(PLUGINS_DIR, name) const pluginDir = path.join(PLUGINS_DIR, name)
if (fs.existsSync(pluginDir)) { if (fs.existsSync(pluginDir)) {
if (local) {
console.log(styleText("yellow", `${name} directory already exists, updating lockfile`))
lockfile.plugins[name] = {
source: entry.source,
resolved: url,
commit: "local",
installedAt: new Date().toISOString(),
}
installed.push({ name, pluginDir })
continue
}
console.log(styleText("yellow", `${name} directory already exists, updating lockfile`)) console.log(styleText("yellow", `${name} directory already exists, updating lockfile`))
const commit = getGitCommit(pluginDir) const commit = getGitCommit(pluginDir)
lockfile.plugins[name] = { lockfile.plugins[name] = {
@ -835,25 +959,46 @@ export async function handlePluginResolve({ dryRun = false } = {}) {
continue continue
} }
console.log(styleText("cyan", `→ Cloning ${name} from ${url}...`)) if (local) {
// Local path: symlink
if (ref) { const resolvedPath = path.resolve(url)
execSync(`git clone --depth 1 --branch ${ref} ${url} ${pluginDir}`, { stdio: "ignore" }) if (!fs.existsSync(resolvedPath)) {
console.log(styleText("red", `✗ Local path does not exist: ${resolvedPath}`))
failed++
continue
}
console.log(styleText("cyan", `→ Linking ${name} from ${resolvedPath}...`))
fs.mkdirSync(path.dirname(pluginDir), { recursive: true })
fs.symlinkSync(resolvedPath, pluginDir, "dir")
lockfile.plugins[name] = {
source: entry.source,
resolved: resolvedPath,
commit: "local",
installedAt: new Date().toISOString(),
}
installed.push({ name, pluginDir })
console.log(styleText("green", `✓ Linked ${name} (local)`))
} else { } else {
execSync(`git clone --depth 1 ${url} ${pluginDir}`, { stdio: "ignore" }) console.log(styleText("cyan", `→ Cloning ${name} from ${url}...`))
}
const commit = getGitCommit(pluginDir) if (ref) {
lockfile.plugins[name] = { execSync(`git clone --depth 1 --branch ${ref} ${url} ${pluginDir}`, { stdio: "ignore" })
source: entry.source, } else {
resolved: url, execSync(`git clone --depth 1 ${url} ${pluginDir}`, { stdio: "ignore" })
commit, }
...(ref && { ref }),
installedAt: new Date().toISOString(),
}
installed.push({ name, pluginDir }) const commit = getGitCommit(pluginDir)
console.log(styleText("green", `✓ Cloned ${name}@${commit.slice(0, 7)}`)) lockfile.plugins[name] = {
source: entry.source,
resolved: url,
commit,
...(ref && { ref }),
installedAt: new Date().toISOString(),
}
installed.push({ name, pluginDir })
console.log(styleText("green", `✓ Cloned ${name}@${commit.slice(0, 7)}`))
}
} catch (error) { } catch (error) {
console.log(styleText("red", `✗ Failed to resolve ${entry.source}: ${error}`)) console.log(styleText("red", `✗ Failed to resolve ${entry.source}: ${error}`))
failed++ failed++

View File

@ -13,7 +13,13 @@ import {
PluginLayoutDeclaration, PluginLayoutDeclaration,
FlexGroupConfig, FlexGroupConfig,
} from "./types" } from "./types"
import { parsePluginSource, installPlugin, getPluginEntryPoint, toFileUrl } from "./gitLoader" import {
parsePluginSource,
installPlugin,
getPluginEntryPoint,
toFileUrl,
isLocalSource,
} from "./gitLoader"
import { loadComponentsFromPackage } from "./componentLoader" import { loadComponentsFromPackage } from "./componentLoader"
import { loadFramesFromPackage } from "./frameLoader" import { loadFramesFromPackage } from "./frameLoader"
import { componentRegistry } from "../../components/registry" import { componentRegistry } from "../../components/registry"
@ -44,6 +50,10 @@ function readPluginsJson(): QuartzPluginsJson | null {
} }
function extractPluginName(source: string): string { function extractPluginName(source: string): string {
// Local file paths: use directory basename
if (isLocalSource(source)) {
return path.basename(source.replace(/[\/]+$/, ""))
}
if (source.startsWith("github:")) { if (source.startsWith("github:")) {
const withoutPrefix = source.replace("github:", "") const withoutPrefix = source.replace("github:", "")
const [repoPath] = withoutPrefix.split("#") const [repoPath] = withoutPrefix.split("#")

View File

@ -21,27 +21,52 @@ export function toFileUrl(filePath: string): string {
export interface GitPluginSpec { export interface GitPluginSpec {
/** Plugin name (used for directory) */ /** Plugin name (used for directory) */
name: string name: string
/** Git repository URL (https://github.com/user/repo.git or just github:user/repo) */ /** Git repository URL or absolute local path */
repo: string repo: string
/** Git ref (branch, tag, or commit hash). Defaults to 'main' */ /** Git ref (branch, tag, or commit hash). Defaults to 'main' */
ref?: string ref?: string
/** Optional subdirectory within the repo if plugin is not at root */ /** Optional subdirectory within the repo if plugin is not at root */
subdir?: string subdir?: string
/** Whether this is a local path source */
local?: boolean
} }
export type PluginInstallSource = string | GitPluginSpec export type PluginInstallSource = string | GitPluginSpec
const PLUGINS_CACHE_DIR = path.join(process.cwd(), ".quartz", "plugins") const PLUGINS_CACHE_DIR = path.join(process.cwd(), ".quartz", "plugins")
/**
* Check if a source string refers to a local file path.
* Local sources start with ./, ../, / or a Windows drive letter (e.g. C:\).
*/
export function isLocalSource(source: string): boolean {
if (source.startsWith("./") || source.startsWith("../") || source.startsWith("/")) {
return true
}
// Windows absolute paths (e.g. C:\ or D:/)
if (/^[A-Za-z]:[\\/]/.test(source)) {
return true
}
return false
}
/** /**
* Parse a plugin source string into a GitPluginSpec * Parse a plugin source string into a GitPluginSpec
* Supports: * Supports:
* - "./path/to/plugin" or "/absolute/path" -> local path
* - "github:user/repo" -> https://github.com/user/repo.git * - "github:user/repo" -> https://github.com/user/repo.git
* - "github:user/repo#ref" -> https://github.com/user/repo.git with specific ref * - "github:user/repo#ref" -> https://github.com/user/repo.git with specific ref
* - "git+https://..." -> direct git URL * - "git+https://..." -> direct git URL
* - "https://github.com/..." -> direct https URL * - "https://github.com/..." -> direct https URL
*/ */
export function parsePluginSource(source: string): GitPluginSpec { export function parsePluginSource(source: string): GitPluginSpec {
// Handle local paths
if (isLocalSource(source)) {
const resolved = path.resolve(source)
const name = path.basename(resolved)
return { name, repo: resolved, local: true }
}
// Handle github shorthand: github:user/repo or github:user/repo#ref // Handle github shorthand: github:user/repo or github:user/repo#ref
if (source.startsWith("github:")) { if (source.startsWith("github:")) {
const withoutPrefix = source.replace("github:", "") const withoutPrefix = source.replace("github:", "")
@ -94,7 +119,7 @@ function extractRepoName(url: string): string {
} }
/** /**
* Install a plugin from a Git repository * Install a plugin from a Git repository, or symlink a local plugin.
*/ */
export async function installPlugin( export async function installPlugin(
spec: GitPluginSpec, spec: GitPluginSpec,
@ -102,6 +127,57 @@ export async function installPlugin(
): Promise<string> { ): Promise<string> {
const pluginDir = path.join(PLUGINS_CACHE_DIR, spec.name) const pluginDir = path.join(PLUGINS_CACHE_DIR, spec.name)
// Local source: symlink instead of clone
if (spec.local) {
if (!fs.existsSync(spec.repo)) {
throw new Error(`Local plugin path does not exist: ${spec.repo}`)
}
if (!options.force && fs.existsSync(pluginDir)) {
// Check if existing entry is already a symlink to the right place
try {
const stat = fs.lstatSync(pluginDir)
if (stat.isSymbolicLink() && fs.realpathSync(pluginDir) === fs.realpathSync(spec.repo)) {
if (options.verbose) {
console.log(styleText("cyan", ``), `Plugin ${spec.name} already linked`)
}
return pluginDir
}
} catch {
// stat failed, recreate
}
}
// Clean up if force reinstall or existing non-symlink entry
if (fs.existsSync(pluginDir)) {
const stat = fs.lstatSync(pluginDir)
if (stat.isSymbolicLink()) {
fs.unlinkSync(pluginDir)
} else {
fs.rmSync(pluginDir, { recursive: true })
}
}
// Ensure parent directory exists
const parentDir = path.dirname(pluginDir)
if (!fs.existsSync(parentDir)) {
fs.mkdirSync(parentDir, { recursive: true })
}
if (options.verbose) {
console.log(styleText("cyan", ``), `Linking ${spec.name} from ${spec.repo}...`)
}
fs.symlinkSync(spec.repo, pluginDir, "dir")
if (options.verbose) {
console.log(styleText("green", ``), `Linked ${spec.name}`)
}
return pluginDir
}
// Git source: clone
// Check if already installed // Check if already installed
if (!options.force && fs.existsSync(pluginDir)) { if (!options.force && fs.existsSync(pluginDir)) {
// Check if it's a git repo by trying to resolve HEAD // Check if it's a git repo by trying to resolve HEAD

View File

@ -14,7 +14,13 @@ import {
QuartzEmitterPlugin, QuartzEmitterPlugin,
QuartzPageTypePlugin, QuartzPageTypePlugin,
} from "../types" } from "../types"
import { parsePluginSource, installPlugin, getPluginEntryPoint, toFileUrl } from "./gitLoader" import {
parsePluginSource,
installPlugin,
getPluginEntryPoint,
toFileUrl,
isLocalSource,
} from "./gitLoader"
const MINIMUM_QUARTZ_VERSION = "4.5.0" const MINIMUM_QUARTZ_VERSION = "4.5.0"
@ -115,8 +121,9 @@ function extractPluginFactory(
} }
function isGitSource(source: string): boolean { function isGitSource(source: string): boolean {
// Check if it's a Git-based source // Check if it's a Git-based or local file path source
return ( return (
isLocalSource(source) ||
source.startsWith("github:") || source.startsWith("github:") ||
source.startsWith("git+") || source.startsWith("git+") ||
source.startsWith("https://github.com/") || source.startsWith("https://github.com/") ||
@ -267,7 +274,7 @@ async function resolveSinglePlugin(
plugin: factory, plugin: factory,
manifest: fullManifest, manifest: fullManifest,
type: detectedType, type: detectedType,
source: `${gitSpec.repo}#${gitSpec.ref}`, source: gitSpec.local ? `local:${gitSpec.repo}` : `${gitSpec.repo}#${gitSpec.ref}`,
} }
if (options.verbose) { if (options.verbose) {

View File

@ -192,7 +192,7 @@
"properties": { "properties": {
"source": { "source": {
"type": "string", "type": "string",
"description": "Plugin source path or identifier. Supports github:user/repo, git+https://, and https:// URLs. Append #ref to pin to a specific branch or tag (e.g., github:user/repo#my-branch)." "description": "Plugin source path or identifier. Supports github:user/repo, git+https://, and https:// URLs. Append #ref to pin to a specific branch or tag (e.g., github:user/repo#my-branch). Local file paths (e.g., ./my-plugin, ../sibling-plugin, /absolute/path) are also supported for local development or airgapped environments."
}, },
"enabled": { "enabled": {
"type": "boolean", "type": "boolean",