mirror of
https://github.com/jackyzha0/quartz.git
synced 2026-03-21 21:45:42 -05:00
feat(cli): allow local plugins
This commit is contained in:
parent
3e08aa2375
commit
30a01a8856
@ -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("/")
|
||||||
|
|||||||
@ -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++
|
||||||
|
|||||||
@ -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("#")
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user