Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
22 changes: 17 additions & 5 deletions packages/create-gen-app-test/src/__tests__/cached-template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as path from 'path';

import { appstash, resolve } from 'appstash';

import { createFromCachedTemplate, getCachedRepo, cloneToCache } from '../index';
import { createFromCachedTemplate, TemplateCache } from '../index';

const DEFAULT_TEMPLATE_URL = 'https://github.com/launchql/pgpm-boilerplates';

Expand Down Expand Up @@ -35,15 +35,23 @@ describe('cached template integration tests', () => {

describe('cache functionality', () => {
let sharedCachePath: string;
let templateCache: TemplateCache;

beforeAll(() => {
templateCache = new TemplateCache({
enabled: true,
toolName: testCacheTool,
});
});

it('should return null when cache does not exist for new URL', () => {
const nonExistentUrl = 'https://github.com/nonexistent/repo-test-123456';
const cachedRepo = getCachedRepo(nonExistentUrl, testCacheTool);
const cachedRepo = templateCache.get(nonExistentUrl);
expect(cachedRepo).toBeNull();
});

it('should clone repository to cache', () => {
const cachePath = cloneToCache(DEFAULT_TEMPLATE_URL, testCacheTool);
const cachePath = templateCache.set(DEFAULT_TEMPLATE_URL);
sharedCachePath = cachePath;

expect(fs.existsSync(cachePath)).toBe(true);
Expand All @@ -54,7 +62,7 @@ describe('cached template integration tests', () => {
}, 60000);

it('should retrieve cached repository', () => {
const cachedRepo = getCachedRepo(DEFAULT_TEMPLATE_URL, testCacheTool);
const cachedRepo = templateCache.get(DEFAULT_TEMPLATE_URL);
expect(cachedRepo).not.toBeNull();
expect(cachedRepo).toBe(sharedCachePath);
expect(fs.existsSync(cachedRepo!)).toBe(true);
Expand Down Expand Up @@ -120,7 +128,11 @@ describe('cached template integration tests', () => {
});

it('should verify template cache was created', () => {
const cachedRepo = getCachedRepo(DEFAULT_TEMPLATE_URL, testCacheTool);
const templateCache = new TemplateCache({
enabled: true,
toolName: testCacheTool,
});
const cachedRepo = templateCache.get(DEFAULT_TEMPLATE_URL);
expect(cachedRepo).not.toBeNull();
expect(fs.existsSync(cachedRepo!)).toBe(true);
});
Expand Down
3 changes: 2 additions & 1 deletion packages/create-gen-app-test/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ import { Inquirerer, ListQuestion } from "inquirerer";
import minimist, { ParsedArgs } from "minimist";

import { cloneRepo, createGen } from "create-gen-app";
import createGenPackageJson from "create-gen-app/package.json";

const DEFAULT_REPO = "https://github.com/launchql/pgpm-boilerplates.git";
const DEFAULT_PATH = ".";
const DEFAULT_OUTPUT_FALLBACK = "create-gen-app-output";

// Use require for package.json to avoid module resolution issues
const createGenPackageJson = require("create-gen-app/package.json");
const PACKAGE_VERSION =
(createGenPackageJson as { version?: string }).version ?? "0.0.0";

Expand Down
93 changes: 18 additions & 75 deletions packages/create-gen-app-test/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
import { execSync } from 'child_process';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';

import { appstash, resolve } from 'appstash';
import { replaceVariables, extractVariables } from 'create-gen-app';
import { replaceVariables, extractVariables, TemplateCache } from 'create-gen-app';

export interface CachedTemplateOptions {
templateUrl: string;
outputDir: string;
answers: Record<string, any>;
cacheTool?: string;
branch?: string;
ttl?: number;
maxAge?: number;
}

export interface CachedTemplateResult {
Expand All @@ -20,82 +17,25 @@ export interface CachedTemplateResult {
}

/**
* Get cached repository from appstash cache directory
* @param templateUrl - Repository URL
* @param cacheTool - Tool name for appstash (default: 'mymodule')
* @returns Cached repository path or null if not found
*/
export function getCachedRepo(templateUrl: string, cacheTool: string = 'mymodule'): string | null {
const dirs = appstash(cacheTool, { ensure: true });
const repoHash = crypto.createHash('md5').update(templateUrl).digest('hex');
const cachePath = resolve(dirs, 'cache', 'repos', repoHash);

if (fs.existsSync(cachePath)) {
return cachePath;
}

return null;
}

/**
* Clone repository to cache
* @param templateUrl - Repository URL
* @param cacheTool - Tool name for appstash (default: 'mymodule')
* @returns Path to cached repository
*/
export function cloneToCache(templateUrl: string, cacheTool: string = 'mymodule'): string {
const dirs = appstash(cacheTool, { ensure: true });
const repoHash = crypto.createHash('md5').update(templateUrl).digest('hex');
const cachePath = resolve(dirs, 'cache', 'repos', repoHash);

if (!fs.existsSync(path.dirname(cachePath))) {
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
}

const gitUrl = normalizeGitUrl(templateUrl);

execSync(`git clone ${gitUrl} ${cachePath}`, {
stdio: 'inherit'
});

const gitDir = path.join(cachePath, '.git');
if (fs.existsSync(gitDir)) {
fs.rmSync(gitDir, { recursive: true, force: true });
}

return cachePath;
}

/**
* Normalize a URL to a git-cloneable format
* @param url - Input URL
* @returns Normalized git URL
*/
function normalizeGitUrl(url: string): string {
if (url.startsWith('git@') || url.startsWith('https://') || url.startsWith('http://')) {
return url;
}

if (/^[\w-]+\/[\w-]+$/.test(url)) {
return `https://github.com/${url}.git`;
}

return url;
}

/**
* Create project from cached template
* Create project from cached template using the shared TemplateCache
* @param options - Options for creating from cached template
* @returns Result with output directory and cache information
*/
export async function createFromCachedTemplate(options: CachedTemplateOptions): Promise<CachedTemplateResult> {
const { templateUrl, outputDir, answers, cacheTool = 'mymodule' } = options;
const { templateUrl, outputDir, answers, cacheTool = 'mymodule', branch, ttl, maxAge } = options;

const templateCache = new TemplateCache({
enabled: true,
toolName: cacheTool,
ttl,
maxAge,
});

let templateDir: string;
let cacheUsed = false;
let cachePath: string | undefined;

const cachedRepo = getCachedRepo(templateUrl, cacheTool);
const cachedRepo = templateCache.get(templateUrl, branch);

if (cachedRepo) {
console.log(`Using cached template from ${cachedRepo}`);
Expand All @@ -104,7 +44,7 @@ export async function createFromCachedTemplate(options: CachedTemplateOptions):
cachePath = cachedRepo;
} else {
console.log(`Cloning template to cache from ${templateUrl}`);
templateDir = cloneToCache(templateUrl, cacheTool);
templateDir = templateCache.set(templateUrl, branch);
cachePath = templateDir;
}

Expand All @@ -118,3 +58,6 @@ export async function createFromCachedTemplate(options: CachedTemplateOptions):
cachePath
};
}

// Re-export TemplateCache for convenience
export { TemplateCache } from 'create-gen-app';
63 changes: 63 additions & 0 deletions packages/create-gen-app/__tests__/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,69 @@ describe("template caching (appstash)", () => {
cleanupWorkspace(secondWorkspace);
}
});

it("invalidates cache when TTL expires", async () => {
const shortTtl = 1000; // 1 second
const cacheOptions = {
toolName: `${cacheTool}-ttl`,
baseDir: tempBaseDir,
enabled: true,
ttl: shortTtl,
};

const firstWorkspace = createTempWorkspace("ttl-first");
const firstAnswers = buildAnswers("ttl-first");
const logSpy = jest.spyOn(console, "log").mockImplementation(() => undefined);

try {
await createGen({
templateUrl: TEST_REPO,
fromBranch: TEST_BRANCH,
fromPath: TEST_TEMPLATE,
outputDir: firstWorkspace.outputDir,
argv: firstAnswers,
noTty: true,
cache: cacheOptions,
});

expect(
logSpy.mock.calls.some(([message]) =>
typeof message === "string" && message.includes("Caching repository")
)
).toBe(true);
} finally {
cleanupWorkspace(firstWorkspace);
}

// Wait for TTL to expire
await new Promise((resolve) => setTimeout(resolve, shortTtl + 200));

logSpy.mockClear();
const secondWorkspace = createTempWorkspace("ttl-second");
const secondAnswers = buildAnswers("ttl-second");

try {
await createGen({
templateUrl: TEST_REPO,
fromBranch: TEST_BRANCH,
fromPath: TEST_TEMPLATE,
outputDir: secondWorkspace.outputDir,
argv: secondAnswers,
noTty: true,
cache: cacheOptions,
});

// Should re-cache since TTL expired
expect(
logSpy.mock.calls.some(([message]) =>
typeof message === "string" && message.includes("Caching repository")
)
).toBe(true);
} finally {
logSpy.mockRestore();
cleanupWorkspace(secondWorkspace);
}
});
});


Loading