@whittakertech/virgil 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.tool-versions +1 -0
  2. package/README.md +230 -0
  3. package/dist/cli.d.ts +3 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +39 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/engine/hash.d.ts +20 -0
  8. package/dist/engine/hash.d.ts.map +1 -0
  9. package/dist/engine/hash.js +53 -0
  10. package/dist/engine/hash.js.map +1 -0
  11. package/dist/engine/lock.d.ts +18 -0
  12. package/dist/engine/lock.d.ts.map +1 -0
  13. package/dist/engine/lock.js +54 -0
  14. package/dist/engine/lock.js.map +1 -0
  15. package/dist/engine/manifest.d.ts +14 -0
  16. package/dist/engine/manifest.d.ts.map +1 -0
  17. package/dist/engine/manifest.js +39 -0
  18. package/dist/engine/manifest.js.map +1 -0
  19. package/dist/engine/run.d.ts +15 -0
  20. package/dist/engine/run.d.ts.map +1 -0
  21. package/dist/engine/run.js +110 -0
  22. package/dist/engine/run.js.map +1 -0
  23. package/dist/generators/og-image.d.ts +16 -0
  24. package/dist/generators/og-image.d.ts.map +1 -0
  25. package/dist/generators/og-image.js +46 -0
  26. package/dist/generators/og-image.js.map +1 -0
  27. package/dist/generators/robots.d.ts +9 -0
  28. package/dist/generators/robots.d.ts.map +1 -0
  29. package/dist/generators/robots.js +29 -0
  30. package/dist/generators/robots.js.map +1 -0
  31. package/dist/generators/sitemap.d.ts +9 -0
  32. package/dist/generators/sitemap.d.ts.map +1 -0
  33. package/dist/generators/sitemap.js +24 -0
  34. package/dist/generators/sitemap.js.map +1 -0
  35. package/dist/index.d.ts +8 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +5 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/templates/og-default.html +100 -0
  40. package/dist/types/lock.d.ts +11 -0
  41. package/dist/types/lock.d.ts.map +1 -0
  42. package/dist/types/lock.js +2 -0
  43. package/dist/types/lock.js.map +1 -0
  44. package/dist/types/manifest.d.ts +7 -0
  45. package/dist/types/manifest.d.ts.map +1 -0
  46. package/dist/types/manifest.js +2 -0
  47. package/dist/types/manifest.js.map +1 -0
  48. package/dist/types/spec.d.ts +47 -0
  49. package/dist/types/spec.d.ts.map +1 -0
  50. package/dist/types/spec.js +2 -0
  51. package/dist/types/spec.js.map +1 -0
  52. package/dist/utils/fs.d.ts +13 -0
  53. package/dist/utils/fs.d.ts.map +1 -0
  54. package/dist/utils/fs.js +32 -0
  55. package/dist/utils/fs.js.map +1 -0
  56. package/dist/utils/paths.d.ts +14 -0
  57. package/dist/utils/paths.d.ts.map +1 -0
  58. package/dist/utils/paths.js +30 -0
  59. package/dist/utils/paths.js.map +1 -0
  60. package/package.json +37 -0
  61. package/src/cli.ts +47 -0
  62. package/src/engine/hash.ts +71 -0
  63. package/src/engine/lock.ts +72 -0
  64. package/src/engine/manifest.ts +52 -0
  65. package/src/engine/run.ts +156 -0
  66. package/src/generators/og-image.ts +65 -0
  67. package/src/generators/robots.ts +44 -0
  68. package/src/generators/sitemap.ts +36 -0
  69. package/src/index.ts +8 -0
  70. package/src/templates/og-default.html +100 -0
  71. package/src/types/lock.ts +11 -0
  72. package/src/types/manifest.ts +6 -0
  73. package/src/types/spec.ts +52 -0
  74. package/src/utils/fs.ts +32 -0
  75. package/src/utils/paths.ts +33 -0
  76. package/tsconfig.json +20 -0
  77. package/virgil.spec.example.json +65 -0
