Skip to content

Commit 05487f4

Browse files
committed
feat: support for remote template configs
1 parent c50518a commit 05487f4

2 files changed

Lines changed: 91 additions & 17 deletions

File tree

src/cmd.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
1313

1414
return (
1515
massarg<ScaffoldCmdConfig>()
16-
.main((config) => {
17-
const _config = parseConfig(config)
16+
.main(async (config) => {
17+
const _config = await parseConfig(config)
1818
return Scaffold(_config)
1919
})
2020
.option({
@@ -29,7 +29,7 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
2929
name: "config",
3030
aliases: ["c"],
3131
description:
32-
"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.",
32+
"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.",
3333
})
3434
.option({
3535
name: "key",

src/utils.ts

Lines changed: 88 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import dtFormat from "date-fns/format"
2323
import dtParseISO from "date-fns/parseISO"
2424
import { glob, hasMagic } from "glob"
2525
import { OptionsBase } from "massarg/types"
26+
import { spawn } from "node:child_process"
27+
import os from "node:os"
2628

2729
const dateFns = {
2830
add: dtAdd,
@@ -99,17 +101,22 @@ export function handleErr(err: NodeJS.ErrnoException | null): void {
99101
if (err) throw err
100102
}
101103

102-
export function log(config: ScaffoldConfig, level: LogLevel, ...obj: any[]): void {
104+
/** @internal */
105+
export type LogConfig = Pick<ScaffoldConfig, "quiet" | "verbose">
106+
107+
export function log(config: LogConfig, level: LogLevel, ...obj: any[]): void {
103108
if (config.quiet || config.verbose === LogLevel.None || level < (config.verbose ?? LogLevel.Info)) {
104109
return
105110
}
111+
106112
const levelColor: Record<LogLevel, keyof typeof chalk> = {
107113
[LogLevel.None]: "reset",
108114
[LogLevel.Debug]: "blue",
109115
[LogLevel.Info]: "dim",
110116
[LogLevel.Warning]: "yellow",
111117
[LogLevel.Error]: "red",
112118
}
119+
113120
const chalkFn: any = chalk[levelColor[level]]
114121
const key: "log" | "warn" | "error" = level === LogLevel.Error ? "error" : level === LogLevel.Warning ? "warn" : "log"
115122
const logFn: any = console[key]
@@ -118,8 +125,8 @@ export function log(config: ScaffoldConfig, level: LogLevel, ...obj: any[]): voi
118125
i instanceof Error
119126
? chalkFn(i, JSON.stringify(i, undefined, 1), i.stack)
120127
: typeof i === "object"
121-
? chalkFn(JSON.stringify(i, undefined, 1))
122-
: chalkFn(i),
128+
? chalkFn(JSON.stringify(i, undefined, 1))
129+
: chalkFn(i),
123130
),
124131
)
125132
}
@@ -370,16 +377,18 @@ export function logInitStep(config: ScaffoldConfig): void {
370377
name: config.name,
371378
templates: config.templates,
372379
output: config.output,
373-
createSubfolder: config.createSubFolder,
380+
createSubFolder: config.createSubFolder,
374381
data: config.data,
375382
overwrite: config.overwrite,
376383
quiet: config.quiet,
377-
subFolderTransformHelper: config.subFolderNameHelper,
384+
subFolderNameHelper: config.subFolderNameHelper,
378385
helpers: Object.keys(config.helpers ?? {}),
379386
verbose: `${config.verbose} (${Object.keys(LogLevel).find(
380387
(k) => (LogLevel[k as any] as unknown as number) === config.verbose!,
381388
)})`,
382-
})
389+
dryRun: config.dryRun,
390+
beforeWrite: config.beforeWrite,
391+
} as Record<keyof ScaffoldConfig, unknown>)
383392
log(config, LogLevel.Info, "Data:", config.data)
384393
}
385394

@@ -398,21 +407,27 @@ function isWrappedWithQuotes(string: string): boolean {
398407
}
399408

400409
/** @internal */
401-
export function parseConfig(config: ScaffoldCmdConfig & OptionsBase): ScaffoldConfig {
410+
export async function parseConfig(config: ScaffoldCmdConfig & OptionsBase): Promise<ScaffoldConfig> {
402411
let c: ScaffoldConfig = config
403412

404413
if (config.config) {
405-
const [configFile, colonTemplate = "default"] = config.config.split(":")
406-
const template = config.key ?? colonTemplate
407-
const configImport: ScaffoldConfigFile = require(path.resolve(process.cwd(), configFile))
408-
if (!configImport[template]) {
409-
throw new Error(`Template "${template}" not found in ${configFile}`)
414+
const isUrl = config.config.includes("://")
415+
416+
const hasColonToken = (!isUrl && config.config.includes(":")) || (isUrl && count(config.config, ":") > 1)
417+
const colonIndex = config.config.lastIndexOf(":")
418+
const [configFile, templateKey = "default"] = hasColonToken
419+
? [config.config.substring(0, colonIndex), config.config.substring(colonIndex + 1)]
420+
: [config.config, undefined]
421+
const key = (config.key ?? templateKey) || "default"
422+
const configImport = await getConfig({ config: configFile, quiet: config.quiet, verbose: config.verbose })
423+
if (!configImport[key]) {
424+
throw new Error(`Template "${key}" not found in ${configFile}`)
410425
}
411426
c = {
412427
...config,
413-
...configImport[template],
428+
...configImport[key],
414429
data: {
415-
...configImport[template].data,
430+
...configImport[key].data,
416431
...config.data,
417432
},
418433
}
@@ -422,3 +437,62 @@ export function parseConfig(config: ScaffoldCmdConfig & OptionsBase): ScaffoldCo
422437
delete config.appendData
423438
return c
424439
}
440+
441+
/** @internal */
442+
export async function getConfig(
443+
config: Pick<ScaffoldCmdConfig, "quiet" | "verbose" | "config">,
444+
): Promise<ScaffoldConfigFile> {
445+
const { config: configFile, ...logConfig } = config as Required<typeof config>
446+
const url = new URL(configFile)
447+
448+
if (url.protocol === "file:") {
449+
log(logConfig, LogLevel.Info, `Loading config from file ${configFile}`)
450+
const absolutePath = path.resolve(process.cwd(), configFile)
451+
return import(absolutePath)
452+
}
453+
454+
const isHttp = url.protocol === "http:" || url.protocol === "https:"
455+
const isGit = url.protocol === "git:" || (isHttp && url.pathname.endsWith(".git"))
456+
457+
if (isHttp || isGit) {
458+
if (isGit) {
459+
const repoUrl = `${url.protocol}//${url.host}${url.pathname}`
460+
log(logConfig, LogLevel.Info, `Cloning git repo ${repoUrl}`)
461+
const tmpPath = path.resolve(os.tmpdir(), `scaffold-config-${Date.now()}`)
462+
463+
return new Promise((resolve, reject) => {
464+
const clone = spawn("git", ["clone", repoUrl, tmpPath])
465+
466+
clone.on("error", reject)
467+
clone.on("close", async (code) => {
468+
if (code === 0) {
469+
log(logConfig, LogLevel.Info, `Loading config from git repo: ${configFile}`)
470+
const absolutePath = path.resolve(tmpPath, url.hash.replace("#", ""))
471+
const loadedConfig = (await import(absolutePath)).default as ScaffoldConfigFile
472+
log(logConfig, LogLevel.Info, `Loaded config from git repo`)
473+
log(logConfig, LogLevel.Debug, `Raw config:`, loadedConfig)
474+
const fixedConfig: ScaffoldConfigFile = Object.fromEntries(
475+
Object.entries(loadedConfig).map(([k, v]) => [
476+
k,
477+
// use absolute paths for template as config is necessarily in another directory
478+
{ ...v, templates: v.templates.map((t) => path.resolve(tmpPath, t)) },
479+
]),
480+
)
481+
482+
resolve(fixedConfig)
483+
} else {
484+
reject(new Error(`Git clone failed with code ${code}`))
485+
}
486+
})
487+
})
488+
}
489+
490+
throw new Error(`Unsupported protocol ${url.protocol}`)
491+
}
492+
493+
return import(path.resolve(process.cwd(), configFile))
494+
}
495+
496+
function count(string: string, substring: string): number {
497+
return string.split(substring).length - 1
498+
}

0 commit comments

Comments
 (0)