@codejen/jen 1.0.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.
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function main(): Promise<void>;
package/dist/core.d.ts ADDED
@@ -0,0 +1,72 @@
1
+ export type Answers = Record<string, any>;
2
+ /** A parameter's default also decides its type: boolean default → boolean, otherwise string. */
3
+ export type Params = Record<string, {
4
+ default?: string | boolean;
5
+ }>;
6
+ export type Action =
7
+ /** Create a file. Skipped if it exists (unless force / --force). */
8
+ {
9
+ add: string;
10
+ template: string;
11
+ force?: boolean;
12
+ }
13
+ /**
14
+ * Insert `line` before the marker line. A single line reuses the marker's
15
+ * own indentation exactly; a `\n`-joined block is re-rendered in the
16
+ * file's indent style, keeping the block's relative nesting. No duplicates.
17
+ */
18
+ | {
19
+ insert: string;
20
+ before: string;
21
+ line: string;
22
+ }
23
+ /**
24
+ * Search and replace via RegExp. Also handy for removing lines. A
25
+ * multi-line `replace` is reindented to match the first match's line.
26
+ */
27
+ | {
28
+ modify: string;
29
+ pattern: RegExp;
30
+ replace: string;
31
+ }
32
+ /** Delete a file. Only inside the project root; directories are refused. */
33
+ | {
34
+ delete: string;
35
+ };
36
+ export interface Helpers {
37
+ pascal(s: string): string;
38
+ camel(s: string): string;
39
+ snake(s: string): string;
40
+ kebab(s: string): string;
41
+ constant(s: string): string;
42
+ }
43
+ export interface Generator {
44
+ description?: string;
45
+ params?: Params;
46
+ actions(answers: Answers, helpers: Helpers): Action[];
47
+ }
48
+ /** Default export of a jen pack: generator name → generator. */
49
+ export type Pack = Record<string, Generator>;
50
+ export declare const helpers: Helpers;
51
+ export declare function fail(message: string): never;
52
+ export declare const dim: (s: string) => string;
53
+ export interface Step {
54
+ mark: '+' | '~' | '!' | '-' | '=' | '?';
55
+ file: string;
56
+ note?: string;
57
+ }
58
+ /**
59
+ * Computes all changes in memory without touching the disk.
60
+ * `changes` holds the final state of every affected file (null = delete),
61
+ * so several actions on the same file (e.g. add, then insert) build on each other.
62
+ */
63
+ export declare function plan(actions: Action[], force: boolean): Promise<{
64
+ steps: Step[];
65
+ changes: Map<string, string | null>;
66
+ }>;
67
+ /** Writes or deletes everything the plan computed. */
68
+ export declare function apply(changes: Map<string, string | null>): Promise<{
69
+ written: number;
70
+ deleted: number;
71
+ }>;
72
+ export declare function printPlan(steps: Step[]): void;
@@ -0,0 +1,31 @@
1
+ /** Walks upward from the file, merging matching .editorconfig sections (closer file & later section win). */
2
+ export declare function editorConfigFor(file: string): Record<string, string>;
3
+ /** Normalizes indentation, line endings, trailing whitespace and final newline per editorconfig props. */
4
+ export declare function applyEditorConfig(content: string, props: Record<string, string>): string;
5
+ /** What newly introduced lines in `text` should be indented with: .editorconfig wins, else the file's own style. */
6
+ export declare function indentTargetFor(file: string, text: string): {
7
+ style: 'tab' | 'space';
8
+ size: number;
9
+ };
10
+ /**
11
+ * Re-renders a block of lines in the target indent style, preserving their
12
+ * relative nesting (the block's own smallest indent becomes one level).
13
+ * `baseIndent` is prefixed to every line, e.g. the marker's own indentation.
14
+ * A single line is just trimmed and prefixed – exactly today's behaviour.
15
+ */
16
+ export declare function reindentBlock(block: string[], baseIndent: string, target: {
17
+ style: 'tab' | 'space';
18
+ size: number;
19
+ }): string[];
20
+ /** Whether `block` (compared line-by-line, trimmed) already occurs anywhere in `lines`. */
21
+ export declare function containsBlock(lines: string[], block: string[]): boolean;
22
+ /**
23
+ * Reindents a multi-line `replace` template to match the indentation at its
24
+ * first match in `text`. The first line continues inline right after the
25
+ * match, so it's left exactly as written; only the following lines – which
26
+ * start fresh – are re-rendered in the file's indent style.
27
+ */
28
+ export declare function reindentReplace(text: string, pattern: RegExp, replace: string, target: {
29
+ style: 'tab' | 'space';
30
+ size: number;
31
+ }): string;
package/dist/jen.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export type { Action, Answers, Generator, Helpers, Pack, Params } from './core.ts';
3
+ export { helpers } from './core.ts';
package/dist/jen.js ADDED
@@ -0,0 +1,705 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/jen.ts
4
+ import { realpathSync } from "node:fs";
5
+ import { styleText as styleText3 } from "node:util";
6
+
7
+ // src/cli.ts
8
+ import { spawnSync } from "node:child_process";
9
+ import { existsSync as existsSync4, mkdtempSync, readFileSync as readFileSync2, readdirSync, rmSync } from "node:fs";
10
+ import { createRequire as createRequire2 } from "node:module";
11
+ import { tmpdir } from "node:os";
12
+ import { delimiter, join as join3, resolve as resolve2 } from "node:path";
13
+ import { pathToFileURL as pathToFileURL2 } from "node:url";
14
+ import { parseArgs, styleText as styleText2 } from "node:util";
15
+
16
+ // src/core.ts
17
+ import { existsSync as existsSync3, statSync } from "node:fs";
18
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
19
+ import { dirname as dirname3, isAbsolute, relative as relative2, resolve } from "node:path";
20
+ import { styleText } from "node:util";
21
+
22
+ // src/editorconfig.ts
23
+ import { existsSync, readFileSync } from "node:fs";
24
+ import { dirname, join, relative } from "node:path";
25
+ function parseEditorConfig(text) {
26
+ let root = false;
27
+ const sections = [];
28
+ let current = null;
29
+ for (const rawLine of text.split(/\r\n|\r|\n/)) {
30
+ const line = rawLine.trim();
31
+ if (!line || line.startsWith("#") || line.startsWith(";")) continue;
32
+ const section = line.match(/^\[(.+)\]$/);
33
+ if (section) {
34
+ current = { pattern: section[1], props: {} };
35
+ sections.push(current);
36
+ continue;
37
+ }
38
+ const kv = line.match(/^([^=]+)=(.*)$/);
39
+ if (!kv) continue;
40
+ const key = kv[1].trim().toLowerCase();
41
+ const value = kv[2].trim();
42
+ if (current) current.props[key] = value.toLowerCase();
43
+ else if (key === "root") root = value.toLowerCase() === "true";
44
+ }
45
+ return { root, sections };
46
+ }
47
+ var RE_SPECIAL = /[.+^$()|\\\]]/g;
48
+ function globToRegExp(glob) {
49
+ const pattern = glob.includes("/") ? glob.replace(/^\//, "") : `**/${glob}`;
50
+ let re = "";
51
+ for (let i = 0; i < pattern.length; i++) {
52
+ const c = pattern[i];
53
+ if (c === "*" && pattern[i + 1] === "*") {
54
+ re += ".*";
55
+ i++;
56
+ if (pattern[i + 1] === "/") i++;
57
+ } else if (c === "*") {
58
+ re += "[^/]*";
59
+ } else if (c === "?") {
60
+ re += "[^/]";
61
+ } else if (c === "[") {
62
+ const end = pattern.indexOf("]", i + 1);
63
+ if (end < 0) {
64
+ re += "\\[";
65
+ } else {
66
+ const cls = pattern.slice(i + 1, end);
67
+ re += `[${cls.startsWith("!") ? `^${cls.slice(1)}` : cls}]`;
68
+ i = end;
69
+ }
70
+ } else if (c === "{") {
71
+ const end = pattern.indexOf("}", i + 1);
72
+ if (end < 0) {
73
+ re += "\\{";
74
+ } else {
75
+ const alts = pattern.slice(i + 1, end).split(",").map((s) => s.replace(RE_SPECIAL, "\\$&"));
76
+ re += `(?:${alts.join("|")})`;
77
+ i = end;
78
+ }
79
+ } else {
80
+ re += c.replace(RE_SPECIAL, "\\$&");
81
+ }
82
+ }
83
+ return new RegExp(`^${re}$`);
84
+ }
85
+ function editorConfigFor(file) {
86
+ const chain = [];
87
+ let dir = dirname(file);
88
+ for (; ; ) {
89
+ const cfgPath = join(dir, ".editorconfig");
90
+ if (existsSync(cfgPath)) {
91
+ const { root, sections } = parseEditorConfig(readFileSync(cfgPath, "utf8"));
92
+ const rel = relative(dir, file);
93
+ const matched = sections.filter((s) => globToRegExp(s.pattern).test(rel)).map((s) => s.props);
94
+ if (matched.length > 0) chain.push(matched);
95
+ if (root) break;
96
+ }
97
+ const parent = dirname(dir);
98
+ if (parent === dir) break;
99
+ dir = parent;
100
+ }
101
+ const props = {};
102
+ for (const fileMatches of chain.reverse()) for (const p of fileMatches) Object.assign(props, p);
103
+ return props;
104
+ }
105
+ function applyEditorConfig(content, props) {
106
+ if (Object.keys(props).length === 0) return content;
107
+ const eol = props.end_of_line === "crlf" ? "\r\n" : props.end_of_line === "cr" ? "\r" : "\n";
108
+ const hadFinalNewline = /\r\n$|\r$|\n$/.test(content);
109
+ const lines = content.split(/\r\n|\r|\n/);
110
+ if (hadFinalNewline) lines.pop();
111
+ const indentSize = Number(props.indent_size === "tab" ? props.tab_width : props.indent_size) || void 0;
112
+ const formatted = lines.map((line) => {
113
+ let out = line;
114
+ if (indentSize && (props.indent_style === "space" || props.indent_style === "tab")) {
115
+ const indent = out.match(/^[ \t]*/)?.[0] ?? "";
116
+ let columns = 0;
117
+ for (const ch of indent) columns += ch === " " ? indentSize : 1;
118
+ const newIndent = props.indent_style === "tab" ? " ".repeat(Math.floor(columns / indentSize)) + " ".repeat(columns % indentSize) : " ".repeat(columns);
119
+ out = newIndent + out.slice(indent.length);
120
+ }
121
+ return props.trim_trailing_whitespace === "true" ? out.replace(/[ \t]+$/, "") : out;
122
+ });
123
+ let result = formatted.join(eol);
124
+ if (props.insert_final_newline === "true" || hadFinalNewline && props.insert_final_newline !== "false") {
125
+ result += eol;
126
+ }
127
+ return result;
128
+ }
129
+ function sniffIndent(text) {
130
+ const leads = text.split("\n").map((l) => l.match(/^[ \t]+/)?.[0] ?? "").filter(Boolean);
131
+ const tabs = leads.filter((l) => l[0] === " ").length;
132
+ const spaced = leads.filter((l) => l[0] === " ").map((l) => l.length);
133
+ if (tabs > 0 && tabs >= spaced.length) return { style: "tab", size: 1 };
134
+ if (spaced.length === 0) return { style: "space", size: 2 };
135
+ const gcd = (a, b) => b === 0 ? a : gcd(b, a % b);
136
+ return { style: "space", size: spaced.reduce(gcd) };
137
+ }
138
+ function indentTargetFor(file, text) {
139
+ const props = editorConfigFor(file);
140
+ const style = props.indent_style === "tab" || props.indent_style === "space" ? props.indent_style : void 0;
141
+ const size = Number(props.indent_size === "tab" ? props.tab_width : props.indent_size) || void 0;
142
+ const sniffed = sniffIndent(text);
143
+ return { style: style ?? sniffed.style, size: size ?? sniffed.size };
144
+ }
145
+ function reindentBlock(block, baseIndent, target) {
146
+ if (block.length <= 1) return [baseIndent + (block[0] ?? "").trim()];
147
+ const leads = block.filter((l) => l.trim() !== "").map((l) => l.match(/^[ \t]*/)?.[0].length ?? 0);
148
+ const unit = Math.min(...leads.filter((n) => n > 0), Infinity);
149
+ const step = target.style === "tab" ? " " : " ".repeat(target.size);
150
+ return block.map((line) => {
151
+ const trimmed = line.trim();
152
+ if (!trimmed) return "";
153
+ const lead = line.match(/^[ \t]*/)?.[0].length ?? 0;
154
+ const level = unit > 0 && Number.isFinite(unit) ? Math.round(lead / unit) : 0;
155
+ return baseIndent + step.repeat(level) + trimmed;
156
+ });
157
+ }
158
+ function containsBlock(lines, block) {
159
+ const b = block.map((l) => l.trim());
160
+ for (let k = 0; k + b.length <= lines.length; k++) {
161
+ if (b.every((l, j) => lines[k + j].trim() === l)) return true;
162
+ }
163
+ return false;
164
+ }
165
+ function reindentReplace(text, pattern, replace, target) {
166
+ const search = new RegExp(pattern.source, pattern.flags.replace("g", ""));
167
+ const m = search.exec(text);
168
+ if (!m) return replace;
169
+ const lineStart = text.lastIndexOf("\n", m.index - 1) + 1;
170
+ const baseIndent = text.slice(lineStart, m.index).match(/^[ \t]*/)?.[0] ?? "";
171
+ const lines = replace.split("\n");
172
+ const rendered = reindentBlock(lines, baseIndent, target);
173
+ return [lines[0], ...rendered.slice(1)].join("\n");
174
+ }
175
+
176
+ // src/location.ts
177
+ import { existsSync as existsSync2 } from "node:fs";
178
+ import { homedir } from "node:os";
179
+ import { dirname as dirname2, join as join2 } from "node:path";
180
+ function findUp(name, dir = process.cwd()) {
181
+ for (; ; ) {
182
+ const candidate = join2(dir, name);
183
+ if (existsSync2(candidate)) return candidate;
184
+ const parent = dirname2(dir);
185
+ if (parent === dir) return null;
186
+ dir = parent;
187
+ }
188
+ }
189
+ var PROJECT_JEN = findUp(".jen");
190
+ var ROOT = PROJECT_JEN ? dirname2(PROJECT_JEN) : process.cwd();
191
+ function setRoot(root) {
192
+ ROOT = root;
193
+ }
194
+ var USER_DIR = process.env.XDG_CONFIG_HOME ? join2(process.env.XDG_CONFIG_HOME, "jen") : process.platform === "win32" ? join2(process.env.APPDATA ?? homedir(), "jen") : join2(homedir(), ".config", "jen");
195
+
196
+ // src/core.ts
197
+ var words = (s) => s.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[\s_\-./]+/).filter(Boolean);
198
+ var cap = (w) => w.charAt(0).toUpperCase() + w.slice(1);
199
+ var helpers = {
200
+ pascal: (s) => words(s).map(cap).join(""),
201
+ camel: (s) => {
202
+ const p = helpers.pascal(s);
203
+ return p.charAt(0).toLowerCase() + p.slice(1);
204
+ },
205
+ snake: (s) => words(s).map((w) => w.toLowerCase()).join("_"),
206
+ kebab: (s) => words(s).map((w) => w.toLowerCase()).join("-"),
207
+ constant: (s) => words(s).map((w) => w.toUpperCase()).join("_")
208
+ };
209
+ function fail(message) {
210
+ throw new Error(message);
211
+ }
212
+ var dim = (s) => styleText("dim", s);
213
+ var isFile = (f) => existsSync3(f) && statSync(f).isFile();
214
+ var isInside = (root, f) => {
215
+ const rel = relative2(root, f);
216
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
217
+ };
218
+ async function plan(actions, force) {
219
+ const steps = [];
220
+ const changes = /* @__PURE__ */ new Map();
221
+ const added = /* @__PURE__ */ new Set();
222
+ const read = async (f) => changes.has(f) ? changes.get(f) ?? null : isFile(f) ? await readFile(f, "utf8") : null;
223
+ for (const a of actions) {
224
+ if ("add" in a) {
225
+ const file2 = resolve(ROOT, a.add);
226
+ const exists = await read(file2) !== null;
227
+ if (exists && !(a.force || force)) {
228
+ steps.push({ mark: "=", file: file2, note: "exists, skipped" });
229
+ continue;
230
+ }
231
+ changes.set(file2, a.template);
232
+ added.add(file2);
233
+ steps.push(exists ? { mark: "!", file: file2, note: "will be overwritten" } : { mark: "+", file: file2 });
234
+ continue;
235
+ }
236
+ if ("delete" in a) {
237
+ const file2 = resolve(ROOT, a.delete);
238
+ if (!isInside(ROOT, file2)) {
239
+ steps.push({ mark: "?", file: file2, note: "outside the project, skipped" });
240
+ } else if (existsSync3(file2) && !changes.has(file2) && !isFile(file2)) {
241
+ steps.push({ mark: "?", file: file2, note: "is a directory, skipped" });
242
+ } else if (await read(file2) === null) {
243
+ steps.push({ mark: "=", file: file2, note: "does not exist" });
244
+ } else {
245
+ changes.set(file2, null);
246
+ steps.push({ mark: "-", file: file2 });
247
+ }
248
+ continue;
249
+ }
250
+ const file = resolve(ROOT, "insert" in a ? a.insert : a.modify);
251
+ const text = await read(file);
252
+ if (text === null) {
253
+ steps.push({ mark: "?", file, note: "file missing, skipped" });
254
+ continue;
255
+ }
256
+ if ("insert" in a) {
257
+ const lines = text.split("\n");
258
+ const block = a.line.split("\n");
259
+ if (containsBlock(lines, block)) {
260
+ steps.push({ mark: "=", file, note: `already present: ${block[0].trim()}${block.length > 1 ? " \u2026" : ""}` });
261
+ continue;
262
+ }
263
+ const i = lines.findIndex((l) => l.includes(a.before));
264
+ if (i < 0) {
265
+ steps.push({ mark: "?", file, note: `marker "${a.before}" not found` });
266
+ continue;
267
+ }
268
+ const baseIndent = lines[i].match(/^\s*/)?.[0] ?? "";
269
+ const rendered = reindentBlock(block, baseIndent, indentTargetFor(file, text));
270
+ lines.splice(i, 0, ...rendered);
271
+ changes.set(file, lines.join("\n"));
272
+ steps.push({
273
+ mark: "~",
274
+ file,
275
+ note: `+ ${block[0].trim()}${block.length > 1 ? ` (+${block.length - 1} more)` : ""}`
276
+ });
277
+ } else {
278
+ const replace = a.replace.includes("\n") ? reindentReplace(text, a.pattern, a.replace, indentTargetFor(file, text)) : a.replace;
279
+ const next = text.replace(a.pattern, replace);
280
+ if (next === text) {
281
+ steps.push({ mark: "=", file, note: `no match for ${a.pattern}` });
282
+ continue;
283
+ }
284
+ changes.set(file, next);
285
+ steps.push({ mark: "~", file, note: `${a.pattern} replaced` });
286
+ }
287
+ }
288
+ for (const file of added) {
289
+ const content = changes.get(file);
290
+ if (typeof content === "string") changes.set(file, applyEditorConfig(content, editorConfigFor(file)));
291
+ }
292
+ return { steps, changes };
293
+ }
294
+ async function apply(changes) {
295
+ let written = 0;
296
+ let deleted = 0;
297
+ for (const [file, content] of changes) {
298
+ if (content === null) {
299
+ if (isFile(file)) {
300
+ await rm(file);
301
+ deleted++;
302
+ }
303
+ continue;
304
+ }
305
+ await mkdir(dirname3(file), { recursive: true });
306
+ await writeFile(file, content);
307
+ written++;
308
+ }
309
+ return { written, deleted };
310
+ }
311
+ var MARK_STYLE = {
312
+ "+": "green",
313
+ "~": "cyan",
314
+ "!": "yellow",
315
+ "-": "red",
316
+ "=": "dim",
317
+ "?": "magenta"
318
+ };
319
+ function printPlan(steps) {
320
+ for (const s of steps) {
321
+ const note = s.note ? dim(` ${s.note}`) : "";
322
+ console.log(` ${styleText(MARK_STYLE[s.mark], s.mark)} ${relative2(process.cwd(), s.file)}${note}`);
323
+ }
324
+ }
325
+
326
+ // src/yeoman.ts
327
+ import { createRequire } from "node:module";
328
+ import { pathToFileURL } from "node:url";
329
+ var PRIORITIES = ["initializing", "prompting", "configuring", "default", "writing", "transform", "conflicts"];
330
+ function answerPrompt(questions, given) {
331
+ const list2 = Array.isArray(questions) ? questions : [questions];
332
+ const answers = {};
333
+ const missing = [];
334
+ for (const q of list2) {
335
+ if (q.name in given) {
336
+ answers[q.name] = given[q.name];
337
+ } else if (typeof q.default === "function") {
338
+ answers[q.name] = q.default(answers);
339
+ } else if (q.default !== void 0) {
340
+ answers[q.name] = q.default;
341
+ } else {
342
+ missing.push(q.name);
343
+ }
344
+ }
345
+ if (missing.length > 0) {
346
+ fail(`Missing: ${missing.map((n) => `--${n}`).join(", ")} (asked by the generator's prompting step; jen never prompts).`);
347
+ }
348
+ return answers;
349
+ }
350
+ function makeLog() {
351
+ const log = ((...args) => {
352
+ console.log(...args);
353
+ return log;
354
+ });
355
+ for (const level of ["error", "warn", "info", "ok"]) {
356
+ log[level] = (...args) => {
357
+ console.log(...args);
358
+ return log;
359
+ };
360
+ }
361
+ return log;
362
+ }
363
+ function toArgv(given, positionals) {
364
+ const flags = Object.entries(given).flatMap(
365
+ ([name, value]) => typeof value === "boolean" ? value ? [`--${name}`] : [] : [`--${name}=${String(value)}`]
366
+ );
367
+ return [...flags, ...positionals];
368
+ }
369
+ async function runYeoman(GeneratorClass, resolved, ctx) {
370
+ const require2 = createRequire(resolved);
371
+ const memFs = await import(pathToFileURL(require2.resolve("mem-fs")).href);
372
+ const sharedFs = memFs.create();
373
+ const queues = new Map(PRIORITIES.map((name) => [name, []]));
374
+ const env = {
375
+ cwd: ctx.root,
376
+ logCwd: ctx.root,
377
+ sharedFs,
378
+ options: {},
379
+ adapter: {
380
+ log: makeLog(),
381
+ prompt: async (questions) => answerPrompt(questions, ctx.given),
382
+ // eslint-disable-next-line @typescript-eslint/no-empty-function -- only used for progress messages we don't print
383
+ progress: async (fn) => fn({ step() {
384
+ } })
385
+ },
386
+ getVersion: () => "999.0.0",
387
+ // eslint-disable-next-line @typescript-eslint/no-empty-function -- custom priorities aren't supported, nothing to register
388
+ addPriority() {
389
+ },
390
+ queueTask(queueName, fn) {
391
+ queues.get(queueName)?.push(fn);
392
+ },
393
+ composeWith() {
394
+ fail("This generator uses composeWith (sub-generator composition), which jen's Yeoman support doesn't cover.");
395
+ },
396
+ async runGenerator(generator2) {
397
+ await generator2.queueTasks();
398
+ for (const name of PRIORITIES) {
399
+ for (const task of queues.get(name) ?? []) await task();
400
+ }
401
+ },
402
+ // eslint-disable-next-line @typescript-eslint/no-empty-function -- nothing listens on the env, so events are dropped
403
+ emit() {
404
+ }
405
+ };
406
+ const generator = new GeneratorClass(toArgv(ctx.given, ctx.positionals), {
407
+ env,
408
+ resolved,
409
+ namespace: ctx.namespace,
410
+ skipCache: true,
411
+ skipLocalCache: true,
412
+ skipInstall: true
413
+ });
414
+ await generator.run();
415
+ const actions = [];
416
+ for (const file of sharedFs.all()) {
417
+ if (file.state === "modified") actions.push({ add: file.path, template: (file.contents ?? Buffer.alloc(0)).toString("utf8") });
418
+ else if (file.state === "deleted") actions.push({ delete: file.path });
419
+ }
420
+ return actions;
421
+ }
422
+
423
+ // src/cli.ts
424
+ var BUILTINS = {
425
+ generator: {
426
+ description: "create a new generator (project or user-wide)",
427
+ params: {
428
+ name: {},
429
+ location: { default: "project" }
430
+ },
431
+ actions: ({ name, location }, { kebab }) => [
432
+ {
433
+ add: join3(location === "user" ? USER_DIR : join3(ROOT, ".jen"), `${kebab(name)}.mjs`),
434
+ template: `// @ts-check
435
+ /** @type {import('@codejen/jen').Generator} */
436
+ export default {
437
+ description: ${JSON.stringify(String(name))},
438
+ params: {
439
+ name: {},
440
+ },
441
+ actions: ({ name }, { pascal }) => [
442
+ { add: \`src/\${pascal(name)}.txt\`, template: \`Hello \${name}!\\n\` },
443
+ ],
444
+ };
445
+ `
446
+ }
447
+ ]
448
+ }
449
+ };
450
+ var GEN_FILE = /\.(m?js|m?ts)$/;
451
+ function asGenerator(mod, where) {
452
+ const m = mod;
453
+ const gen = m.default ?? m;
454
+ if (typeof gen?.actions !== "function") fail(`${where}: does not export an actions() function`);
455
+ return { kind: "actions", ...gen };
456
+ }
457
+ function dirSource(source, dir) {
458
+ if (!dir || !existsSync4(dir)) return [];
459
+ return readdirSync(dir).filter((f) => GEN_FILE.test(f) && !f.endsWith(".d.ts")).map((f) => {
460
+ const file = join3(dir, f);
461
+ return {
462
+ name: f.replace(GEN_FILE, ""),
463
+ source,
464
+ load: async () => asGenerator(await import(pathToFileURL2(file).href), file)
465
+ };
466
+ });
467
+ }
468
+ var PACK_RE = /^(?:jen-pack-|@[^/]+\/pack-)(.+)$/;
469
+ var packPrefix = (name) => PACK_RE.exec(name)?.[1] ?? name;
470
+ function readPackageJson(dir) {
471
+ const file = dir ? join3(dir, "package.json") : null;
472
+ if (!file || !existsSync4(file)) return {};
473
+ try {
474
+ return JSON.parse(readFileSync2(file, "utf8"));
475
+ } catch (e) {
476
+ fail(`${file}: ${e.message}`);
477
+ }
478
+ }
479
+ function discoverDeps(dir, re) {
480
+ const pkg = readPackageJson(dir);
481
+ const names = [
482
+ ...Object.keys(pkg.dependencies ?? {}),
483
+ ...Object.keys(pkg.devDependencies ?? {}),
484
+ ...Object.keys(pkg.peerDependencies ?? {})
485
+ ];
486
+ return names.filter((n) => re.test(n));
487
+ }
488
+ async function packSource(name, bases = [join3(ROOT, "package.json"), import.meta.filename]) {
489
+ for (const base of bases) {
490
+ let file;
491
+ try {
492
+ file = createRequire2(base).resolve(name);
493
+ } catch {
494
+ continue;
495
+ }
496
+ const mod = await import(pathToFileURL2(file).href);
497
+ const pack = mod.default ?? mod;
498
+ return Object.entries(pack).map(([gen, g]) => ({
499
+ name: gen,
500
+ source: `pack ${name}`,
501
+ prefix: packPrefix(name),
502
+ load: async () => asGenerator(g, `${name}:${gen}`)
503
+ }));
504
+ }
505
+ console.warn(styleText2("yellow", `Pack ${name} not found (install it locally or globally).`));
506
+ return [];
507
+ }
508
+ var YEOMAN_RE = /^(?:generator-|@[^/]+\/generator-)(.+)$/;
509
+ var yeomanShortName = (name) => YEOMAN_RE.exec(name)?.[1] ?? name;
510
+ async function yeomanSource(name, bases = [join3(ROOT, "package.json"), import.meta.filename]) {
511
+ for (const base of bases) {
512
+ let file;
513
+ try {
514
+ file = createRequire2(base).resolve(name);
515
+ } catch {
516
+ continue;
517
+ }
518
+ const mod = await import(pathToFileURL2(file).href);
519
+ const GeneratorClass = mod.default ?? mod;
520
+ if (typeof GeneratorClass !== "function") {
521
+ console.warn(styleText2("yellow", `${name}: default export is not a Yeoman generator class, skipped.`));
522
+ return [];
523
+ }
524
+ const shortName = yeomanShortName(name);
525
+ return [
526
+ {
527
+ name: shortName,
528
+ source: `yeoman ${name}`,
529
+ load: async () => ({
530
+ kind: "yeoman",
531
+ description: "Yeoman generator",
532
+ run: (given, positionals) => runYeoman(GeneratorClass, file, { given, positionals, root: ROOT, namespace: `${shortName}:app` })
533
+ })
534
+ }
535
+ ];
536
+ }
537
+ console.warn(styleText2("yellow", `Yeoman generator ${name} not found (install it locally or globally).`));
538
+ return [];
539
+ }
540
+ function splitPkgSpec(spec) {
541
+ const at = spec.startsWith("@") ? spec.indexOf("@", 1) : spec.indexOf("@");
542
+ return at < 0 ? [spec, "latest"] : [spec.slice(0, at), spec.slice(at + 1)];
543
+ }
544
+ async function fetchFrom(spec) {
545
+ const [name, version] = splitPkgSpec(spec);
546
+ if (!PACK_RE.test(name) && !YEOMAN_RE.test(name)) {
547
+ fail(`"${name}" doesn't look like a jen pack (jen-pack-*, @scope/pack-*) or a Yeoman generator (generator-*, @scope/generator-*).`);
548
+ }
549
+ const dir = mkdtempSync(join3(tmpdir(), "jen-from-"));
550
+ const remove = () => rmSync(dir, { recursive: true, force: true });
551
+ const onSignal = (signal) => {
552
+ remove();
553
+ process.exit(128 + (signal === "SIGINT" ? 2 : 15));
554
+ };
555
+ process.once("SIGINT", onSignal);
556
+ process.once("SIGTERM", onSignal);
557
+ const cleanup = () => {
558
+ process.off("SIGINT", onSignal);
559
+ process.off("SIGTERM", onSignal);
560
+ remove();
561
+ };
562
+ console.log(dim(`Fetching ${name}@${version} \u2026`));
563
+ const result = spawnSync(
564
+ "npm",
565
+ ["install", `${name}@${version}`, "--prefix", dir, "--no-save", "--no-audit", "--no-fund", "--ignore-scripts"],
566
+ { stdio: "inherit", shell: process.platform === "win32" }
567
+ );
568
+ if (result.error || result.status !== 0) {
569
+ cleanup();
570
+ fail(`npm install ${name}@${version} failed${result.error ? `: ${result.error.message}` : ""}.`);
571
+ }
572
+ const bases = [join3(dir, "package.json")];
573
+ const entries = PACK_RE.test(name) ? await packSource(name, bases) : await yeomanSource(name, bases);
574
+ return { entries, cleanup };
575
+ }
576
+ async function collect() {
577
+ const extra = (process.env.JEN_PATH ?? "").split(delimiter).filter(Boolean);
578
+ const packs = [.../* @__PURE__ */ new Set([...discoverDeps(ROOT, PACK_RE), ...discoverDeps(USER_DIR, PACK_RE)])];
579
+ const yeomanGenerators = [.../* @__PURE__ */ new Set([...discoverDeps(ROOT, YEOMAN_RE), ...discoverDeps(USER_DIR, YEOMAN_RE)])];
580
+ const entries = [
581
+ ...dirSource("project", PROJECT_JEN),
582
+ ...dirSource("user", USER_DIR),
583
+ ...extra.flatMap((dir) => dirSource(`JEN_PATH ${dir}`, dir))
584
+ ];
585
+ for (const p of packs) entries.push(...await packSource(p));
586
+ for (const g of yeomanGenerators) entries.push(...await yeomanSource(g));
587
+ for (const [name, g] of Object.entries(BUILTINS)) {
588
+ entries.push({ name, source: "built-in", load: async () => ({ kind: "actions", ...g }) });
589
+ }
590
+ return entries;
591
+ }
592
+ var qualified = (e) => e.prefix ? `${e.prefix}:${e.name}` : e.name;
593
+ function find(entries, query) {
594
+ const colon = query.indexOf(":");
595
+ const prefix = colon < 0 ? null : query.slice(0, colon);
596
+ const name = colon < 0 ? query : query.slice(colon + 1);
597
+ return entries.find(
598
+ (e) => e.name === name && (prefix === null || e.prefix === prefix || e.source === prefix || e.source.startsWith(`${prefix} `))
599
+ );
600
+ }
601
+ async function list(entries) {
602
+ const seen = /* @__PURE__ */ new Set();
603
+ for (const e of entries) {
604
+ const shadowed = seen.has(e.name);
605
+ seen.add(e.name);
606
+ const description = await e.load().then(
607
+ (g) => g.description ?? "",
608
+ (err) => styleText2("red", err.message)
609
+ );
610
+ const line = ` ${qualified(e).padEnd(22)} ${e.source.padEnd(26)} ${description}`;
611
+ console.log(shadowed ? dim(`${line} (shadowed)`) : line);
612
+ }
613
+ }
614
+ var TRUE = /^(1|true|y|yes)$/i;
615
+ function resolveParams(params, given) {
616
+ const answers = {};
617
+ const missing = [];
618
+ for (const [name, spec] of Object.entries(params)) {
619
+ if (name in given) {
620
+ const v = given[name];
621
+ answers[name] = typeof spec.default === "boolean" ? v === true || TRUE.test(String(v)) : String(v);
622
+ } else if (spec.default !== void 0) {
623
+ answers[name] = spec.default;
624
+ } else {
625
+ missing.push(name);
626
+ }
627
+ }
628
+ if (missing.length > 0) {
629
+ const usage = Object.entries(params).map(([name, spec]) => spec.default === void 0 ? `--${name}=\u2026` : `--${name}=${spec.default}`).join(" ");
630
+ fail(`Missing: ${missing.map((n) => `--${n}`).join(", ")}. Usage: ${usage}`);
631
+ }
632
+ return answers;
633
+ }
634
+ var HELP = `jen \u2013 the code jen(erator)
635
+
636
+ jen [generator] [--answer=value \u2026] [options]
637
+
638
+ generator name ("class"), with pack ("cpp:class") or source ("user:class")
639
+ --list, -l list all generators and where they come from
640
+ --dry-run, -n only show the plan
641
+ --force, -f overwrite existing files
642
+ --where, -w generate into this directory instead of the project root
643
+ --from fetch a pack or Yeoman generator via npm, run it once, then remove it
644
+ --help, -h show this help
645
+
646
+ CLI answers always use "=": --name=Foo (flags like --moveOnly work without).
647
+ Without a generator name, jen lists the available ones.
648
+ `;
649
+ var FLAGS = {
650
+ list: { type: "boolean", short: "l" },
651
+ "dry-run": { type: "boolean", short: "n" },
652
+ force: { type: "boolean", short: "f" },
653
+ where: { type: "string", short: "w" },
654
+ from: { type: "string" },
655
+ help: { type: "boolean", short: "h" }
656
+ };
657
+ async function main() {
658
+ const { values, positionals } = parseArgs({ allowPositionals: true, strict: false, options: FLAGS });
659
+ if (values.help) return void console.log(HELP);
660
+ if (values.where) setRoot(resolve2(process.cwd(), String(values.where)));
661
+ const fetched = values.from ? await fetchFrom(String(values.from)) : void 0;
662
+ try {
663
+ const entries = fetched ? fetched.entries : await collect();
664
+ if (values.list) return void await list(entries);
665
+ const query = positionals[0];
666
+ if (!query) {
667
+ await list(entries);
668
+ process.exitCode = 1;
669
+ return;
670
+ }
671
+ const entry = find(entries, query) ?? fail(`Generator "${query}" not found \u2013 jen --list shows all.`);
672
+ const gen = await entry.load();
673
+ console.log(dim(`${qualified(entry)} (${entry.source})${gen.description ? ` \u2013 ${gen.description}` : ""}`));
674
+ const given = Object.fromEntries(Object.entries(values).filter(([k]) => !(k in FLAGS)));
675
+ const actions = gen.kind === "yeoman" ? await gen.run(given, positionals.slice(1)) : gen.actions(resolveParams(gen.params ?? {}, given), helpers);
676
+ const { steps, changes } = await plan(actions, Boolean(values.force));
677
+ console.log();
678
+ printPlan(steps);
679
+ if (changes.size === 0) return void console.log("\nNothing to do.");
680
+ if (values["dry-run"]) return;
681
+ const { written, deleted } = await apply(changes);
682
+ const summary = [written && `${written} written`, deleted && `${deleted} deleted`].filter(Boolean).join(", ");
683
+ console.log(styleText2("green", `\u2713 ${summary || "nothing changed"}`));
684
+ } finally {
685
+ fetched?.cleanup();
686
+ }
687
+ }
688
+
689
+ // src/jen.ts
690
+ var isMain = (() => {
691
+ try {
692
+ return realpathSync(process.argv[1] ?? "") === import.meta.filename;
693
+ } catch {
694
+ return false;
695
+ }
696
+ })();
697
+ if (isMain) {
698
+ main().catch((e) => {
699
+ console.error(styleText3("red", e instanceof Error ? e.message : String(e)));
700
+ process.exitCode = 1;
701
+ });
702
+ }
703
+ export {
704
+ helpers
705
+ };
@@ -0,0 +1,5 @@
1
+ export declare function findUp(name: string, dir?: string): string | null;
2
+ export declare const PROJECT_JEN: string | null;
3
+ export declare let ROOT: string;
4
+ export declare function setRoot(root: string): void;
5
+ export declare const USER_DIR: string;
@@ -0,0 +1,22 @@
1
+ import type { Action } from './core.ts';
2
+ interface YeomanGeneratorInstance {
3
+ run(): Promise<void>;
4
+ }
5
+ export type YeomanGeneratorClass = new (args: string[], options: Record<string, unknown>) => YeomanGeneratorInstance;
6
+ export interface YeomanRunContext {
7
+ /** CLI-given values (as jen's own resolveParams sees them), used to answer this.prompt(). */
8
+ given: Record<string, unknown>;
9
+ /** Positionals after the generator name, forwarded as the generator's own positional arguments. */
10
+ positionals: string[];
11
+ /** Project root, used as the generator's destinationRoot/cwd. */
12
+ root: string;
13
+ /** Generator namespace, e.g. "code:app" – yeoman-generator requires one. */
14
+ namespace: string;
15
+ }
16
+ /**
17
+ * Instantiates and runs a real Yeoman generator class, faking just enough
18
+ * of a yeoman-environment for it to run, and returns the files it would
19
+ * write as jen actions – ready for jen's own plan()/apply().
20
+ */
21
+ export declare function runYeoman(GeneratorClass: YeomanGeneratorClass, resolved: string, ctx: YeomanRunContext): Promise<Action[]>;
22
+ export {};
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@codejen/jen",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "type": "module",
6
+ "bin": {
7
+ "jen": "./dist/jen.js"
8
+ },
9
+ "main": "./dist/jen.js",
10
+ "types": "./dist/jen.d.ts",
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/learosema/jen.git",
17
+ "directory": "packages/jen"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "engines": {
23
+ "node": ">=24"
24
+ },
25
+ "scripts": {
26
+ "build": "rm -rf dist && esbuild src/jen.ts --bundle --platform=node --format=esm --target=node24 --outfile=dist/jen.js && tsc -p tsconfig.build.json && chmod +x dist/jen.js",
27
+ "typecheck": "tsc",
28
+ "test": "node --test jen.test.ts",
29
+ "prepublishOnly": "npm test && npm run typecheck && npm run build"
30
+ },
31
+ "keywords": [],
32
+ "author": "",
33
+ "license": "ISC",
34
+ "devDependencies": {
35
+ "@types/node": "^26.6.3",
36
+ "esbuild": "^0.27.7",
37
+ "mem-fs": "^6.0.0",
38
+ "typescript": "^6.0.3",
39
+ "yeoman-generator": "^9.2.0"
40
+ }
41
+ }