@@ -0,0 +1,72 @@
1
+ import { readFile, writeFile } from 'fs/promises';
2
+ import { VirgilLock, LockEntry } from '../types/lock.js';
3
+
4
+ const LOCK_VERSION = '0.1' as const;
5
+
6
+ /**
7
+ * Load lock file if it exists, return empty lock otherwise
8
+ */
9
+ export async function loadLock(lockPath: string): Promise<VirgilLock> {
10
+ try {
11
+ const contents = await readFile(lockPath, 'utf-8');
12
+ return JSON.parse(contents);
13
+ } catch (err) {
14
+ // Lock doesn't exist or is invalid - start fresh
15
+ return {
16
+ version: LOCK_VERSION,
17
+ entries: {}
18
+ };
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Write lock file atomically
24
+ */
25
+ export async function saveLock(lockPath: string, lock: VirgilLock): Promise<void> {
26
+ const contents = JSON.stringify(lock, null, 2);
27
+
28
+ // Write to temp file, then rename (atomic on POSIX)
29
+ const tempPath = `${lockPath}.tmp`;
30
+ await writeFile(tempPath, contents, 'utf-8');
31
+
32
+ // Atomic rename
33
+ const { rename } = await import('fs/promises');
34
+ await rename(tempPath, lockPath);
35
+ }
36
+
37
+ /**
38
+ * Check if output needs regeneration
39
+ */
40
+ export function needsRegeneration(
41
+ lock: VirgilLock,
42
+ id: string,
43
+ currentHash: string
44
+ ): boolean {
45
+ const entry = lock.entries[id];
46
+
47
+ if (!entry) {
48
+ // Never generated before
49
+ return true;
50
+ }
51
+
52
+ // Compare hashes
53
+ return entry.hash !== currentHash;
54
+ }
55
+
56
+ /**
57
+ * Update lock entry
58
+ */
59
+ export function updateLockEntry(
60
+ lock: VirgilLock,
61
+ id: string,
62
+ hash: string,
63
+ generator: string,
64
+ generatorVersion: string
65
+ ): void {
66
+ lock.entries[id] = {
67
+ hash,
68
+ generatedAt: new Date().toISOString(),
69
+ generator,
70
+ generatorVersion
71
+ };
72
+ }
@@ -0,0 +1,52 @@
1
+ import { readFile, writeFile } from 'fs/promises';
2
+ import { VirgilManifest } from '../types/manifest.js';
3
+
4
+ const MANIFEST_VERSION = '0.1' as const;
5
+
6
+ /**
7
+ * Load manifest if it exists, return empty manifest otherwise
8
+ */
9
+ export async function loadManifest(manifestPath: string): Promise<VirgilManifest> {
10
+ try {
11
+ const contents = await readFile(manifestPath, 'utf-8');
12
+ return JSON.parse(contents);
13
+ } catch (err) {
14
+ return {
15
+ version: MANIFEST_VERSION,
16
+ og: {},
17
+ sitemap: {},
18
+ robots: {}
19
+ };
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Write manifest atomically
25
+ */
26
+ export async function saveManifest(
27
+ manifestPath: string,
28
+ manifest: VirgilManifest
29
+ ): Promise<void> {
30
+ const contents = JSON.stringify(manifest, null, 2);
31
+
32
+ const tempPath = `${manifestPath}.tmp`;
33
+ await writeFile(tempPath, contents, 'utf-8');
34
+
35
+ const { rename } = await import('fs/promises');
36
+ await rename(tempPath, manifestPath);
37
+ }
38
+
39
+ /**
40
+ * Update manifest entry for given output type
41
+ */
42
+ export function updateManifestEntry(
43
+ manifest: VirgilManifest,
44
+ type: 'og' | 'sitemap' | 'robots',
45
+ id: string,
46
+ path: string
47
+ ): void {
48
+ if (!manifest[type]) {
49
+ manifest[type] = {};
50
+ }
51
+ manifest[type]![id] = path;
52
+ }
@@ -0,0 +1,156 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { VirgilSpec, OutputConfig } from '../types/spec.js';
4
+ import { VirgilLock } from '../types/lock.js';
5
+ import { VirgilManifest } from '../types/manifest.js';
6
+ import { PathResolver } from '../utils/paths.js';
7
+ import { computeHash, GENERATOR_VERSION } from './hash.js';
8
+ import { loadLock, saveLock, needsRegeneration, updateLockEntry } from './lock.js';
9
+ import { loadManifest, saveManifest, updateManifestEntry } from './manifest.js';
10
+ import { generateOGImage, generateOGImageFilename } from '../generators/og-image.js';
11
+ import { generateSitemap } from '../generators/sitemap.js';
12
+ import { generateRobots } from '../generators/robots.js';
13
+
14
+ export interface RunOptions {
15
+ rootDir: string;
16
+ outputDir: string;
17
+ verbose?: boolean;
18
+ }
19
+
20
+ export interface RunResult {
21
+ generated: number;
22
+ skipped: number;
23
+ errors: string[];
24
+ }
25
+
26
+ /**
27
+ * Main orchestration - this is the heart of Virgil
28
+ */
29
+ export async function run(options: RunOptions): Promise<RunResult> {
30
+ const paths = new PathResolver(options.rootDir);
31
+ const result: RunResult = {
32
+ generated: 0,
33
+ skipped: 0,
34
+ errors: []
35
+ };
36
+
37
+ try {
38
+ // Step 1: Load spec
39
+ const specContents = await readFile(paths.spec, 'utf-8');
40
+ const spec: VirgilSpec = JSON.parse(specContents);
41
+
42
+ if (options.verbose) {
43
+ console.log(`📋 Loaded spec with ${spec.outputs.length} outputs`);
44
+ }
45
+
46
+ // Step 2: Load previous lock
47
+ const lock = await loadLock(paths.lock);
48
+
49
+ // Step 3: Load manifest
50
+ const manifest = await loadManifest(paths.manifest);
51
+
52
+ // Step 4-7: Process each output
53
+ for (const output of spec.outputs) {
54
+ try {
55
+ await processOutput(spec, output, lock, manifest, options, result);
56
+ } catch (err) {
57
+ const message = err instanceof Error ? err.message : String(err);
58
+ result.errors.push(`${output.id}: ${message}`);
59
+ }
60
+ }
61
+
62
+ // Step 8: Write updated lock
63
+ await saveLock(paths.lock, lock);
64
+
65
+ // Step 9: Write updated manifest
66
+ await saveManifest(paths.manifest, manifest);
67
+
68
+ if (options.verbose) {
69
+ console.log(`✅ Generated: ${result.generated}, Skipped: ${result.skipped}, Errors: ${result.errors.length}`);
70
+ }
71
+
72
+ return result;
73
+
74
+ } catch (err) {
75
+ const message = err instanceof Error ? err.message : String(err);
76
+ result.errors.push(`Fatal: ${message}`);
77
+ return result;
78
+ }
79
+ }
80
+
81
+ async function processOutput(
82
+ spec: VirgilSpec,
83
+ output: OutputConfig,
84
+ lock: VirgilLock,
85
+ manifest: VirgilManifest,
86
+ options: RunOptions,
87
+ result: RunResult
88
+ ): Promise<void> {
89
+ // Compute current hash
90
+ const hash = await computeHash({
91
+ data: output,
92
+ generatorName: output.type
93
+ });
94
+
95
+ // Check if regeneration needed
96
+ if (!needsRegeneration(lock, output.id, hash)) {
97
+ if (options.verbose) {
98
+ console.log(`⏭️ Skipping ${output.id} (unchanged)`);
99
+ }
100
+ result.skipped++;
101
+ return;
102
+ }
103
+
104
+ if (options.verbose) {
105
+ console.log(`🔨 Generating ${output.id}`);
106
+ }
107
+
108
+ // Generate based on type
109
+ let outputPath: string;
110
+ let manifestPath: string;
111
+
112
+ switch (output.type) {
113
+ case 'og-image': {
114
+ const filename = generateOGImageFilename(output.id);
115
+ outputPath = join(options.outputDir, 'og', filename);
116
+ manifestPath = `/og/${filename}`;
117
+
118
+ await generateOGImage(
119
+ {
120
+ brand: spec.brand,
121
+ product: spec.product,
122
+ output
123
+ },
124
+ outputPath
125
+ );
126
+
127
+ updateManifestEntry(manifest, 'og', output.id, manifestPath);
128
+ break;
129
+ }
130
+
131
+ case 'sitemap': {
132
+ outputPath = join(options.outputDir, 'sitemap.xml');
133
+ manifestPath = '/sitemap.xml';
134
+
135
+ await generateSitemap({ output }, outputPath);
136
+ updateManifestEntry(manifest, 'sitemap', output.id, manifestPath);
137
+ break;
138
+ }
139
+
140
+ case 'robots': {
141
+ outputPath = join(options.outputDir, 'robots.txt');
142
+ manifestPath = '/robots.txt';
143
+
144
+ await generateRobots({ output }, outputPath);
145
+ updateManifestEntry(manifest, 'robots', output.id, manifestPath);
146
+ break;
147
+ }
148
+
149
+ default:
150
+ throw new Error(`Unknown output type: ${(output as any).type}`);
151
+ }
152
+
153
+ // Update lock
154
+ updateLockEntry(lock, output.id, hash, output.type, GENERATOR_VERSION);
155
+ result.generated++;
156
+ }
@@ -0,0 +1,65 @@
1
+ import { chromium } from 'playwright';
2
+ import { readFile, writeFile } from 'fs/promises';
3
+ import { join, dirname } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { OGImageOutput, BrandConfig, ProductConfig } from '../types/spec.js';
6
+ import { ensureParentDir } from '../utils/fs.js';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+
10
+ export interface OGImageContext {
11
+ brand: BrandConfig;
12
+ product: ProductConfig;
13
+ output: OGImageOutput;
14
+ templatePath?: string;
15
+ }
16
+
17
+ /**
18
+ * Generate OG image using Playwright
19
+ */
20
+ export async function generateOGImage(
21
+ ctx: OGImageContext,
22
+ outputPath: string
23
+ ): Promise<void> {
24
+ // Load template
25
+ const templatePath = ctx.templatePath || join(__dirname, '../templates/og-default.html');
26
+ let html = await readFile(templatePath, 'utf-8');
27
+
28
+ // Replace placeholders
29
+ html = html
30
+ .replace(/{{brand.name}}/g, ctx.brand.name)
31
+ .replace(/{{brand.color}}/g, ctx.brand.color)
32
+ .replace(/{{product.name}}/g, ctx.product.name)
33
+ .replace(/{{product.version}}/g, ctx.product.version)
34
+ .replace(/{{page.title}}/g, ctx.output.page.title)
35
+ .replace(/{{page.url}}/g, ctx.output.page.url)
36
+ .replace(/{{page.description}}/g, ctx.output.page.description || '');
37
+
38
+ // Launch headless browser
39
+ const browser = await chromium.launch();
40
+ const page = await browser.newPage({
41
+ viewport: { width: 1200, height: 630 }
42
+ });
43
+
44
+ // Set content and wait for fonts/assets
45
+ await page.setContent(html, { waitUntil: 'networkidle' });
46
+
47
+ // Ensure output directory exists
48
+ await ensureParentDir(outputPath);
49
+
50
+ // Screenshot
51
+ await page.screenshot({
52
+ path: outputPath,
53
+ type: 'png'
54
+ });
55
+
56
+ await browser.close();
57
+ }
58
+
59
+ /**
60
+ * Generate filename for OG image with cache-busting timestamp
61
+ */
62
+ export function generateOGImageFilename(id: string): string {
63
+ const timestamp = Math.floor(Date.now() / 1000);
64
+ return `${id}.${timestamp}.png`;
65
+ }
@@ -0,0 +1,44 @@
1
+ import { writeFile } from 'fs/promises';
2
+ import { RobotsOutput } from '../types/spec.js';
3
+ import { ensureParentDir } from '../utils/fs.js';
4
+
5
+ export interface RobotsContext {
6
+ output: RobotsOutput;
7
+ }
8
+
9
+ /**
10
+ * Generate robots.txt
11
+ */
12
+ export async function generateRobots(
13
+ ctx: RobotsContext,
14
+ outputPath: string
15
+ ): Promise<void> {
16
+ const lines: string[] = [];
17
+
18
+ for (const rule of ctx.output.rules) {
19
+ lines.push(`User-agent: ${rule.userAgent}`);
20
+
21
+ if (rule.allow) {
22
+ for (const path of rule.allow) {
23
+ lines.push(`Allow: ${path}`);
24
+ }
25
+ }
26
+
27
+ if (rule.disallow) {
28
+ for (const path of rule.disallow) {
29
+ lines.push(`Disallow: ${path}`);
30
+ }
31
+ }
32
+
33
+ lines.push(''); // Blank line between rules
34
+ }
35
+
36
+ if (ctx.output.sitemap) {
37
+ lines.push(`Sitemap: ${ctx.output.sitemap}`);
38
+ }
39
+
40
+ const content = lines.join('\n');
41
+
42
+ await ensureParentDir(outputPath);
43
+ await writeFile(outputPath, content, 'utf-8');
44
+ }
@@ -0,0 +1,36 @@
1
+ import { writeFile } from 'fs/promises';
2
+ import { SitemapOutput } from '../types/spec.js';
3
+ import { ensureParentDir } from '../utils/fs.js';
4
+
5
+ export interface SitemapContext {
6
+ output: SitemapOutput;
7
+ }
8
+
9
+ /**
10
+ * Generate sitemap.xml
11
+ */
12
+ export async function generateSitemap(
13
+ ctx: SitemapContext,
14
+ outputPath: string
15
+ ): Promise<void> {
16
+ const { baseUrl, pages } = ctx.output;
17
+
18
+ const urls = pages.map(page => {
19
+ const loc = `${baseUrl}${page.path}`;
20
+ const lastmod = page.lastmod ? `<lastmod>${page.lastmod}</lastmod>` : '';
21
+ const changefreq = page.changefreq ? `<changefreq>${page.changefreq}</changefreq>` : '';
22
+ const priority = page.priority !== undefined ? `<priority>${page.priority}</priority>` : '';
23
+
24
+ return ` <url>
25
+ <loc>${loc}</loc>${lastmod}${changefreq}${priority}
26
+ </url>`;
27
+ }).join('\n');
28
+
29
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
30
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
31
+ ${urls}
32
+ </urlset>`;
33
+
34
+ await ensureParentDir(outputPath);
35
+ await writeFile(outputPath, xml, 'utf-8');
36
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export { run } from './engine/run.js';
2
+ export { computeHash, hashFile } from './engine/hash.js';
3
+ export { loadLock, saveLock } from './engine/lock.js';
4
+ export { loadManifest, saveManifest } from './engine/manifest.js';
5
+
6
+ export type { VirgilSpec, OutputConfig } from './types/spec.js';
7
+ export type { VirgilLock, LockEntry } from './types/lock.js';
8
+ export type { VirgilManifest } from './types/manifest.js';
@@ -0,0 +1,100 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <style>
6
+ * {
7
+ margin: 0;
8
+ padding: 0;
9
+ box-sizing: border-box;
10
+ }
11
+
12
+ body {
13
+ width: 1200px;
14
+ height: 630px;
15
+ background: linear-gradient(135deg, {{brand.color}} 0%, #1a1a2e 100%);
16
+ display: flex;
17
+ align-items: center;
18
+ justify-content: center;
19
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
20
+ padding: 60px;
21
+ }
22
+
23
+ .container {
24
+ width: 100%;
25
+ height: 100%;
26
+ display: flex;
27
+ flex-direction: column;
28
+ justify-content: space-between;
29
+ color: white;
30
+ }
31
+
32
+ .header {
33
+ display: flex;
34
+ align-items: center;
35
+ gap: 20px;
36
+ }
37
+
38
+ .brand {
39
+ font-size: 24px;
40
+ font-weight: 600;
41
+ opacity: 0.9;
42
+ }
43
+
44
+ .content {
45
+ flex: 1;
46
+ display: flex;
47
+ flex-direction: column;
48
+ justify-content: center;
49
+ }
50
+
51
+ .title {
52
+ font-size: 72px;
53
+ font-weight: 700;
54
+ line-height: 1.2;
55
+ margin-bottom: 20px;
56
+ }
57
+
58
+ .description {
59
+ font-size: 28px;
60
+ opacity: 0.8;
61
+ line-height: 1.4;
62
+ }
63
+
64
+ .footer {
65
+ display: flex;
66
+ justify-content: space-between;
67
+ align-items: center;
68
+ font-size: 20px;
69
+ opacity: 0.7;
70
+ }
71
+
72
+ .product {
73
+ font-weight: 600;
74
+ }
75
+
76
+ .version {
77
+ background: rgba(255, 255, 255, 0.2);
78
+ padding: 4px 12px;
79
+ border-radius: 4px;
80
+ }
81
+ </style>
82
+ </head>
83
+ <body>
84
+ <div class="container">
85
+ <div class="header">
86
+ <div class="brand">{{brand.name}}</div>
87
+ </div>
88
+
89
+ <div class="content">
90
+ <div class="title">{{page.title}}</div>
91
+ <div class="description">{{page.description}}</div>
92
+ </div>
93
+
94
+ <div class="footer">
95
+ <div class="product">{{product.name}}</div>
96
+ <div class="version">v{{product.version}}</div>
97
+ </div>
98
+ </div>
99
+ </body>
100
+ </html>
@@ -0,0 +1,11 @@
1
+ export interface VirgilLock {
2
+ version: '0.1';
3
+ entries: Record<string, LockEntry>;
4
+ }
5
+
6
+ export interface LockEntry {
7
+ hash: string;
8
+ generatedAt: string;
9
+ generator: string;
10
+ generatorVersion: string;
11
+ }
@@ -0,0 +1,6 @@
1
+ export interface VirgilManifest {
2
+ version: '0.1';
3
+ og?: Record<string, string>;
4
+ sitemap?: Record<string, string>;
5
+ robots?: Record<string, string>;
6
+ }
@@ -0,0 +1,52 @@
1
+ export interface VirgilSpec {
2
+ brand: BrandConfig;
3
+ product: ProductConfig;
4
+ outputs: OutputConfig[];
5
+ }
6
+
7
+ export interface BrandConfig {
8
+ name: string;
9
+ logo: string;
10
+ color: string;
11
+ }
12
+
13
+ export interface ProductConfig {
14
+ name: string;
15
+ logo: string;
16
+ version: string;
17
+ }
18
+
19
+ export type OutputConfig = OGImageOutput | SitemapOutput | RobotsOutput;
20
+
21
+ export interface OGImageOutput {
22
+ type: 'og-image';
23
+ id: string;
24
+ page: {
25
+ title: string;
26
+ url: string;
27
+ description?: string;
28
+ };
29
+ }
30
+
31
+ export interface SitemapOutput {
32
+ type: 'sitemap';
33
+ id: string;
34
+ baseUrl: string;
35
+ pages: Array<{
36
+ path: string;
37
+ lastmod?: string;
38
+ changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
39
+ priority?: number;
40
+ }>;
41
+ }
42
+
43
+ export interface RobotsOutput {
44
+ type: 'robots';
45
+ id: string;
46
+ rules: Array<{
47
+ userAgent: string;
48
+ allow?: string[];
49
+ disallow?: string[];
50
+ }>;
51
+ sitemap?: string;
52
+ }
@@ -0,0 +1,32 @@
1
+ import { mkdir, access } from 'fs/promises';
2
+ import { dirname } from 'path';
3
+
4
+ /**
5
+ * Ensure directory exists, create if needed
6
+ */
7
+ export async function ensureDir(dirPath: string): Promise<void> {
8
+ try {
9
+ await access(dirPath);
10
+ } catch {
11
+ await mkdir(dirPath, { recursive: true });
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Ensure parent directory exists for a file path
17
+ */
18
+ export async function ensureParentDir(filePath: string): Promise<void> {
19
+ await ensureDir(dirname(filePath));
20
+ }
21
+
22
+ /**
23
+ * Check if file exists
24
+ */
25
+ export async function fileExists(filePath: string): Promise<boolean> {
26
+ try {
27
+ await access(filePath);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
@@ -0,0 +1,33 @@
1
+ import { resolve, join, relative } from 'path';
2
+
3
+ /**
4
+ * Resolve paths relative to project root
5
+ */
6
+ export class PathResolver {
7
+ constructor(private rootDir: string) {}
8
+
9
+ resolve(...segments: string[]): string {
10
+ return resolve(this.rootDir, ...segments);
11
+ }
12
+
13
+ join(...segments: string[]): string {
14
+ return join(this.rootDir, ...segments);
15
+ }
16
+
17
+ relative(from: string, to: string): string {
18
+ return relative(from, to);
19
+ }
20
+
21
+ // Standard Virgil paths
22
+ get spec(): string {
23
+ return this.resolve('virgil.spec.json');
24
+ }
25
+
26
+ get lock(): string {
27
+ return this.resolve('virgil.lock.json');
28
+ }
29
+
30
+ get manifest(): string {
31
+ return this.resolve('virgil.manifest.json');
32
+ }
33
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "lib": ["ES2022"],
6
+ "moduleResolution": "node",
7
+ "outDir": "./dist",
8
+ "rootDir": "./src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "resolveJsonModule": true,
14
+ "declaration": true,
15
+ "declarationMap": true,
16
+ "sourceMap": true
17
+ },
18
+ "include": ["src/**/*"],
19
+ "exclude": ["node_modules", "dist"]
20
+ }