diff --git a/.agents/skills/pgpm/references/cli.md b/.agents/skills/pgpm/references/cli.md index 51c03df9b6..92f19feede 100644 --- a/.agents/skills/pgpm/references/cli.md +++ b/.agents/skills/pgpm/references/cli.md @@ -165,8 +165,16 @@ pgpm init --template pnpm/module -w # Use custom template repository pgpm init --repo https://github.com/org/templates.git --template my-template + +# Refresh a stale cached template repository +pgpm init --refresh ``` +Non-interactive init requires every question to be answered by flags; see +[starter-kits.md](starter-kits.md)'s non-interactive flag table for +`--name --fullName --email --username --repoName --license`, plus module +`--moduleName --packageIdentifier --moduleDesc --access`. + ### Change Management **pgpm add** — Add a new database change diff --git a/.agents/skills/pgpm/references/testing.md b/.agents/skills/pgpm/references/testing.md index a80d977ff9..047cbd0be1 100644 --- a/.agents/skills/pgpm/references/testing.md +++ b/.agents/skills/pgpm/references/testing.md @@ -76,7 +76,7 @@ Returns: | `db.query(sql, params?)` | Execute SQL query | | `db.beforeEach()` | Start savepoint (call in beforeEach) | | `db.afterEach()` | Rollback to savepoint (call in afterEach) | -| `db.setContext(key, value)` | Set session context variable | +| `db.setContext(context)` | Set session context variables | | `db.getPool()` | Get underlying pg Pool | ## Seeding Data @@ -161,13 +161,34 @@ For RLS (Row Level Security) testing: ```typescript test('user can only see own data', async () => { - await db.setContext('user_id', 'user-123'); + db.setContext({ role: 'authenticated', 'jwt.claims.user_id': 'user-1' }); const result = await db.query('SELECT * FROM user_data'); - // Only returns rows where user_id = 'user-123' + // Only returns rows where user_id = 'user-1' }); ``` +### RLS testing + +`db` from `getConnections()` connects as `app_user`, so grant access to the +schema and table before testing whether RLS policies allow a row: + +```sql +GRANT USAGE ON SCHEMA app_public TO authenticated; +GRANT SELECT, INSERT ON app_public.posts TO authenticated; +GRANT USAGE ON SEQUENCE app_public.posts_id_seq TO authenticated; +``` + +Then set the role and claims on the same client before querying: + +```typescript +db.setContext({ role: 'authenticated', 'jwt.claims.user_id': 'user-1' }); +``` + +The `pg` and `db` clients are separate connections with separate savepoints. +A row inserted through `db` is invisible to `pg` in the same test; perform +superuser-visibility assertions through the same client that inserted the row. + ### Multiple Connections ```typescript diff --git a/pgpm/cli/__tests__/init.boilerplate.test.ts b/pgpm/cli/__tests__/init.boilerplate.test.ts index e8db742860..5981ddbe58 100644 --- a/pgpm/cli/__tests__/init.boilerplate.test.ts +++ b/pgpm/cli/__tests__/init.boilerplate.test.ts @@ -4,6 +4,7 @@ import os from 'os'; import path from 'path'; import { + isScaffoldableInPlace, persistBoilerplateSource, readBoilerplateSource, resolveInitTemplateRepo, @@ -97,3 +98,33 @@ describe('persist/read boilerplate source', () => { expect(readBoilerplateSource(dir)).toBeUndefined(); }); }); + +describe('isScaffoldableInPlace', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgpm-in-place-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('accepts an empty directory', () => { + expect(isScaffoldableInPlace(dir)).toBe(true); + }); + + it('accepts a directory containing only .git', () => { + fs.mkdirSync(path.join(dir, '.git')); + expect(isScaffoldableInPlace(dir)).toBe(true); + }); + + it('rejects a directory containing README.md', () => { + fs.writeFileSync(path.join(dir, 'README.md'), '# project\n'); + expect(isScaffoldableInPlace(dir)).toBe(false); + }); + + it('rejects a missing directory', () => { + expect(isScaffoldableInPlace(path.join(dir, 'missing'))).toBe(false); + }); +}); diff --git a/pgpm/cli/__tests__/init.test.ts b/pgpm/cli/__tests__/init.test.ts index ac2a4f6455..24c5487da1 100644 --- a/pgpm/cli/__tests__/init.test.ts +++ b/pgpm/cli/__tests__/init.test.ts @@ -3,7 +3,7 @@ process.env.PGPM_SKIP_UPDATE_CHECK = 'true'; process.env.PGPM_SKIP_SKILL_INSTALL = 'true'; import { PgpmPackage, TEMPLATE_REPOS } from '@pgpmjs/core'; -import { existsSync, readFileSync } from 'fs'; +import { existsSync, mkdirSync, readFileSync } from 'fs'; import { sync as glob } from 'glob'; import { Inquirerer, ParsedArgs } from 'inquirerer'; import * as path from 'path'; @@ -93,6 +93,33 @@ describe('cmds:init', () => { ); }); + it('scaffolds a workspace in place when cwd is an empty named directory', async () => { + const workspaceDir = path.join(fixture.tempDir, 'foo'); + mkdirSync(workspaceDir); + const { mockInput, mockOutput } = environment; + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: true + }); + + await commands(withInitDefaults({ + _: ['init', 'workspace'], + cwd: workspaceDir, + name: 'foo', + workspace: true + }), prompter, { + noTty: true, + input: mockInput, + output: mockOutput, + version: '1.0.0', + minimistOpts: {} + }); + + expect(existsSync(path.join(workspaceDir, 'pgpm.json'))).toBe(true); + expect(existsSync(path.join(workspaceDir, 'foo'))).toBe(false); + }); + it('initializes module', async () => { const workspaceDir = path.join(fixture.tempDir, 'my-workspace'); const moduleDir = path.join(workspaceDir, 'packages', 'my-module'); diff --git a/pgpm/cli/__tests__/tty.test.ts b/pgpm/cli/__tests__/tty.test.ts new file mode 100644 index 0000000000..35c56b48fa --- /dev/null +++ b/pgpm/cli/__tests__/tty.test.ts @@ -0,0 +1,18 @@ +import { detectNoTtyFromProcess, isNoTtyRequested } from '../src/utils/tty'; + +describe('tty detection', () => { + const originalIsTTY = process.stdin.isTTY; + + afterEach(() => { + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + value: originalIsTTY, + }); + }); + + it('treats a non-terminal stdin as non-interactive', () => { + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: false }); + expect(isNoTtyRequested({})).toBe(true); + expect(detectNoTtyFromProcess(['node', 'pgpm'])).toBe(true); + }); +}); diff --git a/pgpm/cli/src/commands/init/boilerplate.ts b/pgpm/cli/src/commands/init/boilerplate.ts index 8e3463947c..8f18217aab 100644 --- a/pgpm/cli/src/commands/init/boilerplate.ts +++ b/pgpm/cli/src/commands/init/boilerplate.ts @@ -12,6 +12,15 @@ export interface BoilerplateSource { dir?: string; } +export function isScaffoldableInPlace(dir: string): boolean { + try { + return fs.statSync(dir).isDirectory() && + fs.readdirSync(dir).filter((entry) => entry !== '.git').length === 0; + } catch { + return false; + } +} + /** * Resolve the template repo for an `init` invocation from its flags. * diff --git a/pgpm/cli/src/commands/init/index.ts b/pgpm/cli/src/commands/init/index.ts index f5032e8e93..b47e5241cf 100644 --- a/pgpm/cli/src/commands/init/index.ts +++ b/pgpm/cli/src/commands/init/index.ts @@ -2,14 +2,17 @@ import { BoilerplateSkill, DEFAULT_TEMPLATE_REPO, DEFAULT_TEMPLATE_TOOL_NAME, + describeTemplateSource, inspectTemplate, PgpmPackage, + refreshTemplateCache, resolveBoilerplateBaseDir, scaffoldTemplate, scanBoilerplates, SkillInstaller, sluggify, spawnSyncChecked, + TemplateSourceInfo, } from '@pgpmjs/core'; import { resolveWorkspaceByType } from '@pgpmjs/env'; import { errors } from '@pgpmjs/types'; @@ -19,6 +22,7 @@ import path from 'path'; import { isNoTtyRequested } from '../../utils'; import { + isScaffoldableInPlace, persistBoilerplateSource, readBoilerplateSource, resolveInitTemplateRepo, @@ -50,6 +54,7 @@ Options: Sugar for --repo https://github.com/constructive-io/pglite-boilerplates.git. Recorded on the workspace so later \`init\` calls inherit it automatically. --from-branch Branch/tag to use when cloning repo + --refresh, --no-cache Re-fetch the template repo instead of using the cached copy --dir Template variant directory (e.g., supabase, drizzle) --template, -t Full template path (e.g., pnpm/module) - combines dir and fromPath --boilerplate Prompt to select from available boilerplates @@ -59,6 +64,8 @@ Options: Add them later instead with \`${binaryName} extension\`. --with-extensions Prompt interactively for extensions during module init +Non-interactive runs must answer every question with flags; use --no-tty. + Examples: ${binaryName} init Initialize new module (default, no extensions) ${binaryName} init workspace Initialize new workspace @@ -100,6 +107,12 @@ async function handleInit(argv: Partial>, prompter: Inquirer pglite: Boolean(argv.pglite), }); const branch = argv.fromBranch as string | undefined; + const forceRefresh = Boolean( + argv.refresh || + argv['no-cache'] || + argv.noCache || + argv.cache === false + ); const noTty = isNoTtyRequested(argv); const useBoilerplatePrompt = Boolean(argv.boilerplate); const createWorkspace = Boolean(argv.createWorkspace || argv['create-workspace'] || argv.w); @@ -128,6 +141,13 @@ async function handleInit(argv: Partial>, prompter: Inquirer // Handle --boilerplate flag: separate path from regular init if (useBoilerplatePrompt) { + const source = refreshTemplateCache({ + templateRepo, + branch, + toolName: DEFAULT_TEMPLATE_TOOL_NAME, + cwd, + force: forceRefresh, + }); return handleBoilerplateInit(argv, prompter, { positionalFromPath, templateRepo, @@ -136,6 +156,8 @@ async function handleInit(argv: Partial>, prompter: Inquirer noTty, cwd, useNpxSkills, + forceRefresh, + source, }); } @@ -144,6 +166,14 @@ async function handleInit(argv: Partial>, prompter: Inquirer // Track if user explicitly requested module (e.g., `pgpm init module` or `--template pnpm/module`) const wasExplicitModuleRequest = positionalFromPath === 'module' || templateFromPath === 'module'; + const source = refreshTemplateCache({ + templateRepo, + branch, + toolName: DEFAULT_TEMPLATE_TOOL_NAME, + cwd, + force: forceRefresh, + }); + // Inspect the template to get its type const inspection = inspectTemplate({ fromPath, @@ -167,6 +197,8 @@ async function handleInit(argv: Partial>, prompter: Inquirer cwd, useNpxSkills, repoWasExplicit, + source, + forceRefresh, }); } @@ -182,6 +214,8 @@ async function handleInit(argv: Partial>, prompter: Inquirer createWorkspace, useNpxSkills, repoWasExplicit, + source, + forceRefresh, }, wasExplicitModuleRequest); } @@ -193,6 +227,8 @@ interface BoilerplateInitContext { noTty: boolean; cwd: string; useNpxSkills?: boolean; + forceRefresh?: boolean; + source?: TemplateSourceInfo; } async function handleBoilerplateInit( @@ -275,6 +311,7 @@ async function handleBoilerplateInit( noTty: ctx.noTty, cwd: ctx.cwd, useNpxSkills: ctx.useNpxSkills, + source: ctx.source, }); } @@ -289,6 +326,8 @@ async function handleBoilerplateInit( cwd: ctx.cwd, requiresWorkspace: inspection.config?.requiresWorkspace, useNpxSkills: ctx.useNpxSkills, + forceRefresh: ctx.forceRefresh, + source: ctx.source, }, true); } @@ -318,6 +357,8 @@ interface InitContext { * recorded boilerplate repo (see `PgpmWorkspaceConfig.boilerplates`). */ repoWasExplicit?: boolean; + source?: TemplateSourceInfo; + forceRefresh?: boolean; } function installSkills(skills: BoilerplateSkill[], cwd: string, useNpxSkills: boolean): void { @@ -403,7 +444,22 @@ async function handleWorkspaceInit( ]; const answers = await prompter.prompt(argv, workspaceQuestions); - const targetPath = path.join(ctx.cwd, sluggify(answers.name)); + let targetPath = path.join(ctx.cwd, sluggify(answers.name)); + const slug = sluggify(answers.name); + const inPlace = path.basename(ctx.cwd) === slug && isScaffoldableInPlace(ctx.cwd); + if (inPlace) { + targetPath = ctx.cwd; + process.stdout.write( + `Scaffolding into current directory ./ (it is empty and named "${answers.name}")\n` + ); + } + const source = ctx.source ?? refreshTemplateCache({ + templateRepo: ctx.templateRepo, + branch: ctx.branch, + toolName: DEFAULT_TEMPLATE_TOOL_NAME, + cwd: ctx.cwd, + }); + process.stdout.write(`${describeTemplateSource(source)}\n`); // Register workspace.dirname resolver so boilerplate templates can use it via defaultFrom/setFrom const dirName = path.basename(targetPath); @@ -467,7 +523,8 @@ async function handleWorkspaceInit( } const relPath = path.relative(process.cwd(), targetPath); - process.stdout.write(`\n✨ Enjoy!\n\ncd ./${relPath}\n`); + process.stdout.write('\n✨ Enjoy!\n'); + if (relPath) process.stdout.write(`\ncd ./${relPath}\n`); return { ...argv, ...answers, cwd: targetPath }; } @@ -543,8 +600,22 @@ async function handleModuleInit( ctx.templateRepo = inherited.repo; ctx.branch = inherited.branch; ctx.dir = inherited.dir; + ctx.source = refreshTemplateCache({ + templateRepo: ctx.templateRepo, + branch: ctx.branch, + toolName: DEFAULT_TEMPLATE_TOOL_NAME, + cwd: ctx.cwd, + force: ctx.forceRefresh, + }); } } + const source = ctx.source ?? refreshTemplateCache({ + templateRepo: ctx.templateRepo, + branch: ctx.branch, + toolName: DEFAULT_TEMPLATE_TOOL_NAME, + cwd: ctx.cwd, + force: ctx.forceRefresh, + }); // Determine workspace requirement (defaults to 'pgpm' for backward compatibility) const workspaceType = ctx.requiresWorkspace ?? 'pgpm'; @@ -590,6 +661,13 @@ async function handleModuleInit( noTty: ctx.noTty, cwd: ctx.cwd, repoWasExplicit: ctx.repoWasExplicit, + source: refreshTemplateCache({ + templateRepo: workspaceTemplateConfig.repo, + branch: workspaceTemplateConfig.branch, + toolName: DEFAULT_TEMPLATE_TOOL_NAME, + cwd: ctx.cwd, + force: ctx.forceRefresh, + }), }); // Update context to point to new workspace and continue with module creation @@ -639,6 +717,13 @@ async function handleModuleInit( noTty: ctx.noTty, cwd: ctx.cwd, repoWasExplicit: ctx.repoWasExplicit, + source: refreshTemplateCache({ + templateRepo: ctx.templateRepo, + branch: ctx.branch, + toolName: DEFAULT_TEMPLATE_TOOL_NAME, + cwd: ctx.cwd, + force: ctx.forceRefresh, + }), }); } } @@ -719,6 +804,7 @@ async function handleModuleInit( // Determine output path based on whether we're in a workspace let modulePath: string; + process.stdout.write(`${describeTemplateSource(source)}\n`); if (project.workspacePath) { // PGPM workspace - use workspace-aware initModule await project.initModule({ @@ -810,7 +896,8 @@ async function handleModuleInit( } const relPath = path.relative(process.cwd(), modulePath); - process.stdout.write(`\n✨ Enjoy!\n\ncd ./${relPath}\n`); + process.stdout.write('\n✨ Enjoy!\n'); + if (relPath) process.stdout.write(`\ncd ./${relPath}\n`); return { ...argv, ...answers }; } diff --git a/pgpm/cli/src/utils/tty.ts b/pgpm/cli/src/utils/tty.ts index 9938c7ed1b..7296930d7f 100644 --- a/pgpm/cli/src/utils/tty.ts +++ b/pgpm/cli/src/utils/tty.ts @@ -7,7 +7,8 @@ export const isNoTtyRequested = (argv: Partial>): boolean => argv.noTty || argv['no-tty'] || argv.tty === false || - process.env.CI === 'true' + process.env.CI === 'true' || + process.stdin.isTTY !== true ); /** @@ -17,4 +18,5 @@ export const isNoTtyRequested = (argv: Partial>): boolean => export const detectNoTtyFromProcess = (argv: string[] = process.argv): boolean => argv.includes('--no-tty') || argv.includes('--noTty') || - process.env.CI === 'true'; + process.env.CI === 'true' || + process.stdin.isTTY !== true; diff --git a/pgpm/core/__tests__/template-refresh.test.ts b/pgpm/core/__tests__/template-refresh.test.ts new file mode 100644 index 0000000000..5750e3d986 --- /dev/null +++ b/pgpm/core/__tests__/template-refresh.test.ts @@ -0,0 +1,154 @@ +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import { CacheManager } from 'genomic'; +import os from 'os'; +import path from 'path'; + +import { + _resetTemplateRefreshMemo, + describeTemplateSource, + inspectTemplate, + refreshTemplateCache, +} from '../src'; +const git = (cwd: string, ...args: string[]) => + execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf8' }); + +describe('template cache refresh', () => { + let root: string; + let bareDir: string; + let workDir: string; + let cacheBaseDir: string; + let templateRepo: string; + + beforeEach(() => { + _resetTemplateRefreshMemo(); + root = fs.mkdtempSync(path.join(os.tmpdir(), 'pgpm-template-refresh-')); + bareDir = path.join(root, 'template.git'); + workDir = path.join(root, 'work'); + cacheBaseDir = path.join(root, 'cache'); + fs.mkdirSync(bareDir); + git(root, 'init', '--bare', bareDir); + git(root, 'clone', bareDir, workDir); + git(workDir, 'config', 'user.name', 'Template Test'); + git(workDir, 'config', 'user.email', 'template@example.com'); + fs.mkdirSync(path.join(workDir, 'module'), { recursive: true }); + fs.writeFileSync( + path.join(workDir, 'module', '.boilerplate.json'), + JSON.stringify({ type: 'module', requiresWorkspace: false }) + ); + git(workDir, 'add', 'module/.boilerplate.json'); + git(workDir, 'commit', '-m', 'initial template'); + git(workDir, 'branch', '-M', 'main'); + git(workDir, 'push', 'origin', 'main'); + git(bareDir, 'symbolic-ref', 'HEAD', 'refs/heads/main'); + templateRepo = `file://${bareDir}`; + }); + + afterEach(() => { + delete process.env.PGPM_TEMPLATE_OFFLINE; + fs.rmSync(root, { recursive: true, force: true }); + _resetTemplateRefreshMemo(); + }); + + it('refreshes only when the remote revision changes or force is requested', () => { + const first = refreshTemplateCache({ + templateRepo, + branch: 'main', + cacheBaseDir, + toolName: 'pgpm-refresh-test', + }); + expect(first.remoteSha).toMatch(/^[0-9a-f]{40}$/); + expect(first.refreshed).toBe(true); + + const cm = new CacheManager({ toolName: 'pgpm-refresh-test', baseDir: cacheBaseDir }); + const key = cm.createKey(templateRepo, 'main'); + expect(fs.existsSync(path.join(cm.getMetadataDir(), `${key}.ref.json`))).toBe(true); + + inspectTemplate({ + fromPath: 'module', + templateRepo, + branch: 'main', + cacheBaseDir, + toolName: 'pgpm-refresh-test', + }); + _resetTemplateRefreshMemo(); + const second = refreshTemplateCache({ + templateRepo, + branch: 'main', + cacheBaseDir, + toolName: 'pgpm-refresh-test', + }); + expect(second.refreshed).toBe(false); + expect(fs.existsSync(path.join(cm.getReposDir(), key))).toBe(true); + + fs.writeFileSync(path.join(workDir, 'module', 'new.txt'), 'new revision\n'); + git(workDir, 'add', 'module/new.txt'); + git(workDir, 'commit', '-m', 'update template'); + git(workDir, 'push', 'origin', 'main'); + _resetTemplateRefreshMemo(); + const changed = refreshTemplateCache({ + templateRepo, + branch: 'main', + cacheBaseDir, + toolName: 'pgpm-refresh-test', + }); + expect(changed.refreshed).toBe(true); + expect(fs.existsSync(path.join(cm.getReposDir(), key))).toBe(false); + + _resetTemplateRefreshMemo(); + const forced = refreshTemplateCache({ + templateRepo, + branch: 'main', + cacheBaseDir, + toolName: 'pgpm-refresh-test', + force: true, + }); + expect(forced.refreshed).toBe(true); + }); + + it('keeps the cache when offline', () => { + const info = refreshTemplateCache({ + templateRepo, + branch: 'main', + cacheBaseDir, + toolName: 'pgpm-refresh-test', + }); + const cm = new CacheManager({ toolName: 'pgpm-refresh-test', baseDir: cacheBaseDir }); + const key = cm.createKey(templateRepo, 'main'); + fs.mkdirSync(path.join(cm.getReposDir(), key), { recursive: true }); + + _resetTemplateRefreshMemo(); + process.env.PGPM_TEMPLATE_OFFLINE = '1'; + const offline = refreshTemplateCache({ + templateRepo, + branch: 'main', + cacheBaseDir, + toolName: 'pgpm-refresh-test', + force: true, + }); + expect(offline.offline).toBe(true); + expect(offline.refreshed).toBe(false); + expect(fs.existsSync(path.join(cm.getReposDir(), key))).toBe(true); + expect(info.remoteSha).toBeTruthy(); + }); + + it('describes an https source with its short revision and fetch age', () => { + const source = 'https://github.com/owner/repo.git'; + const cm = new CacheManager({ toolName: 'pgpm-describe-test', baseDir: cacheBaseDir }); + const key = cm.createKey(source, 'main'); + cm.set(key, root); + expect( + describeTemplateSource( + { + repo: source, + branch: 'main', + remoteSha: 'abcdef1234567890abcdef1234567890abcdef12', + refreshed: false, + offline: false, + local: false, + }, + { toolName: 'pgpm-describe-test', cacheBaseDir } + ) + ).toMatch(/^using owner\/repo@main \(abcdef1, fetched \d+s ago\)$/); + }); +}); diff --git a/pgpm/core/src/core/template-scaffold.ts b/pgpm/core/src/core/template-scaffold.ts index 09a8a5e4ce..bdc81d81c2 100644 --- a/pgpm/core/src/core/template-scaffold.ts +++ b/pgpm/core/src/core/template-scaffold.ts @@ -1,4 +1,6 @@ -import { BoilerplateConfig as GenomicBoilerplateConfig,TemplateScaffolder } from 'genomic'; +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import { BoilerplateConfig as GenomicBoilerplateConfig, CacheManager, GitCloner, TemplateScaffolder } from 'genomic'; import os from 'os'; import path from 'path'; export type { BoilerplateSkill } from 'genomic'; @@ -115,6 +117,24 @@ export const DEFAULT_TEMPLATE_REPO = TEMPLATE_REPOS.default; export const DEFAULT_TEMPLATE_TTL_MS = 1 * 24 * 60 * 60 * 1000; // 1 day export const DEFAULT_TEMPLATE_TOOL_NAME = 'pgpm'; +export interface TemplateSourceInfo { + repo: string; + branch?: string; + remoteSha?: string; + refreshed: boolean; + offline: boolean; + local: boolean; +} + +export interface RefreshTemplateCacheOptions { + templateRepo?: string; + branch?: string; + toolName?: string; + cacheBaseDir?: string; + cwd?: string; + force?: boolean; +} + function resolveCacheBaseDir(cacheBaseDir?: string): string | undefined { if (cacheBaseDir) { return cacheBaseDir; @@ -128,6 +148,177 @@ function resolveCacheBaseDir(cacheBaseDir?: string): string | undefined { return undefined; } +const templateRefreshMemo = new Map(); + +/** + * Clear the per-process refresh memo. Primarily useful for tests and callers + * that begin a new init run in the same process. + */ +export function _resetTemplateRefreshMemo(): void { + templateRefreshMemo.clear(); +} + +/** + * Check the remote template revision and clear a stale local clone. + * + * Set PGPM_TEMPLATE_OFFLINE to skip `git ls-remote`, which is useful for tests + * and environments that must not make network requests. + */ +export function refreshTemplateCache( + options: RefreshTemplateCacheOptions = {} +): TemplateSourceInfo { + const { + templateRepo = DEFAULT_TEMPLATE_REPO, + branch, + toolName = DEFAULT_TEMPLATE_TOOL_NAME, + cacheBaseDir, + cwd, + force = false, + } = options; + + const template = + templateRepo.startsWith('.') || + templateRepo.startsWith('/') || + templateRepo.startsWith('~') + ? path.resolve(cwd ?? process.cwd(), templateRepo) + : templateRepo; + + if ( + templateRepo.startsWith('.') || + templateRepo.startsWith('/') || + templateRepo.startsWith('~') + ) { + return { + repo: template, + branch, + refreshed: false, + offline: false, + local: true, + }; + } + + const url = new GitCloner().normalizeUrl(templateRepo); + const cm = new CacheManager({ + toolName, + baseDir: resolveCacheBaseDir(cacheBaseDir), + }); + const key = cm.createKey(url, branch); + const memoKey = `${key}|${force}`; + const memoized = templateRefreshMemo.get(memoKey); + if (memoized) return memoized; + + if (process.env.PGPM_TEMPLATE_OFFLINE) { + const info = { + repo: url, + branch, + refreshed: false, + offline: true, + local: false, + }; + templateRefreshMemo.set(memoKey, info); + return info; + } + + let remoteSha: string; + try { + const output = execFileSync( + 'git', + ['ls-remote', '--', url, branch ?? 'HEAD'], + { stdio: 'pipe', encoding: 'utf-8', timeout: 15_000 } + ); + const match = output.match(/\b([0-9a-f]{40})\b/i); + if (!match) throw new Error('git ls-remote returned no commit SHA'); + remoteSha = match[1]; + } catch { + const info = { + repo: url, + branch, + refreshed: false, + offline: true, + local: false, + }; + templateRefreshMemo.set(memoKey, info); + return info; + } + + const refPath = path.join(cm.getMetadataDir(), `${key}.ref.json`); + let previousSha: string | undefined; + try { + const ref = JSON.parse(fs.readFileSync(refPath, 'utf8')); + previousSha = typeof ref.sha === 'string' ? ref.sha : undefined; + } catch { + // A missing or malformed sidecar is treated as unknown. + } + + const cachePath = path.join(cm.getReposDir(), key); + const refreshed = + force || + previousSha !== remoteSha || + (fs.existsSync(cachePath) && !previousSha); + if (refreshed) cm.clear(key); + + fs.mkdirSync(cm.getMetadataDir(), { recursive: true }); + fs.writeFileSync( + refPath, + JSON.stringify({ sha: remoteSha, checkedAt: Date.now() }, null, 2) + ); + + const info = { + repo: url, + branch, + remoteSha, + refreshed, + offline: false, + local: false, + }; + templateRefreshMemo.set(memoKey, info); + return info; +} + +function formatAge(lastUpdated: number): string { + const seconds = Math.max(0, Math.floor((Date.now() - lastUpdated) / 1000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; +} + +export function describeTemplateSource( + info: TemplateSourceInfo, + opts: { toolName?: string; cacheBaseDir?: string } = {} +): string { + if (info.local) return `using local template ${info.repo}`; + + const repoDisplay = info.repo + .replace(/^https:\/\/github\.com\//, '') + .replace(/\.git$/, ''); + const label = `${repoDisplay}@${info.branch ?? 'HEAD'}`; + const shortSha = info.remoteSha?.slice(0, 7); + let fetched: string | undefined; + if (info.refreshed) { + fetched = 'fetched just now'; + } else if (info.remoteSha) { + const url = new GitCloner().normalizeUrl(info.repo); + const cm = new CacheManager({ + toolName: opts.toolName ?? DEFAULT_TEMPLATE_TOOL_NAME, + baseDir: resolveCacheBaseDir(opts.cacheBaseDir), + }); + const key = cm.createKey(url, info.branch); + const metadata = cm.getMetadata(key); + if (metadata?.lastUpdated) fetched = `fetched ${formatAge(metadata.lastUpdated)} ago`; + } + + const details = [ + shortSha, + fetched, + ].filter(Boolean).join(', '); + let result = `using ${label}${details ? ` (${details})` : ''}`; + if (info.offline) result += ' (offline, using cached copy)'; + return result; +} + export function inspectTemplate( options: InspectTemplateOptions ): InspectTemplateResult {