mirror of
https://github.com/chenasraf/simple-scaffold.git
synced 2026-05-18 01:29:09 +00:00
feat: support for remote template configs
This commit is contained in:
@@ -13,8 +13,8 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
massarg<ScaffoldCmdConfig>()
|
massarg<ScaffoldCmdConfig>()
|
||||||
.main((config) => {
|
.main(async (config) => {
|
||||||
const _config = parseConfig(config)
|
const _config = await parseConfig(config)
|
||||||
return Scaffold(_config)
|
return Scaffold(_config)
|
||||||
})
|
})
|
||||||
.option({
|
.option({
|
||||||
@@ -29,7 +29,7 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
|
|||||||
name: "config",
|
name: "config",
|
||||||
aliases: ["c"],
|
aliases: ["c"],
|
||||||
description:
|
description:
|
||||||
"Filename to load config from instead of passing arguments to CLI or using a Node.js script. You may pass a JSON or JS file, with a relative or absolute path.",
|
"Filename to load config from instead of passing arguments to CLI or using a Node.js script. You may pass a JSON or JS file, with a relative or absolute path, a URL to a repository, or a GitHub path (e.g. username/package). You may also optionally add a key (same as passing --key) to load from inside the config.",
|
||||||
})
|
})
|
||||||
.option({
|
.option({
|
||||||
name: "key",
|
name: "key",
|
||||||
|
|||||||
102
src/utils.ts
102
src/utils.ts
@@ -23,6 +23,8 @@ import dtFormat from "date-fns/format"
|
|||||||
import dtParseISO from "date-fns/parseISO"
|
import dtParseISO from "date-fns/parseISO"
|
||||||
import { glob, hasMagic } from "glob"
|
import { glob, hasMagic } from "glob"
|
||||||
import { OptionsBase } from "massarg/types"
|
import { OptionsBase } from "massarg/types"
|
||||||
|
import { spawn } from "node:child_process"
|
||||||
|
import os from "node:os"
|
||||||
|
|
||||||
const dateFns = {
|
const dateFns = {
|
||||||
add: dtAdd,
|
add: dtAdd,
|
||||||
@@ -99,10 +101,14 @@ export function handleErr(err: NodeJS.ErrnoException | null): void {
|
|||||||
if (err) throw err
|
if (err) throw err
|
||||||
}
|
}
|
||||||
|
|
||||||
export function log(config: ScaffoldConfig, level: LogLevel, ...obj: any[]): void {
|
/** @internal */
|
||||||
|
export type LogConfig = Pick<ScaffoldConfig, "quiet" | "verbose">
|
||||||
|
|
||||||
|
export function log(config: LogConfig, level: LogLevel, ...obj: any[]): void {
|
||||||
if (config.quiet || config.verbose === LogLevel.None || level < (config.verbose ?? LogLevel.Info)) {
|
if (config.quiet || config.verbose === LogLevel.None || level < (config.verbose ?? LogLevel.Info)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const levelColor: Record<LogLevel, keyof typeof chalk> = {
|
const levelColor: Record<LogLevel, keyof typeof chalk> = {
|
||||||
[LogLevel.None]: "reset",
|
[LogLevel.None]: "reset",
|
||||||
[LogLevel.Debug]: "blue",
|
[LogLevel.Debug]: "blue",
|
||||||
@@ -110,6 +116,7 @@ export function log(config: ScaffoldConfig, level: LogLevel, ...obj: any[]): voi
|
|||||||
[LogLevel.Warning]: "yellow",
|
[LogLevel.Warning]: "yellow",
|
||||||
[LogLevel.Error]: "red",
|
[LogLevel.Error]: "red",
|
||||||
}
|
}
|
||||||
|
|
||||||
const chalkFn: any = chalk[levelColor[level]]
|
const chalkFn: any = chalk[levelColor[level]]
|
||||||
const key: "log" | "warn" | "error" = level === LogLevel.Error ? "error" : level === LogLevel.Warning ? "warn" : "log"
|
const key: "log" | "warn" | "error" = level === LogLevel.Error ? "error" : level === LogLevel.Warning ? "warn" : "log"
|
||||||
const logFn: any = console[key]
|
const logFn: any = console[key]
|
||||||
@@ -118,8 +125,8 @@ export function log(config: ScaffoldConfig, level: LogLevel, ...obj: any[]): voi
|
|||||||
i instanceof Error
|
i instanceof Error
|
||||||
? chalkFn(i, JSON.stringify(i, undefined, 1), i.stack)
|
? chalkFn(i, JSON.stringify(i, undefined, 1), i.stack)
|
||||||
: typeof i === "object"
|
: typeof i === "object"
|
||||||
? chalkFn(JSON.stringify(i, undefined, 1))
|
? chalkFn(JSON.stringify(i, undefined, 1))
|
||||||
: chalkFn(i),
|
: chalkFn(i),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -370,16 +377,18 @@ export function logInitStep(config: ScaffoldConfig): void {
|
|||||||
name: config.name,
|
name: config.name,
|
||||||
templates: config.templates,
|
templates: config.templates,
|
||||||
output: config.output,
|
output: config.output,
|
||||||
createSubfolder: config.createSubFolder,
|
createSubFolder: config.createSubFolder,
|
||||||
data: config.data,
|
data: config.data,
|
||||||
overwrite: config.overwrite,
|
overwrite: config.overwrite,
|
||||||
quiet: config.quiet,
|
quiet: config.quiet,
|
||||||
subFolderTransformHelper: config.subFolderNameHelper,
|
subFolderNameHelper: config.subFolderNameHelper,
|
||||||
helpers: Object.keys(config.helpers ?? {}),
|
helpers: Object.keys(config.helpers ?? {}),
|
||||||
verbose: `${config.verbose} (${Object.keys(LogLevel).find(
|
verbose: `${config.verbose} (${Object.keys(LogLevel).find(
|
||||||
(k) => (LogLevel[k as any] as unknown as number) === config.verbose!,
|
(k) => (LogLevel[k as any] as unknown as number) === config.verbose!,
|
||||||
)})`,
|
)})`,
|
||||||
})
|
dryRun: config.dryRun,
|
||||||
|
beforeWrite: config.beforeWrite,
|
||||||
|
} as Record<keyof ScaffoldConfig, unknown>)
|
||||||
log(config, LogLevel.Info, "Data:", config.data)
|
log(config, LogLevel.Info, "Data:", config.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,21 +407,27 @@ function isWrappedWithQuotes(string: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
export function parseConfig(config: ScaffoldCmdConfig & OptionsBase): ScaffoldConfig {
|
export async function parseConfig(config: ScaffoldCmdConfig & OptionsBase): Promise<ScaffoldConfig> {
|
||||||
let c: ScaffoldConfig = config
|
let c: ScaffoldConfig = config
|
||||||
|
|
||||||
if (config.config) {
|
if (config.config) {
|
||||||
const [configFile, colonTemplate = "default"] = config.config.split(":")
|
const isUrl = config.config.includes("://")
|
||||||
const template = config.key ?? colonTemplate
|
|
||||||
const configImport: ScaffoldConfigFile = require(path.resolve(process.cwd(), configFile))
|
const hasColonToken = (!isUrl && config.config.includes(":")) || (isUrl && count(config.config, ":") > 1)
|
||||||
if (!configImport[template]) {
|
const colonIndex = config.config.lastIndexOf(":")
|
||||||
throw new Error(`Template "${template}" not found in ${configFile}`)
|
const [configFile, templateKey = "default"] = hasColonToken
|
||||||
|
? [config.config.substring(0, colonIndex), config.config.substring(colonIndex + 1)]
|
||||||
|
: [config.config, undefined]
|
||||||
|
const key = (config.key ?? templateKey) || "default"
|
||||||
|
const configImport = await getConfig({ config: configFile, quiet: config.quiet, verbose: config.verbose })
|
||||||
|
if (!configImport[key]) {
|
||||||
|
throw new Error(`Template "${key}" not found in ${configFile}`)
|
||||||
}
|
}
|
||||||
c = {
|
c = {
|
||||||
...config,
|
...config,
|
||||||
...configImport[template],
|
...configImport[key],
|
||||||
data: {
|
data: {
|
||||||
...configImport[template].data,
|
...configImport[key].data,
|
||||||
...config.data,
|
...config.data,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -422,3 +437,62 @@ export function parseConfig(config: ScaffoldCmdConfig & OptionsBase): ScaffoldCo
|
|||||||
delete config.appendData
|
delete config.appendData
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
export async function getConfig(
|
||||||
|
config: Pick<ScaffoldCmdConfig, "quiet" | "verbose" | "config">,
|
||||||
|
): Promise<ScaffoldConfigFile> {
|
||||||
|
const { config: configFile, ...logConfig } = config as Required<typeof config>
|
||||||
|
const url = new URL(configFile)
|
||||||
|
|
||||||
|
if (url.protocol === "file:") {
|
||||||
|
log(logConfig, LogLevel.Info, `Loading config from file ${configFile}`)
|
||||||
|
const absolutePath = path.resolve(process.cwd(), configFile)
|
||||||
|
return import(absolutePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHttp = url.protocol === "http:" || url.protocol === "https:"
|
||||||
|
const isGit = url.protocol === "git:" || (isHttp && url.pathname.endsWith(".git"))
|
||||||
|
|
||||||
|
if (isHttp || isGit) {
|
||||||
|
if (isGit) {
|
||||||
|
const repoUrl = `${url.protocol}//${url.host}${url.pathname}`
|
||||||
|
log(logConfig, LogLevel.Info, `Cloning git repo ${repoUrl}`)
|
||||||
|
const tmpPath = path.resolve(os.tmpdir(), `scaffold-config-${Date.now()}`)
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const clone = spawn("git", ["clone", repoUrl, tmpPath])
|
||||||
|
|
||||||
|
clone.on("error", reject)
|
||||||
|
clone.on("close", async (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
log(logConfig, LogLevel.Info, `Loading config from git repo: ${configFile}`)
|
||||||
|
const absolutePath = path.resolve(tmpPath, url.hash.replace("#", ""))
|
||||||
|
const loadedConfig = (await import(absolutePath)).default as ScaffoldConfigFile
|
||||||
|
log(logConfig, LogLevel.Info, `Loaded config from git repo`)
|
||||||
|
log(logConfig, LogLevel.Debug, `Raw config:`, loadedConfig)
|
||||||
|
const fixedConfig: ScaffoldConfigFile = Object.fromEntries(
|
||||||
|
Object.entries(loadedConfig).map(([k, v]) => [
|
||||||
|
k,
|
||||||
|
// use absolute paths for template as config is necessarily in another directory
|
||||||
|
{ ...v, templates: v.templates.map((t) => path.resolve(tmpPath, t)) },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
resolve(fixedConfig)
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Git clone failed with code ${code}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unsupported protocol ${url.protocol}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return import(path.resolve(process.cwd(), configFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
function count(string: string, substring: string): number {
|
||||||
|
return string.split(substring).length - 1
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user