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

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.13",
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
+ }