Skip to content

Commit 8118699

Browse files
committed
feat(create-gen-app): add template caching with appstash
Add built-in template caching to avoid repeated git clones from GitHub. Templates are cached locally using appstash, significantly speeding up subsequent runs when using the same repository and branch. Changes: - Add cache.ts helper with getCachedRepo/cloneToCache/prepareTemplateDirectory - Extend CreateGenOptions with optional cache configuration (toolName, baseDir, enabled) - Update cloneRepo to support cache-aware cloning - Integrate caching into createGen pipeline with cache-first logic - Add cache.test.ts with isolated temp baseDir to avoid touching user's home - Update README with caching documentation and usage examples - Add appstash dependency to package.json The cache is enabled by default and stores repositories under ~/.<toolName>/cache/repos. Tests use temporary directories (via baseDir) to prevent pollution of the developer's home directory, following the pattern from appstash tests.
1 parent d9490cc commit 8118699

File tree

8 files changed

+326
-33
lines changed

8 files changed

+326
-33
lines changed

packages/create-gen-app/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ A TypeScript-first library for cloning template repositories, asking the user fo
2525
- Merge auto-discovered variables with `.questions.{json,js}` (questions win, including `ignore` patterns)
2626
- Interactive prompts powered by `inquirerer`, with flexible override mapping (`argv` support) and non-TTY mode for CI
2727
- License scaffolding: choose from MIT, Apache-2.0, ISC, GPL-3.0, BSD-3-Clause, Unlicense, or MPL-2.0 and generate a populated `LICENSE`
28+
- Built-in template caching powered by `appstash`, so repeat runs skip `git clone` (configurable via `cache` options)
2829

2930
## Installation
3031

@@ -37,6 +38,9 @@ npm install create-gen-app
3738
## Library Usage
3839

3940
```typescript
41+
import * as os from "os";
42+
import * as path from "path";
43+
4044
import { createGen } from "create-gen-app";
4145

4246
await createGen({
@@ -51,9 +55,33 @@ await createGen({
5155
LICENSE: "MIT",
5256
},
5357
noTty: true,
58+
cache: {
59+
// optional: override tool/baseDir (defaults to pgpm + ~/.pgpm)
60+
toolName: "pgpm",
61+
baseDir: path.join(os.tmpdir(), "create-gen-cache"),
62+
},
5463
});
5564
```
5665

