Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ npx giget@latest https://api.github.com/repos/unjs/template/tarball/main

# Clone from https URL (JSON)
npx giget@latest https://raw.githubusercontent.com/unjs/giget/main/templates/unjs.json

# Clone from a local directory using file protocol
npx giget@latest file:///absolute/path/to/template

# Clone from a local directory using file protocol and expand ~ to home directory
npx giget@latest file:~/Documents/my-template
```

## Template Registry
Expand Down
30 changes: 20 additions & 10 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { relative } from "node:path";
import { defineCommand, runMain } from "citty";
import { consola } from "consola";
import pkg from "../package.json" assert { type: "json" };
import { downloadTemplate } from "./giget";
import { copyTemplate, downloadTemplate } from "./giget";
import { startShell } from "./_utils";

const mainCommand = defineCommand({
Expand Down Expand Up @@ -69,15 +69,25 @@ const mainCommand = defineCommand({
process.env.DEBUG = process.env.DEBUG || "true";
}

const r = await downloadTemplate(args.template, {
dir: args.dir,
force: args.force,
forceClean: args.forceClean,
offline: args.offline,
preferOffline: args.preferOffline,
auth: args.auth,
install: args.install,
});
const r = args.template.startsWith("file:")
? await copyTemplate(args.template, {
dir: args.dir,
force: args.force,
forceClean: args.forceClean,
offline: args.offline,
preferOffline: args.preferOffline,
auth: args.auth,
install: args.install,
})
: await downloadTemplate(args.template, {
dir: args.dir,
force: args.force,
forceClean: args.forceClean,
offline: args.offline,
preferOffline: args.preferOffline,
auth: args.auth,
install: args.install,
});

const _from = r.name || r.url;
const _to = relative(process.cwd(), r.dir) || "./";
Expand Down
68 changes: 66 additions & 2 deletions src/giget.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { mkdir, rm } from "node:fs/promises";
import { mkdir, rm, cp } from "node:fs/promises";
import { existsSync, readdirSync } from "node:fs";
import os from "node:os";
// @ts-ignore
import tarExtract from "tar/lib/extract.js";
import type { ExtractOptions } from "tar";
import { resolve, dirname } from "pathe";
import { resolve, dirname, basename } from "pathe";
import { defu } from "defu";
import { installDependencies } from "nypm";
import { cacheDirectory, download, debug, normalizeHeaders } from "./_utils";
Expand Down Expand Up @@ -176,3 +177,66 @@ export async function downloadTemplate(
dir: extractPath,
};
}

export async function copyTemplate(
input: string,
options: DownloadTemplateOptions = {},
): Promise<DownloadTemplateResult> {
options = defu({ auth: process.env.GIGET_AUTH }, options);

const providerName = "file";
let source = input;
if (source.startsWith("file:~")) {
source = source.replace("file:~", "file://" + os.homedir());
}

const provider = options.providers?.[providerName] || providers[providerName];
if (!provider) {
throw new Error(`Unsupported provider: ${providerName}`);
}
const template = await Promise.resolve()
.then(() => provider(source, { auth: options.auth }))
.catch((error) => {
throw new Error(
`Failed to download template from ${providerName}: ${error.message}`,
);
});

if (!template) {
throw new Error(`Failed to resolve template from ${providerName}`);
}
template.name = (template.name || "template").replace(/[^\da-z-]/gi, "-");

const cwd = resolve(options.cwd || ".");
const extractPath = resolve(cwd, options.dir || basename(source));
const srcPath = decodeURIComponent(new URL(source).pathname);
if (options.forceClean) {
await rm(extractPath, { recursive: true, force: true });
}
if (
!options.force &&
existsSync(extractPath) &&
readdirSync(extractPath).length > 0
) {
throw new Error(`Destination ${extractPath} already exists.`);
}
await mkdir(extractPath, { recursive: true });

const s = Date.now();
await cp(srcPath, extractPath, { recursive: true });
debug(`Copied to ${extractPath} in ${Date.now() - s}ms`);

if (options.install) {
debug("Installing dependencies...");
await installDependencies({
cwd: extractPath,
silent: options.silent,
});
}

return {
...template,
source,
dir: extractPath,
};
}
17 changes: 17 additions & 0 deletions src/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,22 @@ const _httpJSON: TemplateProvider = async (input, options) => {
return info;
};

const file: TemplateProvider = async (input, options) => {
const url = new URL(input);
const name: string = basename(url.pathname);

return {
name: `${name}-${url.href.slice(0, 8)}`,
version: "",
subdir: "",
tar: url.href,
defaultDir: name,
headers: {
Authorization: options.auth ? `Bearer ${options.auth}` : undefined,
},
};
};

export const github: TemplateProvider = (input, options) => {
const parsed = parseGitURI(input);

Expand Down Expand Up @@ -131,6 +147,7 @@ export const sourcehut: TemplateProvider = (input, options) => {
export const providers: Record<string, TemplateProvider> = {
http,
https: http,
file,
github,
gh: github,
gitlab,
Expand Down
30 changes: 28 additions & 2 deletions test/getgit.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { existsSync } from "node:fs";
import { existsSync, readdirSync } from "node:fs";
import { rm, mkdir, writeFile } from "node:fs/promises";
import { expect, it, describe, beforeAll } from "vitest";
import { resolve } from "pathe";
import { downloadTemplate } from "../src";
import { downloadTemplate, copyTemplate } from "../src";

describe("downloadTemplate", () => {
beforeAll(async () => {
Expand All @@ -27,3 +27,29 @@ describe("downloadTemplate", () => {
).rejects.toThrow("already exists");
});
});

describe("copyTemplate (file protocol)", () => {
const tmpDir = resolve(__dirname, ".tmp/copied");
const srcDir = resolve(__dirname, "fixtures/my-template");

beforeAll(async () => {
await rm(tmpDir, { recursive: true, force: true });
await mkdir(srcDir, { recursive: true });
await writeFile(resolve(srcDir, "foo.txt"), "bar");
});

it("copy a local directory", async () => {
const destDir = resolve(tmpDir, "copied");
const { dir } = await copyTemplate(`file:${srcDir}`, { dir: destDir });
expect(existsSync(resolve(dir, "foo.txt"))).toBe(true);
});

it("do not clone to exisiting dir", async () => {
const destDir = resolve(tmpDir, "existing");
await mkdir(destDir, { recursive: true });
await writeFile(resolve(destDir, "test.txt"), "test");
await expect(
copyTemplate(`file:${srcDir}`, { dir: destDir }),
).rejects.toThrow("already exists");
});
});