create-pyric 0.1.0-alpha.11 → 0.1.0-alpha.12

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pyric",
3
- "version": "0.1.0-alpha.11",
3
+ "version": "0.1.0-alpha.12",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://pyric.dev",
6
6
  "repository": {
@@ -24,6 +24,7 @@
24
24
  },
25
25
  "files": [
26
26
  "dist",
27
+ "src",
27
28
  "templates",
28
29
  "README.md",
29
30
  "LICENSE"
package/src/bin.ts ADDED
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `create-pyric` — `npm create pyric [dir]` / `npx create-pyric [dir]`.
4
+ *
5
+ * Always scaffolds with registry deps (npm). Default template is `web`
6
+ * (Vite + `@pyric/cli/vite`).
7
+ */
8
+
9
+ import { parseCreateArgs } from './parse-args.js';
10
+ import {
11
+ applyDepsMode,
12
+ isTemplateName,
13
+ normalizeBoolFlags,
14
+ runScaffold,
15
+ TEMPLATE_NAMES,
16
+ TEMPLATES,
17
+ } from './scaffold.js';
18
+ import { readFileSync } from 'node:fs';
19
+
20
+ function packageVersion(): string {
21
+ const metadata = JSON.parse(
22
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
23
+ ) as { version?: unknown };
24
+ if (typeof metadata.version !== 'string' || metadata.version.length === 0) {
25
+ throw new Error('create-pyric package metadata has no version');
26
+ }
27
+ return metadata.version;
28
+ }
29
+
30
+ async function main(): Promise<number> {
31
+ const args = parseCreateArgs(process.argv.slice(2));
32
+ normalizeBoolFlags(args.flags, args.positional);
33
+
34
+ if (args.flags.get('help') === true || args.flags.get('h') === true) {
35
+ process.stdout.write(
36
+ `Usage: npm create pyric [dir] [--template ${TEMPLATE_NAMES.join('|')}] [--name N] [--force] [--json]\n` +
37
+ ` npx create-pyric [dir] [flags]\n\n` +
38
+ `Default template is web (Vite + @pyric/cli/vite).\n` +
39
+ `Directory: optional positional; omit to scaffold in the current directory.\n`,
40
+ );
41
+ return 0;
42
+ }
43
+
44
+ const templateFlag = args.flags.get('template');
45
+ const templateName = typeof templateFlag === 'string' ? templateFlag : 'web';
46
+ if (!isTemplateName(templateName)) {
47
+ process.stderr.write(
48
+ `create-pyric: unknown template '${templateName}' (expected ${TEMPLATE_NAMES.join('|')})\n`,
49
+ );
50
+ return 1;
51
+ }
52
+
53
+ const nameFlag = args.flags.get('name');
54
+ const name = typeof nameFlag === 'string' && nameFlag.length > 0 ? nameFlag : undefined;
55
+
56
+ const version = packageVersion();
57
+ const effectiveTemplate = applyDepsMode(TEMPLATES[templateName], 'npm', { version });
58
+
59
+ return runScaffold(
60
+ {
61
+ dir: args.positional[0],
62
+ template: templateName,
63
+ name,
64
+ force: args.flags.get('force') === true,
65
+ json: args.flags.get('json') === true,
66
+ depsMode: 'npm',
67
+ effectiveTemplate,
68
+ pinVersion: version,
69
+ commandLabel: 'create-pyric',
70
+ },
71
+ );
72
+ }
73
+
74
+ const code = await main();
75
+ process.exit(code);
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `create-pyric` — scaffold engine for `npm create pyric` and `pyric init`.
3
+ */
4
+
5
+ export {
6
+ TEMPLATES,
7
+ TEMPLATE_NAMES,
8
+ isTemplateName,
9
+ applyDepsMode,
10
+ mergeIntoExistingPackageJson,
11
+ packageJsonFor,
12
+ normalizeBoolFlags,
13
+ runScaffold,
14
+ type ScaffoldTemplate,
15
+ type DepsMode,
16
+ type ScaffoldResult,
17
+ type ScaffoldRequest,
18
+ type ScaffoldIo,
19
+ type PackageJsonMerge,
20
+ type TemplateName,
21
+ } from './scaffold.js';
22
+ export { parseCreateArgs, type CreateArgs, type FlagValue } from './parse-args.js';
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Minimal argv parser for `create-pyric` (no subcommand — first bare
3
+ * arg is the target directory).
4
+ */
5
+
6
+ export type FlagValue = string | boolean | Array<string | boolean>;
7
+
8
+ export interface CreateArgs {
9
+ flags: Map<string, FlagValue>;
10
+ positional: string[];
11
+ }
12
+
13
+ export function parseCreateArgs(argv: string[]): CreateArgs {
14
+ const flags = new Map<string, FlagValue>();
15
+ const positional: string[] = [];
16
+ let i = 0;
17
+ while (i < argv.length) {
18
+ const arg = argv[i];
19
+ if (arg === undefined) {
20
+ i += 1;
21
+ continue;
22
+ }
23
+ if (arg === '--') {
24
+ // Flags after `--` are still ours (npm create pyric dir -- --force).
25
+ i += 1;
26
+ continue;
27
+ }
28
+ if (arg.startsWith('--')) {
29
+ const eq = arg.indexOf('=');
30
+ if (eq !== -1) {
31
+ setFlag(flags, arg.slice(2, eq), arg.slice(eq + 1));
32
+ } else {
33
+ const next = argv[i + 1];
34
+ if (next && !next.startsWith('-')) {
35
+ setFlag(flags, arg.slice(2), next);
36
+ i += 1;
37
+ } else {
38
+ setFlag(flags, arg.slice(2), true);
39
+ }
40
+ }
41
+ } else if (arg.startsWith('-')) {
42
+ setFlag(flags, arg.slice(1), true);
43
+ } else {
44
+ positional.push(arg);
45
+ }
46
+ i += 1;
47
+ }
48
+ return { flags, positional };
49
+ }
50
+
51
+ function setFlag(flags: Map<string, FlagValue>, key: string, value: string | boolean): void {
52
+ const current = flags.get(key);
53
+ if (current === undefined) {
54
+ flags.set(key, value);
55
+ } else if (Array.isArray(current)) {
56
+ current.push(value);
57
+ } else {
58
+ flags.set(key, [current, value]);
59
+ }
60
+ }
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Shared project scaffold used by `create-pyric` and `pyric init`.
3
+ *
4
+ * Never prompts. Idempotent: existing files are merged (package.json) or
5
+ * skipped; `--force` overwrites scaffold-owned files only.
6
+ */
7
+
8
+ import { writeFile, readFile, mkdir, access } from 'node:fs/promises';
9
+ import { join, basename, resolve } from 'node:path';
10
+ import {
11
+ TEMPLATES,
12
+ TEMPLATE_NAMES,
13
+ isTemplateName,
14
+ type ScaffoldTemplate,
15
+ type TemplateName,
16
+ } from './templates.js';
17
+ import type { FlagValue } from './parse-args.js';
18
+
19
+ export type { ScaffoldTemplate, TemplateName };
20
+ export { TEMPLATES, TEMPLATE_NAMES, isTemplateName } from './templates.js';
21
+
22
+ /** Where the scaffold's `pyric` / `@pyric/cli` deps come from. */
23
+ export type DepsMode = 'vendor' | 'npm';
24
+
25
+ /** Return a copy of `t` with `pyric` / `@pyric/cli` deps rewritten for `mode`. */
26
+ export function applyDepsMode(
27
+ t: ScaffoldTemplate,
28
+ mode: DepsMode,
29
+ opts: { vendorSpecs?: Record<string, string>; version?: string | null },
30
+ ): ScaffoldTemplate {
31
+ const WORKSPACE_PKGS = ['pyric', '@pyric/cli'];
32
+ const rewrite = (section: Record<string, string>): Record<string, string> => {
33
+ const next = { ...section };
34
+ for (const pkg of WORKSPACE_PKGS) {
35
+ if (!(pkg in next)) continue;
36
+ next[pkg] =
37
+ mode === 'vendor'
38
+ ? (opts.vendorSpecs?.[pkg] ?? next[pkg])
39
+ : opts.version
40
+ ? `^${opts.version}`
41
+ : next[pkg];
42
+ }
43
+ if (mode === 'vendor' && '@pyric/cli' in next && !('pyric' in next) && opts.vendorSpecs?.pyric) {
44
+ next.pyric = opts.vendorSpecs.pyric;
45
+ }
46
+ return next;
47
+ };
48
+ // Vendor mode pins EVERY vendored package via overrides (not just direct
49
+ // deps): @pyric/cli's packed manifest depends on pyric / pyric-admin /
50
+ // create-pyric as version ranges (workspace:* is rewritten at pack time),
51
+ // and those names are unpublished — only an override to the local tarball
52
+ // lets the install resolve them offline. @pyric/cli itself is always a
53
+ // direct dep with a file: spec, so it needs no override.
54
+ const vendorOverrides =
55
+ mode === 'vendor' && opts.vendorSpecs
56
+ ? Object.fromEntries(Object.entries(opts.vendorSpecs).filter(([pkg]) => pkg !== '@pyric/cli'))
57
+ : undefined;
58
+ const overrides =
59
+ vendorOverrides && Object.keys(vendorOverrides).length > 0 ? vendorOverrides : t.overrides;
60
+ return {
61
+ ...t,
62
+ dependencies: rewrite(t.dependencies),
63
+ devDependencies: rewrite(t.devDependencies),
64
+ overrides,
65
+ };
66
+ }
67
+
68
+ export interface PackageJsonMerge {
69
+ contents: string;
70
+ added: string[];
71
+ conflicts: Array<{ key: string; existing: unknown; wanted: unknown }>;
72
+ unchanged: boolean;
73
+ }
74
+
75
+ function detectIndent(raw: string): string | number {
76
+ const m = raw.match(/\n([ \t]+)"/);
77
+ if (!m) return 2;
78
+ const ws = m[1]!;
79
+ return ws.includes('\t') ? '\t' : ws.length;
80
+ }
81
+
82
+ /** Merge template fields into an existing package.json. Never overwrites. */
83
+ export function mergeIntoExistingPackageJson(
84
+ raw: string,
85
+ projectName: string,
86
+ template: ScaffoldTemplate,
87
+ ): PackageJsonMerge {
88
+ let parsed: Record<string, unknown>;
89
+ try {
90
+ parsed = JSON.parse(raw);
91
+ } catch (e) {
92
+ throw new Error(
93
+ `package.json is not valid JSON: ${e instanceof Error ? e.message : String(e)}`,
94
+ );
95
+ }
96
+ const indent = detectIndent(raw);
97
+ const added: string[] = [];
98
+ const conflicts: PackageJsonMerge['conflicts'] = [];
99
+
100
+ if (parsed.name === undefined) {
101
+ parsed.name = projectName;
102
+ added.push('name');
103
+ }
104
+ if (parsed.type === undefined) {
105
+ parsed.type = 'module';
106
+ added.push('type');
107
+ } else if (parsed.type !== 'module') {
108
+ conflicts.push({ key: 'type', existing: parsed.type, wanted: 'module' });
109
+ }
110
+ if (parsed.private === undefined) {
111
+ parsed.private = true;
112
+ added.push('private');
113
+ }
114
+
115
+ const mergeObject = (
116
+ field: 'scripts' | 'dependencies' | 'devDependencies' | 'overrides',
117
+ wanted: Record<string, string>,
118
+ ) => {
119
+ if (parsed[field] === undefined) {
120
+ parsed[field] = {};
121
+ }
122
+ const obj = parsed[field] as Record<string, unknown>;
123
+ for (const [key, value] of Object.entries(wanted)) {
124
+ if (obj[key] === undefined) {
125
+ obj[key] = value;
126
+ added.push(`${field}.${key}`);
127
+ } else if (obj[key] !== value) {
128
+ conflicts.push({ key: `${field}.${key}`, existing: obj[key], wanted: value });
129
+ }
130
+ }
131
+ };
132
+ mergeObject('scripts', template.scripts);
133
+ mergeObject('dependencies', template.dependencies);
134
+ mergeObject('devDependencies', template.devDependencies);
135
+ if (template.overrides) mergeObject('overrides', template.overrides);
136
+
137
+ return {
138
+ contents: JSON.stringify(parsed, null, indent) + '\n',
139
+ added,
140
+ conflicts,
141
+ unchanged: added.length === 0 && conflicts.length === 0,
142
+ };
143
+ }
144
+
145
+ export function packageJsonFor(name: string, t: ScaffoldTemplate): string {
146
+ return (
147
+ JSON.stringify(
148
+ {
149
+ name,
150
+ version: '0.0.0',
151
+ type: 'module',
152
+ private: true,
153
+ scripts: t.scripts,
154
+ dependencies: t.dependencies,
155
+ devDependencies: t.devDependencies,
156
+ ...(t.overrides ? { overrides: t.overrides } : {}),
157
+ },
158
+ null,
159
+ 2,
160
+ ) + '\n'
161
+ );
162
+ }
163
+
164
+ /** Stable `--json` contract; agents parse this. */
165
+ export interface ScaffoldResult {
166
+ template: TemplateName;
167
+ dir: string;
168
+ depsMode: DepsMode;
169
+ created: string[];
170
+ merged: string[];
171
+ skipped: string[];
172
+ conflicts: Array<{ key: string; existing: unknown; wanted: unknown }>;
173
+ nextSteps: string[];
174
+ }
175
+
176
+ export interface ScaffoldIo {
177
+ writeFile?: typeof writeFile;
178
+ readFile?: typeof readFile;
179
+ mkdir?: typeof mkdir;
180
+ exists?: (path: string) => Promise<boolean>;
181
+ cwd?: string;
182
+ stdout?: { write(s: string): void };
183
+ stderr?: { write(s: string): void };
184
+ }
185
+
186
+ async function defaultExists(path: string): Promise<boolean> {
187
+ try {
188
+ await access(path);
189
+ return true;
190
+ } catch {
191
+ return false;
192
+ }
193
+ }
194
+
195
+ export interface ScaffoldRequest {
196
+ /** Absolute or relative project directory (relative resolved against cwd). */
197
+ dir?: string;
198
+ template?: TemplateName;
199
+ name?: string;
200
+ force?: boolean;
201
+ json?: boolean;
202
+ depsMode?: DepsMode;
203
+ /** Already-rewritten template; defaults to TEMPLATES[template] (+ npm pin). */
204
+ effectiveTemplate?: ScaffoldTemplate;
205
+ /** Version pin for npm-mode `@pyric/cli` / `pyric` ranges. */
206
+ pinVersion?: string | null;
207
+ /** Label used in human report lines (`create-pyric` or `pyric init`). */
208
+ commandLabel?: string;
209
+ /** Extra paths already created (e.g. vendor tarballs) to list in the report. */
210
+ preCreated?: string[];
211
+ }
212
+
213
+ /** Reclaim `--force dir` / `--json dir` when a parser bound the value as the flag. */
214
+ export function normalizeBoolFlags(flags: Map<string, FlagValue>, positional: string[]): void {
215
+ for (const key of ['force', 'json']) {
216
+ const v = flags.get(key);
217
+ if (typeof v === 'string') {
218
+ positional.push(v);
219
+ flags.set(key, true);
220
+ }
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Write the scaffold into `dir`. Caller prepares vendor tarballs / pin when
226
+ * needed and may pass `effectiveTemplate`.
227
+ */
228
+ export async function runScaffold(
229
+ request: ScaffoldRequest,
230
+ deps: ScaffoldIo = {},
231
+ ): Promise<number> {
232
+ const out = deps.stdout ?? process.stdout;
233
+ const err = deps.stderr ?? process.stderr;
234
+ const cwd = deps.cwd ?? process.cwd();
235
+ const write = deps.writeFile ?? writeFile;
236
+ const read = deps.readFile ?? readFile;
237
+ const mk = deps.mkdir ?? mkdir;
238
+ const exists = deps.exists ?? defaultExists;
239
+ const label = request.commandLabel ?? 'create-pyric';
240
+
241
+ const templateName = request.template ?? 'web';
242
+ if (!isTemplateName(templateName)) {
243
+ err.write(
244
+ `${label}: unknown template '${templateName}' (expected ${TEMPLATE_NAMES.join('|')})\n`,
245
+ );
246
+ return 1;
247
+ }
248
+ const template = TEMPLATES[templateName];
249
+ const depsMode: DepsMode = request.depsMode ?? 'npm';
250
+ const pinVersion = request.pinVersion ?? null;
251
+
252
+ const dir = request.dir ? resolve(cwd, request.dir) : cwd;
253
+ const projectName =
254
+ request.name && request.name.length > 0 ? request.name : basename(dir);
255
+ const force = request.force === true;
256
+ const json = request.json === true;
257
+ const report = json ? err : out;
258
+
259
+ try {
260
+ for (const d of [dir, ...template.dirs.map((d) => join(dir, d))]) {
261
+ await mk(d, { recursive: true });
262
+ }
263
+ } catch (e) {
264
+ err.write(
265
+ `${label}: failed to create project directories: ${e instanceof Error ? e.message : String(e)}\n`,
266
+ );
267
+ return 2;
268
+ }
269
+
270
+ const effective =
271
+ request.effectiveTemplate ??
272
+ applyDepsMode(template, depsMode === 'vendor' ? 'vendor' : 'npm', {
273
+ version: pinVersion,
274
+ });
275
+
276
+ const result: ScaffoldResult = {
277
+ template: templateName,
278
+ dir,
279
+ depsMode,
280
+ created: [...(request.preCreated ?? [])],
281
+ merged: [],
282
+ skipped: [],
283
+ conflicts: [],
284
+ nextSteps: template.nextSteps,
285
+ };
286
+
287
+ const pkgPath = join(dir, 'package.json');
288
+ let pkgMerge: PackageJsonMerge | null = null;
289
+ try {
290
+ if (await exists(pkgPath)) {
291
+ const raw = (await read(pkgPath, 'utf-8')) as unknown;
292
+ const text = typeof raw === 'string' ? raw : String(raw);
293
+ pkgMerge = mergeIntoExistingPackageJson(text, projectName, effective);
294
+ result.conflicts = pkgMerge.conflicts;
295
+ if (pkgMerge.unchanged) {
296
+ result.skipped.push('package.json');
297
+ } else {
298
+ await write(pkgPath, pkgMerge.contents, 'utf-8');
299
+ result.merged.push('package.json');
300
+ }
301
+ } else {
302
+ await write(pkgPath, packageJsonFor(projectName, effective), 'utf-8');
303
+ result.created.push('package.json');
304
+ }
305
+ } catch (e) {
306
+ err.write(
307
+ `${label}: failed to handle package.json: ${e instanceof Error ? e.message : String(e)}\n`,
308
+ );
309
+ return 2;
310
+ }
311
+
312
+ for (const t of template.files(projectName)) {
313
+ const path = join(dir, t.name);
314
+ if (!force && (await exists(path))) {
315
+ result.skipped.push(t.name);
316
+ continue;
317
+ }
318
+ try {
319
+ await write(path, t.content, 'utf-8');
320
+ result.created.push(t.name);
321
+ } catch (e) {
322
+ err.write(
323
+ `${label}: failed to write ${t.name}: ${e instanceof Error ? e.message : String(e)}\n`,
324
+ );
325
+ return 2;
326
+ }
327
+ }
328
+
329
+ report.write(`${label}: scaffolded ${templateName} project "${projectName}" in ${dir}\n`);
330
+ for (const c of result.created) report.write(` + wrote ${c}\n`);
331
+ for (const m of result.merged) {
332
+ report.write(` ~ merged ${m} (added: ${pkgMerge!.added.join(', ')})\n`);
333
+ }
334
+ for (const s of result.skipped) report.write(` · skipped ${s} (already exists)\n`);
335
+
336
+ if (result.conflicts.length > 0) {
337
+ report.write('\npackage.json — kept your values for these keys (pyric wanted different):\n');
338
+ for (const c of result.conflicts) {
339
+ report.write(
340
+ ` · ${c.key}: ${JSON.stringify(c.existing)} (pyric would have set ${JSON.stringify(c.wanted)})\n`,
341
+ );
342
+ }
343
+ }
344
+
345
+ report.write(
346
+ depsMode === 'vendor'
347
+ ? '\n deps: vendored pyric + @pyric/cli into vendor/ — installs offline, no registry\n'
348
+ : `\n deps: pyric + @pyric/cli from npm${pinVersion ? ` (^${pinVersion})` : ''}\n`,
349
+ );
350
+
351
+ report.write('\nNext steps:\n');
352
+ for (const s of result.nextSteps) report.write(` ${s}\n`);
353
+
354
+ if (json) out.write(JSON.stringify(result) + '\n');
355
+ return 0;
356
+ }
@@ -0,0 +1,795 @@
1
+ /**
2
+ * Scaffold templates for `pyric init` (engine in `./init.js`).
3
+ *
4
+ * `web` (the default) scaffolds a **Vite app** wired to the `@pyric/cli/vite`
5
+ * plugin: `vite dev` runs the app's CANONICAL `firebase/*` imports against the
6
+ * in-process sandbox; `vite build` ships the real `firebase` package. One
7
+ * toolchain, no graduation cliff — the sandbox↔Firebase swap is environmental
8
+ * (dev vs build), never a code edit (the design rationale section 9).
9
+ *
10
+ * `static` is the serve-era scaffold (no bundler): a static app `pyric dev`
11
+ * runs against the in-page sandbox via a runtime import map. For pre-built /
12
+ * retrofit apps, or anyone who wants zero build step.
13
+ *
14
+ * `node` is the script-style scaffold (backend fixtures, agent loops). Its
15
+ * canonical imports are swapped by the dev command and remain Firebase under
16
+ * the production command.
17
+ */
18
+
19
+ import { lstatSync, readFileSync, readdirSync } from 'node:fs';
20
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ export const TEMPLATE_NAMES = ['web', 'node', 'static', 'chat'] as const;
24
+ export type TemplateName = (typeof TEMPLATE_NAMES)[number];
25
+
26
+ export function isTemplateName(value: string): value is TemplateName {
27
+ return (TEMPLATE_NAMES as readonly string[]).includes(value);
28
+ }
29
+
30
+ export interface ScaffoldTemplate {
31
+ /** package.json pieces merged into existing files / written into new ones. */
32
+ scripts: Record<string, string>;
33
+ dependencies: Record<string, string>;
34
+ devDependencies: Record<string, string>;
35
+ /** npm/bun `overrides` (optional). Vendor mode sets `{ pyric: file:… }` so a
36
+ * transitive `pyric` dep can't resolve to the published placeholder. */
37
+ overrides?: Record<string, string>;
38
+ /** Directories created before writing files (relative to the project dir). */
39
+ dirs: string[];
40
+ /** Scaffold-owned files, relative path → content. */
41
+ files(name: string): Array<{ name: string; content: string }>;
42
+ /** Literal commands for the report / `--json` consumers. */
43
+ nextSteps: string[];
44
+ }
45
+
46
+ interface AssetManifest {
47
+ include: string[];
48
+ }
49
+
50
+ interface AssetFile {
51
+ name: string;
52
+ content: string;
53
+ }
54
+
55
+ const ASSET_IGNORES = new Set([
56
+ '.agents',
57
+ '.codex',
58
+ '.env',
59
+ '.env.local',
60
+ '.git',
61
+ '.pyric',
62
+ 'bun.lock',
63
+ 'dist',
64
+ 'node_modules',
65
+ 'package-lock.json',
66
+ 'pnpm-lock.yaml',
67
+ 'test-results',
68
+ 'yarn.lock',
69
+ ]);
70
+
71
+ /** Load one allowlisted packaged tree; the runnable tree is the scaffold source. */
72
+ export function loadAssetTemplate(templateName: string, templateRoot?: string): {
73
+ packageJson: {
74
+ scripts: Record<string, string>;
75
+ dependencies: Record<string, string>;
76
+ devDependencies: Record<string, string>;
77
+ overrides?: Record<string, string>;
78
+ };
79
+ dirs: string[];
80
+ files: AssetFile[];
81
+ } {
82
+ const root = resolve(
83
+ templateRoot ?? fileURLToPath(new URL(`../templates/${templateName}/`, import.meta.url)),
84
+ );
85
+ const manifest = JSON.parse(readFileSync(resolve(root, 'scaffold.json'), 'utf8')) as AssetManifest;
86
+ const packageJson = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as {
87
+ scripts: Record<string, string>;
88
+ dependencies: Record<string, string>;
89
+ devDependencies: Record<string, string>;
90
+ overrides?: Record<string, string>;
91
+ };
92
+ if (!Array.isArray(manifest.include) || manifest.include.length === 0) {
93
+ throw new Error(`create-pyric: template '${templateName}' has no scaffold include list`);
94
+ }
95
+
96
+ const files: AssetFile[] = [];
97
+ const seenFiles = new Set<string>();
98
+ const dirs = new Set<string>();
99
+ const walk = (absolute: string): void => {
100
+ if (ASSET_IGNORES.has(basename(absolute))) return;
101
+ const stat = lstatSync(absolute);
102
+ if (stat.isSymbolicLink()) {
103
+ throw new Error(`create-pyric: template '${templateName}' contains a symlink: ${absolute}`);
104
+ }
105
+ if (stat.isDirectory()) {
106
+ for (const entry of readdirSync(absolute).sort()) walk(resolve(absolute, entry));
107
+ return;
108
+ }
109
+ if (!stat.isFile()) {
110
+ throw new Error(
111
+ `create-pyric: template '${templateName}' contains a non-file asset: ${absolute}`,
112
+ );
113
+ }
114
+ const rawRelative = relative(root, absolute).split(sep).join('/');
115
+ const name = rawRelative === 'gitignore' ? '.gitignore' : rawRelative;
116
+ if (seenFiles.has(name)) {
117
+ throw new Error(`create-pyric: template '${templateName}' includes '${name}' more than once`);
118
+ }
119
+ seenFiles.add(name);
120
+ const bytes = readFileSync(absolute);
121
+ if (bytes.includes(0)) {
122
+ throw new Error(
123
+ `create-pyric: template '${templateName}' contains a binary asset: ${rawRelative}`,
124
+ );
125
+ }
126
+ let parent = dirname(name).split(sep).join('/');
127
+ while (parent !== '.') {
128
+ dirs.add(parent);
129
+ parent = dirname(parent).split(sep).join('/');
130
+ }
131
+ files.push({ name, content: bytes.toString('utf8') });
132
+ };
133
+
134
+ for (const entry of manifest.include) {
135
+ if (!entry || isAbsolute(entry) || entry.split(/[\\/]/).includes('..')) {
136
+ throw new Error(`create-pyric: template '${templateName}' has unsafe include '${entry}'`);
137
+ }
138
+ const absolute = resolve(root, entry);
139
+ if (absolute !== root && !absolute.startsWith(root + sep)) {
140
+ throw new Error(`create-pyric: template '${templateName}' include escapes its root: '${entry}'`);
141
+ }
142
+ walk(absolute);
143
+ }
144
+
145
+ files.sort((a, b) => a.name.localeCompare(b.name));
146
+ return { packageJson, dirs: [...dirs].sort(), files };
147
+ }
148
+
149
+ // ─── web template ─────────────────────────────────────────────────────
150
+
151
+ const WEB_INDEX_HTML = (name: string): string => `<!doctype html>
152
+ <html>
153
+ <head>
154
+ <meta charset="utf-8" />
155
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
156
+ <title>${name}</title>
157
+ <style>
158
+ body { font: 16px/1.5 system-ui, sans-serif; max-width: 640px; margin: 3rem auto; padding: 0 1rem; }
159
+ button { padding: 0.4rem 0.9rem; cursor: pointer; }
160
+ form { display: flex; gap: 0.5rem; margin: 1rem 0; }
161
+ input { flex: 1; padding: 0.4rem 0.6rem; }
162
+ ul { padding-left: 1.2rem; }
163
+ .status { color: #666; }
164
+ </style>
165
+ </head>
166
+ <body>
167
+ <main>
168
+ <h1>${name}</h1>
169
+ <p class="status" id="auth-status">Signed out</p>
170
+ <button id="sign-in">Sign in with Google</button>
171
+ <button id="sign-out" hidden>Sign out</button>
172
+ <form id="add-post" hidden>
173
+ <input id="post-title" placeholder="Post title" required />
174
+ <button type="submit">Add post</button>
175
+ </form>
176
+ <ul id="posts"></ul>
177
+ </main>
178
+ <script type="module" src="/app.js"></script>
179
+ </body>
180
+ </html>
181
+ `;
182
+
183
+ const WEB_APP_JS = `// Canonical firebase/* imports. Under \`pyric dev\` they are served by the
184
+ // in-page pyric sandbox (the config below is ignored); under any standard
185
+ // bundler/pipeline the same imports resolve to the real \`firebase\` package.
186
+ // Graduation changes where you run this code, never the code itself.
187
+ import { initializeApp } from 'firebase/app';
188
+ import {
189
+ getAuth,
190
+ onAuthStateChanged,
191
+ signInWithPopup,
192
+ signOut,
193
+ GoogleAuthProvider,
194
+ } from 'firebase/auth';
195
+ import {
196
+ getFirestore,
197
+ collection,
198
+ onSnapshot,
199
+ addDoc,
200
+ serverTimestamp,
201
+ } from 'firebase/firestore';
202
+
203
+ const app = initializeApp({
204
+ // Graduation: your real web-app config from the Firebase console
205
+ // (see .env.example). Unused while developing under \`pyric dev\`.
206
+ apiKey: 'demo',
207
+ authDomain: 'demo.firebaseapp.com',
208
+ projectId: 'demo',
209
+ });
210
+ const auth = getAuth(app);
211
+ const db = getFirestore(app);
212
+
213
+ const els = {
214
+ status: document.getElementById('auth-status'),
215
+ signIn: document.getElementById('sign-in'),
216
+ signOut: document.getElementById('sign-out'),
217
+ form: document.getElementById('add-post'),
218
+ title: document.getElementById('post-title'),
219
+ posts: document.getElementById('posts'),
220
+ };
221
+
222
+ els.signIn.addEventListener('click', () => signInWithPopup(auth, new GoogleAuthProvider()));
223
+ els.signOut.addEventListener('click', () => signOut(auth));
224
+
225
+ onAuthStateChanged(auth, (user) => {
226
+ els.status.textContent = user
227
+ ? 'Signed in as ' + (user.displayName ?? user.email)
228
+ : 'Signed out';
229
+ els.signIn.hidden = !!user;
230
+ els.signOut.hidden = !user;
231
+ els.form.hidden = !user;
232
+ });
233
+
234
+ els.form.addEventListener('submit', async (e) => {
235
+ e.preventDefault();
236
+ // The owner-based rules require uid == request.auth.uid on create.
237
+ await addDoc(collection(db, 'posts'), {
238
+ title: els.title.value.trim(),
239
+ uid: auth.currentUser.uid,
240
+ createdAt: serverTimestamp(),
241
+ });
242
+ els.title.value = '';
243
+ });
244
+
245
+ onSnapshot(collection(db, 'posts'), (snap) => {
246
+ els.posts.replaceChildren(
247
+ ...snap.docs.map((d) => {
248
+ const li = document.createElement('li');
249
+ li.textContent = d.data().title;
250
+ return li;
251
+ }),
252
+ );
253
+ });
254
+ `;
255
+
256
+ const WEB_RULES = `rules_version = '2';
257
+ service cloud.firestore {
258
+ match /databases/{database}/documents {
259
+ // Owner-based from line 1 — \`pyric dev\` hot-reloads this file and
260
+ // ships a sign-in helper, so safe rules are cheap to iterate. These
261
+ // deploy as-is.
262
+ match /posts/{postId} {
263
+ allow read: if true;
264
+ allow create: if request.auth != null
265
+ && request.resource.data.uid == request.auth.uid;
266
+ allow update, delete: if request.auth != null
267
+ && resource.data.uid == request.auth.uid;
268
+ }
269
+
270
+ // Default deny — opt in per collection.
271
+ match /{document=**} {
272
+ allow read, write: if false;
273
+ }
274
+ }
275
+ }
276
+ `;
277
+
278
+ const WEB_FIREBASE_JSON = `{
279
+ "firestore": {
280
+ "rules": "firestore.rules",
281
+ "indexes": "firestore.indexes.json"
282
+ },
283
+ "hosting": {
284
+ "public": "public",
285
+ "rewrites": [{ "source": "**", "destination": "/index.html" }]
286
+ }
287
+ }
288
+ `;
289
+
290
+ const WEB_SEED_JSON = `{
291
+ "posts/welcome": { "title": "Welcome to pyric", "uid": "seed" },
292
+ "posts/sandboxed": { "title": "This page runs on the in-page sandbox", "uid": "seed" }
293
+ }
294
+ `;
295
+
296
+ const WEB_ENV_EXAMPLE = `# Graduation config — your real Firebase web app (console → project settings).
297
+ # Unused under \`pyric dev\`; wire it into public/app.js (or a bundler env)
298
+ # when you deploy against the real backend.
299
+ FIREBASE_API_KEY=
300
+ FIREBASE_AUTH_DOMAIN=
301
+ FIREBASE_PROJECT_ID=
302
+ FIREBASE_STORAGE_BUCKET=
303
+ FIREBASE_MESSAGING_SENDER_ID=
304
+ FIREBASE_APP_ID=
305
+ `;
306
+
307
+ const webReadme = (name: string): string => `# ${name}
308
+
309
+ A Firebase web app. In development it runs entirely on pyric's in-page
310
+ sandbox — no Firebase project, credentials, or emulators.
311
+
312
+ - **Develop:** \`bun install && bun run dev\` — serves \`public/\` with the
313
+ sandbox standing in for Firebase: seeded data, rules enforced + hot-reloaded,
314
+ popup sign-in via the helper dialog.
315
+ - **Agent:** \`bun run dev:agent\` — same, plus the MCP bridge on the dev-server
316
+ origin (\`/__pyric/mcp\`).
317
+ - **Persist (optional):** \`pyric dev --persist --seed seed.json\` — data and
318
+ test users survive reloads and restarts in \`.pyric/state/state.json\`
319
+ (plain JSON; gitignored). Promote lived state to a committable fixture
320
+ with \`pyric snapshot\`, then re-serve it: \`pyric dev --seed pyric-state.json\`.
321
+ - **Graduate:** fill \`.env\` from the Firebase console and point the config in
322
+ \`public/app.js\` at it, then run \`npx firebase-tools deploy\`. Bare
323
+ \`firebase/*\` imports need a bundler
324
+ (e.g. \`vite build\`) or an import map in production — \`pyric dev\`
325
+ provides the map in dev.
326
+
327
+ The app code uses canonical \`firebase/*\` imports everywhere. Switching
328
+ between sandbox and real Firebase is about **where you run it**, never what
329
+ you wrote.
330
+ `;
331
+
332
+ const GITIGNORE = `node_modules/
333
+ dist/
334
+ .env
335
+ .firebaserc
336
+ .pyric/
337
+ *.log
338
+ `;
339
+
340
+ const FIRESTORE_INDEXES = `{
341
+ "indexes": [],
342
+ "fieldOverrides": []
343
+ }
344
+ `;
345
+
346
+ // ─── node template (init v1 scaffold, carried over) ───────────────────
347
+
348
+ const NODE_APP_TS = `// Canonical Firebase imports stay unchanged between sandbox and production.
349
+ // \`bun run dev\` activates @pyric/cli/register; \`bun start\` loads Firebase.
350
+ import { initializeApp } from 'firebase/app';
351
+ import { getFirestore, collection, getDocs } from 'firebase/firestore';
352
+ import { seed } from './seed.ts';
353
+
354
+ const app = initializeApp({
355
+ apiKey: process.env.FIREBASE_API_KEY ?? 'pyric-local',
356
+ authDomain: process.env.FIREBASE_AUTH_DOMAIN,
357
+ projectId: process.env.FIREBASE_PROJECT_ID ?? 'pyric-local',
358
+ appId: process.env.FIREBASE_APP_ID ?? 'pyric-local',
359
+ });
360
+ const db = getFirestore(app);
361
+
362
+ if (process.env.PYRIC_SANDBOX) {
363
+ await seed(db);
364
+ }
365
+
366
+ const snap = await getDocs(collection(db, 'posts'));
367
+ console.log(\`\${snap.size} posts:\`);
368
+ snap.forEach((doc) => console.log(\` \${doc.id}:\`, doc.data()));
369
+
370
+ // Production: fill .env, deploy firestore.rules, then \`bun start\`.
371
+ `;
372
+
373
+ const NODE_SEED_TS = `import { collection, addDoc, type Firestore } from 'firebase/firestore';
374
+
375
+ export async function seed(db: Firestore): Promise<void> {
376
+ await addDoc(collection(db, 'posts'), {
377
+ title: 'Hello, Pyric',
378
+ author: 'sandbox',
379
+ createdAt: new Date(),
380
+ });
381
+ await addDoc(collection(db, 'posts'), {
382
+ title: 'Local-first by default',
383
+ author: 'sandbox',
384
+ createdAt: new Date(),
385
+ });
386
+ }
387
+ `;
388
+
389
+ const NODE_ENV_EXAMPLE = `# Production Firebase config (Firebase console -> project settings).
390
+ # Sandbox development uses the fallback values in src/app.ts.
391
+ FIREBASE_API_KEY=
392
+ FIREBASE_AUTH_DOMAIN=
393
+ FIREBASE_PROJECT_ID=
394
+ FIREBASE_APP_ID=
395
+ `;
396
+
397
+ const NODE_RULES = `rules_version = '2';
398
+ service cloud.firestore {
399
+ match /databases/{database}/documents {
400
+ // Local-first defaults: open in the sandbox so the quickstart
401
+ // works out of the box. **Tighten these before deploying with firebase-tools**
402
+ // — anonymous read+write is not what you want
403
+ // in the wild.
404
+ match /posts/{postId} {
405
+ allow read: if true;
406
+ allow write: if true;
407
+ }
408
+
409
+ // Default deny for everything else — opt in per collection.
410
+ match /{document=**} {
411
+ allow read, write: if false;
412
+ }
413
+ }
414
+ }
415
+ `;
416
+
417
+ const NODE_FIREBASE_JSON = `{
418
+ "firestore": {
419
+ "rules": "firestore.rules",
420
+ "indexes": "firestore.indexes.json"
421
+ }
422
+ }
423
+ `;
424
+
425
+ const nodeReadme = (name: string): string => `# ${name}
426
+
427
+ A Firebase app whose canonical imports run against Pyric in development and
428
+ real Firebase in production. No application-code switch is required.
429
+
430
+ ## Quick start
431
+
432
+ \`\`\`bash
433
+ bun install
434
+ bun run dev # Pyric sandbox through the Node package swap
435
+ bun start # production: real Firebase
436
+ \`\`\`
437
+
438
+ ## Use with an MCP-connected agent (Claude Code)
439
+
440
+ Install the pyric Claude Code plugin once. It auto-connects through a bundled
441
+ stdio proxy that discovers the running bridge from \`.pyric/serve.json\` and probes
442
+ both IPv4 + IPv6, so there is NO \`claude mcp add\` step and no hand-written URL (a
443
+ static \`127.0.0.1\` URL hits the loopback-family trap). Just start the bridge:
444
+
445
+ \`\`\`bash
446
+ pyric bridge # default port 5174
447
+ \`\`\`
448
+
449
+ and the agent's pyric tools attach automatically.
450
+
451
+ ## Graduating to a real Firebase project
452
+
453
+ Graduation is a command change, not a code edit:
454
+
455
+ 1. Create a project at https://console.firebase.google.com and fill \`.env\`
456
+ (see \`.env.example\`).
457
+ 2. **Tighten \`firestore.rules\`** — the scaffolded rules are open for
458
+ sandbox convenience.
459
+ 3. Deploy them with the Firebase CLI: add a \`.firebaserc\`
460
+ (\`{ "projects": { "default": "your-project-id" } }\`), then run
461
+ \`npx firebase-tools deploy --only firestore:rules\`.
462
+ 4. Run the same canonical-import code against the real backend: \`bun start\`.
463
+ `;
464
+
465
+ // ─── web template (Vite + @pyric/cli/vite) ───────────────────────────
466
+
467
+ const VITE_INDEX_HTML = (name: string): string => `<!doctype html>
468
+ <html>
469
+ <head>
470
+ <meta charset="utf-8" />
471
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
472
+ <title>${name}</title>
473
+ <style>
474
+ body { font: 16px/1.5 system-ui, sans-serif; max-width: 640px; margin: 3rem auto; padding: 0 1rem; }
475
+ button { padding: 0.4rem 0.9rem; cursor: pointer; }
476
+ form { display: flex; gap: 0.5rem; margin: 1rem 0; }
477
+ input { flex: 1; padding: 0.4rem 0.6rem; }
478
+ ul { padding-left: 1.2rem; }
479
+ .status { color: #666; }
480
+ </style>
481
+ </head>
482
+ <body>
483
+ <main>
484
+ <h1>${name}</h1>
485
+ <p class="status" id="auth-status">Signed out</p>
486
+ <button id="sign-in">Sign in with Google</button>
487
+ <button id="sign-out" hidden>Sign out</button>
488
+ <!-- Visible even while signed out ON PURPOSE — submitting attempts the
489
+ write and the owner-based rules deny it (see src/main.ts). -->
490
+ <form id="add-post">
491
+ <input id="post-title" placeholder="Post title" required />
492
+ <button type="submit">Add post</button>
493
+ </form>
494
+ <ul id="posts"></ul>
495
+ </main>
496
+ <script type="module" src="/src/main.ts"></script>
497
+ </body>
498
+ </html>
499
+ `;
500
+
501
+ const VITE_MAIN_TS = `// Canonical firebase/* imports — UNCHANGED between dev and prod.
502
+ // In \`vite dev\` the \`@pyric/cli/vite\` plugin swaps these to an in-process
503
+ // sandbox (the config below is accepted but ignored). \`vite build\` ships the
504
+ // real \`firebase\` package and uses the SAME config. Graduation is a build, not
505
+ // a code edit.
506
+ import { initializeApp } from 'firebase/app';
507
+ import {
508
+ getAuth,
509
+ onAuthStateChanged,
510
+ signInWithPopup,
511
+ signOut,
512
+ GoogleAuthProvider,
513
+ } from 'firebase/auth';
514
+ import {
515
+ getFirestore,
516
+ collection,
517
+ onSnapshot,
518
+ addDoc,
519
+ serverTimestamp,
520
+ } from 'firebase/firestore';
521
+
522
+ const app = initializeApp({
523
+ // Filled from .env (see .env.example) at \`vite build\` time for production.
524
+ // Ignored in \`vite dev\` — the pyric sandbox stands in for Firebase.
525
+ apiKey: import.meta.env.VITE_FIREBASE_API_KEY ?? 'demo',
526
+ authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN ?? 'demo.firebaseapp.com',
527
+ projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID ?? 'demo',
528
+ });
529
+ const auth = getAuth(app);
530
+ const db = getFirestore(app);
531
+
532
+ const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
533
+ const els = {
534
+ status: $('auth-status'),
535
+ signIn: $<HTMLButtonElement>('sign-in'),
536
+ signOut: $<HTMLButtonElement>('sign-out'),
537
+ form: $<HTMLFormElement>('add-post'),
538
+ title: $<HTMLInputElement>('post-title'),
539
+ posts: $('posts'),
540
+ };
541
+
542
+ els.signIn.addEventListener('click', () => signInWithPopup(auth, new GoogleAuthProvider()));
543
+ els.signOut.addEventListener('click', () => signOut(auth));
544
+
545
+ onAuthStateChanged(auth, (user) => {
546
+ els.status.textContent = user
547
+ ? 'Signed in as ' + (user.displayName ?? user.email)
548
+ : 'Signed out';
549
+ els.signIn.hidden = !!user;
550
+ els.signOut.hidden = !user;
551
+ });
552
+
553
+ els.form.addEventListener('submit', async (e) => {
554
+ e.preventDefault();
555
+ // The form stays visible while signed out ON PURPOSE: submitting then
556
+ // ATTEMPTS the write, the owner-based rules deny it (create requires
557
+ // uid == request.auth.uid), and the denial shows up in Pyric Studio's
558
+ // Traffic tab — the rules-teaching loop this demo exists for.
559
+ const user = auth.currentUser;
560
+ try {
561
+ await addDoc(collection(db, 'posts'), {
562
+ title: els.title.value.trim(),
563
+ uid: user?.uid ?? 'anonymous',
564
+ createdAt: serverTimestamp(),
565
+ });
566
+ els.title.value = '';
567
+ } catch (err) {
568
+ els.status.textContent = user
569
+ ? \`Write failed: \${(err as { code?: string }).code ?? String(err)}\`
570
+ : 'Denied by rules (signed out) — see the Traffic tab in Pyric Studio.';
571
+ }
572
+ });
573
+
574
+ onSnapshot(collection(db, 'posts'), (snap) => {
575
+ els.posts.replaceChildren(
576
+ ...snap.docs.map((d) => {
577
+ const li = document.createElement('li');
578
+ li.textContent = (d.data() as { title?: string }).title ?? '';
579
+ return li;
580
+ }),
581
+ );
582
+ });
583
+ `;
584
+
585
+ const VITE_CONFIG = `import { defineConfig } from 'vite';
586
+ import { pyric } from '@pyric/cli/vite';
587
+
588
+ // Under \`vite dev\` pyric() swaps firebase/* to the in-process pyric
589
+ // sandbox and deploys + hot-reloads firestore.rules — no Firebase project,
590
+ // credentials, or emulators. \`vite build\` (mode production) ships the real
591
+ // firebase package; the swap never reaches the deployed artifact. For a
592
+ // self-contained sandbox preview you can serve under \`pyric dev\`, build with a
593
+ // non-production mode: \`vite build --mode development\` (see the \`build:sandbox\`
594
+ // script). That output is marked and can never be deployed.
595
+ export default defineConfig({
596
+ plugins: [pyric()],
597
+ });
598
+ `;
599
+
600
+ const VITE_TSCONFIG = `{
601
+ "compilerOptions": {
602
+ "target": "ES2022",
603
+ "module": "ESNext",
604
+ "moduleResolution": "bundler",
605
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
606
+ "strict": true,
607
+ "skipLibCheck": true,
608
+ "noEmit": true,
609
+ "types": ["vite/client"]
610
+ },
611
+ "include": ["src"]
612
+ }
613
+ `;
614
+
615
+ // Owner-based rules for the Vite web template. Same shape as the static
616
+ // template's WEB_RULES, but the comment reflects the plugin (not pyric dev) —
617
+ // keep this in lockstep with examples/vite-sandbox-app/firestore.rules.
618
+ const VITE_RULES = `rules_version = '2';
619
+ service cloud.firestore {
620
+ match /databases/{database}/documents {
621
+ // Owner-based from line 1 — the Vite plugin deploys + hot-reloads this file
622
+ // into the sandbox, so safe rules are cheap to iterate. These deploy as-is.
623
+ match /posts/{postId} {
624
+ allow read: if true;
625
+ allow create: if request.auth != null
626
+ && request.resource.data.uid == request.auth.uid;
627
+ allow update, delete: if request.auth != null
628
+ && resource.data.uid == request.auth.uid;
629
+ }
630
+
631
+ // Default deny — opt in per collection.
632
+ match /{document=**} {
633
+ allow read, write: if false;
634
+ }
635
+ }
636
+ }
637
+ `;
638
+
639
+ const VITE_ENV_DTS = `/// <reference types="vite/client" />
640
+ `;
641
+
642
+ const VITE_FIREBASE_JSON = `{
643
+ "firestore": {
644
+ "rules": "firestore.rules",
645
+ "indexes": "firestore.indexes.json"
646
+ },
647
+ "hosting": {
648
+ "public": "dist",
649
+ "rewrites": [{ "source": "**", "destination": "/index.html" }]
650
+ }
651
+ }
652
+ `;
653
+
654
+ const VITE_ENV_EXAMPLE = `# Your real Firebase web-app config (Firebase console -> project settings).
655
+ # UNUSED in \`vite dev\` (the pyric sandbox stands in); USED by \`vite build\` for
656
+ # production. Vite only exposes \`VITE_\`-prefixed vars to client code.
657
+ VITE_FIREBASE_API_KEY=
658
+ VITE_FIREBASE_AUTH_DOMAIN=
659
+ VITE_FIREBASE_PROJECT_ID=
660
+ VITE_FIREBASE_STORAGE_BUCKET=
661
+ VITE_FIREBASE_MESSAGING_SENDER_ID=
662
+ VITE_FIREBASE_APP_ID=
663
+ `;
664
+
665
+ const viteReadme = (name: string): string => `# ${name}
666
+
667
+ A Firebase web app built with Vite. In development it runs entirely on pyric's
668
+ in-process sandbox — no Firebase project, credentials, or emulators.
669
+
670
+ - **Develop:** \`bun install && bun run dev\` — \`vite dev\` with the
671
+ \`@pyric/cli/vite\` plugin swapping \`firebase/*\` to the sandbox: seeded data,
672
+ your \`firestore.rules\` deployed + hot-reloaded, popup sign-in.
673
+ - **Build for production:** \`bun run build\` — \`vite build\` ships the real
674
+ \`firebase\` package. Fill \`.env\` from the Firebase console (see
675
+ \`.env.example\`); the SAME config you wrote runs against real Firebase. There
676
+ is no separate "graduation" step — dev and prod are one toolchain.
677
+ - **Deploy:** \`npx firebase-tools deploy\` after the production build
678
+ (\`hosting.public\` is \`dist/\`, Vite's build output).
679
+
680
+ > Your app code uses canonical \`firebase/*\` imports everywhere. Switching
681
+ > between the sandbox and real Firebase is \`vite dev\` vs \`vite build\`, never
682
+ > what you wrote.
683
+
684
+ The plugin is dev-only: SharedWorker multi-tab sync, \`--persist\`, capture, and
685
+ the MCP bridge (all available today under \`pyric dev\`) arrive in the plugin in
686
+ later releases. For a pre-built / no-build app, use \`pyric init --template static\`
687
+ + \`pyric dev\`.
688
+ `;
689
+
690
+ let chatAssets: ReturnType<typeof loadAssetTemplate> | undefined;
691
+ const getChatAssets = (): ReturnType<typeof loadAssetTemplate> =>
692
+ (chatAssets ??= loadAssetTemplate('chat'));
693
+
694
+ // ─── the registry ─────────────────────────────────────────────────────
695
+
696
+ export const TEMPLATES: Record<TemplateName, ScaffoldTemplate> = {
697
+ // web (default) — a Vite app on the @pyric/cli/vite plugin. `vite dev` runs
698
+ // on the sandbox; `vite build` ships real firebase. One toolchain.
699
+ web: {
700
+ scripts: {
701
+ dev: 'vite',
702
+ build: 'vite build',
703
+ 'build:sandbox': 'vite build --mode development',
704
+ preview: 'vite preview',
705
+ },
706
+ // The real firebase package ships day one so the production `vite build`
707
+ // resolves the same canonical imports against it — no code edit at graduation.
708
+ dependencies: { firebase: '^12.12.0' },
709
+ devDependencies: { '@pyric/cli': '*', vite: '^6.0.0', typescript: '^5.7.0' },
710
+ dirs: ['src'],
711
+ files: (name) => [
712
+ { name: 'index.html', content: VITE_INDEX_HTML(name) },
713
+ { name: 'vite.config.ts', content: VITE_CONFIG },
714
+ { name: 'tsconfig.json', content: VITE_TSCONFIG },
715
+ { name: 'src/main.ts', content: VITE_MAIN_TS },
716
+ { name: 'src/vite-env.d.ts', content: VITE_ENV_DTS },
717
+ { name: 'firestore.rules', content: VITE_RULES },
718
+ { name: 'firebase.json', content: VITE_FIREBASE_JSON },
719
+ { name: 'firestore.indexes.json', content: FIRESTORE_INDEXES },
720
+ { name: '.env.example', content: VITE_ENV_EXAMPLE },
721
+ { name: 'README.md', content: viteReadme(name) },
722
+ { name: '.gitignore', content: GITIGNORE },
723
+ ],
724
+ nextSteps: [
725
+ 'bun install',
726
+ 'bun run dev # vite dev on the pyric sandbox',
727
+ 'bun run build # production build against real Firebase',
728
+ ],
729
+ },
730
+ node: {
731
+ scripts: {
732
+ start: 'node --env-file-if-exists=.env --experimental-strip-types src/app.ts',
733
+ dev: 'pyric dev --no-open -- node --env-file-if-exists=.env --experimental-strip-types src/app.ts',
734
+ bridge: 'pyric bridge',
735
+ },
736
+ dependencies: { firebase: '^12.12.0' },
737
+ devDependencies: { '@pyric/cli': '*', '@types/node': '^22.0.0', typescript: '^5.7.0' },
738
+ dirs: ['src'],
739
+ files: (name) => [
740
+ { name: 'src/app.ts', content: NODE_APP_TS },
741
+ { name: '.env.example', content: NODE_ENV_EXAMPLE },
742
+ { name: 'src/seed.ts', content: NODE_SEED_TS },
743
+ { name: 'firestore.rules', content: NODE_RULES },
744
+ { name: 'firebase.json', content: NODE_FIREBASE_JSON },
745
+ { name: 'firestore.indexes.json', content: FIRESTORE_INDEXES },
746
+ { name: 'README.md', content: nodeReadme(name) },
747
+ { name: '.gitignore', content: GITIGNORE },
748
+ ],
749
+ nextSteps: ['bun install', 'bun run dev', 'bun start # production: real Firebase'],
750
+ },
751
+ // static — the serve-era, no-bundler scaffold: a static app `pyric dev`
752
+ // runs against the in-page sandbox via a runtime import map. For pre-built /
753
+ // retrofit apps, or anyone who wants zero build step.
754
+ static: {
755
+ scripts: {
756
+ dev: 'pyric dev --seed seed.json',
757
+ 'dev:agent': 'pyric dev --bridge --seed seed.json',
758
+ },
759
+ dependencies: { firebase: '^12.12.0' },
760
+ devDependencies: { '@pyric/cli': '*' },
761
+ dirs: ['public'],
762
+ files: (name) => [
763
+ { name: 'public/index.html', content: WEB_INDEX_HTML(name) },
764
+ { name: 'public/app.js', content: WEB_APP_JS },
765
+ { name: 'firestore.rules', content: WEB_RULES },
766
+ { name: 'firebase.json', content: WEB_FIREBASE_JSON },
767
+ { name: 'firestore.indexes.json', content: FIRESTORE_INDEXES },
768
+ { name: 'seed.json', content: WEB_SEED_JSON },
769
+ { name: '.env.example', content: WEB_ENV_EXAMPLE },
770
+ { name: 'README.md', content: webReadme(name) },
771
+ { name: '.gitignore', content: GITIGNORE },
772
+ ],
773
+ nextSteps: ['bun install', 'bun run dev', 'bun run dev:agent # agents: MCP at /__pyric/mcp'],
774
+ },
775
+ chat: {
776
+ // Asset-backed templates stay lazy. The standalone binary imports
777
+ // create-pyric for ordinary CLI commands without embedding this package's
778
+ // on-disk template tree; eager reads would make even `pyric --version`
779
+ // fail before dispatch.
780
+ get scripts() { return getChatAssets().packageJson.scripts; },
781
+ get dependencies() { return getChatAssets().packageJson.dependencies; },
782
+ get devDependencies() { return getChatAssets().packageJson.devDependencies; },
783
+ get overrides() { return getChatAssets().packageJson.overrides; },
784
+ get dirs() { return getChatAssets().dirs; },
785
+ files: (name) => getChatAssets().files.map((file) => ({
786
+ name: file.name,
787
+ content: file.content.replaceAll('__PYRIC_PROJECT_NAME__', name),
788
+ })),
789
+ nextSteps: [
790
+ 'npm install # or: bun install',
791
+ 'npm run dev # scripted local AI by default',
792
+ 'npm run typecheck',
793
+ ],
794
+ },
795
+ };