66+
### Template Caching
67+
68+
`create-gen-app` caches repositories under `~/.pgpm/cache/repos/<hash>` by default (using [`appstash`](https://github.com/hyperweb-io/dev-utils/tree/main/packages/appstash)). The first run clones & stores the repo, subsequent runs re-use the cached directory.
69+
70+
- Disable caching with `cache: false` or `cache: { enabled: false }`
71+
- Override the tool name or base directory with `cache: { toolName, baseDir }`
72+
- For tests/CI, point `baseDir` to a temporary folder so the suite does not touch the developer’s real home directory:
73+
74+
```ts
75+
const tempBase = fs.mkdtempSync(path.join(os.tmpdir(), "create-gen-cache-"));
76+
77+
await createGen({
78+
...options,
79+
cache: { baseDir: tempBase, toolName: "pgpm-test-suite" },
80+
});
81+
```
82+
83+
The cache directory never mutates the template, so reusing the same cached repo across many runs is safe.
84+
5785
### Template Variables
5886

5987
Variables should be wrapped in four underscores on each side:
@@ -107,6 +135,7 @@ No code changes are needed; the generator discovers templates at runtime and wil
107135

108136
- `createGen(options)` – full pipeline (clone → extract → prompt → replace)
109137
- `cloneRepo(url, { branch })` – clone to a temp dir
138+
- `normalizeCacheOptions(cache)` / `prepareTemplateDirectory(...)` – inspect or reuse cached template repos
110139
- `extractVariables(dir)` – parse file/folder names + content for variables, load `.questions`
111140
- `promptUser(extracted, argv, noTty)` – run interactive questions with override alias deduping
112141
- `replaceVariables(templateDir, outputDir, extracted, answers)` – copy files, rename paths, render licenses
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import * as childProcess from "child_process";
2+
import * as fs from "fs";
3+
import * as os from "os";
4+
import * as path from "path";
5+
6+
import { createGen } from "../src";
7+
import {
8+
TEST_BRANCH,
9+
TEST_REPO,
10+
TEST_TEMPLATE,
11+
buildAnswers,
12+
cleanupWorkspace,
13+
createTempWorkspace,
14+
} from "../test-utils/integration-helpers";
15+
16+
jest.setTimeout(180_000);
17+
18+
describe("template caching (appstash)", () => {
19+
let tempBaseDir: string;
20+
const cacheTool = `pgpm-cache-${Date.now()}`;
21+
22+
beforeEach(() => {
23+
tempBaseDir = fs.mkdtempSync(path.join(os.tmpdir(), "create-gen-cache-"));
24+
});
25+
26+
afterEach(() => {
27+
if (fs.existsSync(tempBaseDir)) {
28+
fs.rmSync(tempBaseDir, { recursive: true, force: true });
29+
}
30+
});
31+
32+
it("reuses cached repositories across runs when cache is enabled", async () => {
33+
const cacheOptions = {
34+
toolName: cacheTool,
35+
baseDir: tempBaseDir,
36+
enabled: true,
37+
};
38+
39+
const firstWorkspace = createTempWorkspace("cache-first");
40+
const firstAnswers = buildAnswers("cache-first");
41+
42+
try {
43+
await createGen({
44+
templateUrl: TEST_REPO,
45+
fromBranch: TEST_BRANCH,
46+
fromPath: TEST_TEMPLATE,
47+
outputDir: firstWorkspace.outputDir,
48+
argv: firstAnswers,
49+
noTty: true,
50+
cache: cacheOptions,
51+
});
52+
} finally {
53+
cleanupWorkspace(firstWorkspace);
54+
}
55+
56+
const repoCacheDir = path.join(tempBaseDir, `.${cacheTool}`, "cache", "repos");
57+
expect(fs.existsSync(repoCacheDir)).toBe(true);
58+
const cachedEntries = fs.readdirSync(repoCacheDir);
59+
expect(cachedEntries.length).toBeGreaterThan(0);
60+
61+
const secondWorkspace = createTempWorkspace("cache-second");
62+
const secondAnswers = buildAnswers("cache-second");
63+
const execSpy = jest.spyOn(childProcess, "execSync");
64+
65+
try {
66+
await createGen({
67+
templateUrl: TEST_REPO,
68+
fromBranch: TEST_BRANCH,
69+
fromPath: TEST_TEMPLATE,
70+
outputDir: secondWorkspace.outputDir,
71+
argv: secondAnswers,
72+
noTty: true,
73+
cache: cacheOptions,
74+
});
75+
76+
const cloneCalls = execSpy.mock.calls.filter(([command]) => {
77+
return typeof command === "string" && command.includes("git clone");
78+
});
79+
80+
expect(cloneCalls.length).toBe(0);
81+
} finally {
82+
execSpy.mockRestore();
83+
cleanupWorkspace(secondWorkspace);
84+
}
85+
});
86+
});
87+
88+

packages/create-gen-app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"test:watch": "jest --watch"
2929
},
3030
"dependencies": {
31+
"appstash": "workspace:*",
3132
"inquirerer": "workspace:*"
3233
},
3334
"devDependencies": {
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { execSync } from "child_process";
2+
import * as crypto from "crypto";
3+
import * as fs from "fs";
4+
import * as path from "path";
5+
6+
import { appstash, resolve as resolveAppstash } from "appstash";
7+
8+
import { CacheOptions } from "./types";
9+
import { cloneRepo, normalizeGitUrl } from "./clone";
10+
11+
const DEFAULT_TOOL = "pgpm";
12+
13+
interface NormalizedCacheOptions {
14+
enabled: boolean;
15+
toolName: string;
16+
baseDir?: string;
17+
}
18+
19+
export interface TemplateSource {
20+
templateDir: string;
21+
cacheUsed: boolean;
22+
cleanup: () => void;
23+
}
24+
25+
interface PrepareTemplateArgs {
26+
templateUrl: string;
27+
branch?: string;
28+
cache: NormalizedCacheOptions;
29+
}
30+
31+
export function normalizeCacheOptions(cache?: CacheOptions | false): NormalizedCacheOptions {
32+
if (cache === false) {
33+
return {
34+
enabled: false,
35+
toolName: DEFAULT_TOOL,
36+
};
37+
}
38+
39+
const { enabled, toolName, baseDir } = cache ?? {};
40+
41+
return {
42+
enabled: enabled !== false,
43+
toolName: toolName ?? DEFAULT_TOOL,
44+
baseDir,
45+
};
46+
}
47+
48+
export async function prepareTemplateDirectory(args: PrepareTemplateArgs): Promise<TemplateSource> {
49+
const { templateUrl, branch, cache } = args;
50+
51+
if (!cache.enabled) {
52+
const tempDir = await cloneRepo(templateUrl, { branch });
53+
return {
54+
templateDir: tempDir,
55+
cacheUsed: false,
56+
cleanup: () => cleanupDir(tempDir),
57+
};
58+
}
59+
60+
const { cachePath } = ensureCachePath(templateUrl, branch, cache);
61+
62+
if (fs.existsSync(cachePath)) {
63+
return {
64+
templateDir: cachePath,
65+
cacheUsed: true,
66+
cleanup: () => {},
67+
};
68+
}
69+
70+
cloneInto(templateUrl, cachePath, branch);
71+
72+
return {
73+
templateDir: cachePath,
74+
cacheUsed: false,
75+
cleanup: () => {},
76+
};
77+
}
78+
79+
function ensureCachePath(
80+
templateUrl: string,
81+
branch: string | undefined,
82+
cache: NormalizedCacheOptions
83+
): { cachePath: string } {
84+
const dirs = appstash(cache.toolName, {
85+
ensure: true,
86+
baseDir: cache.baseDir,
87+
});
88+
89+
const reposDir = resolveAppstash(dirs, "cache", "repos");
90+
if (!fs.existsSync(reposDir)) {
91+
fs.mkdirSync(reposDir, { recursive: true });
92+
}
93+
94+
const key = createCacheKey(templateUrl, branch);
95+
const cachePath = path.join(reposDir, key);
96+
97+
return { cachePath };
98+
}
99+
100+
function createCacheKey(templateUrl: string, branch?: string): string {
101+
const gitUrl = normalizeGitUrl(templateUrl);
102+
return crypto.createHash("md5").update(`${gitUrl}#${branch ?? "default"}`).digest("hex");
103+
}
104+
105+
function cloneInto(templateUrl: string, destination: string, branch?: string): void {
106+
if (fs.existsSync(destination)) {
107+
fs.rmSync(destination, { recursive: true, force: true });
108+
}
109+
110+
const gitUrl = normalizeGitUrl(templateUrl);
111+
const branchArgs = branch ? ` --branch ${branch} --single-branch` : "";
112+
const depthArgs = " --depth 1";
113+
114+
execSync(`git clone${branchArgs}${depthArgs} ${gitUrl} ${destination}`, {
115+
stdio: "inherit",
116+
});
117+
118+
const gitDir = path.join(destination, ".git");
119+
if (fs.existsSync(gitDir)) {
120+
fs.rmSync(gitDir, { recursive: true, force: true });
121+
}
122+
}
123+
124+
function cleanupDir(dir: string): void {
125+
if (fs.existsSync(dir)) {
126+
fs.rmSync(dir, { recursive: true, force: true });
127+
}
128+
}
129+
130+

packages/create-gen-app/src/clone.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export async function cloneRepo(
4848
* @param url - Input URL
4949
* @returns Normalized git URL
5050
*/
51-
function normalizeGitUrl(url: string): string {
51+
export function normalizeGitUrl(url: string): string {
5252
if (
5353
url.startsWith("git@") ||
5454
url.startsWith("https://") ||

0 commit comments

Comments
 (0)