@atolljs/cli 0.0.0-stage → 0.1.5

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/src/io.ts ADDED
@@ -0,0 +1,127 @@
1
+ import { createInterface, type Interface } from 'node:readline/promises';
2
+ import { stdin, stdout, stderr } from 'node:process';
3
+ import { styleText } from 'node:util';
4
+
5
+ /**
6
+ * Semantic styling — commands print `fmt.accent('cd foo')` instead of raw
7
+ * ANSI, so `scriptIo` (identity) keeps test output clean and piped runs
8
+ * emit no escape codes at all.
9
+ */
10
+ export interface Fmt {
11
+ /** Bold — headings, prompts, the thing to notice. */
12
+ strong(s: string): string;
13
+ /** Dim — secondary detail, defaults, asides. */
14
+ dim(s: string): string;
15
+ /** Accent — commands, flags, and file paths the user should copy. */
16
+ accent(s: string): string;
17
+ }
18
+
19
+ const PLAIN_FMT: Fmt = { strong: (s) => s, dim: (s) => s, accent: (s) => s };
20
+
21
+ /**
22
+ * The IO seam — every command takes an Io so tests can script answers and
23
+ * capture output without a TTY. Non-interactive sessions (piped, `--yes`,
24
+ * tests) resolve every prompt to its declared default.
25
+ */
26
+ export interface Io {
27
+ readonly interactive: boolean;
28
+ readonly fmt: Fmt;
29
+ print(msg?: string): void;
30
+ ok(msg: string): void;
31
+ warn(msg: string): void;
32
+ error(msg: string): void;
33
+ ask(question: string, fallback?: string): Promise<string>;
34
+ confirm(question: string, def?: boolean): Promise<boolean>;
35
+ select<T extends string>(question: string, choices: readonly T[], def: T): Promise<T>;
36
+ close(): void;
37
+ }
38
+
39
+ export function consoleIo(interactive: boolean): Io {
40
+ // NO_COLOR opts out, FORCE_COLOR opts in — otherwise a TTY decides.
41
+ const color =
42
+ process.env.NO_COLOR === undefined &&
43
+ (process.env.FORCE_COLOR !== undefined || stdout.isTTY === true);
44
+ const style = (f: Parameters<typeof styleText>[0]) => (s: string) =>
45
+ color ? styleText(f, s) : s;
46
+ const fmt: Fmt = color
47
+ ? { strong: style('bold'), dim: style('dim'), accent: style('cyan') }
48
+ : PLAIN_FMT;
49
+ const okMark = style('green')(' ✓ ');
50
+ const warnMark = style('yellow')(' ! ');
51
+ const errorMark = style('red')(' ✗ ');
52
+ const rl: Interface | null = interactive
53
+ ? createInterface({ input: stdin, output: stdout })
54
+ : null;
55
+ return {
56
+ interactive,
57
+ fmt,
58
+ print: (msg = '') => stdout.write(msg + '\n'),
59
+ ok: (msg) => stdout.write(`${okMark} ${msg}\n`),
60
+ warn: (msg) => stdout.write(`${warnMark} ${msg}\n`),
61
+ error: (msg) => stderr.write(`${errorMark} ${msg}\n`),
62
+ async ask(question, fallback = '') {
63
+ if (!rl) return fallback;
64
+ const answer = (await rl.question(`${fmt.strong(question)} `)).trim();
65
+ return answer || fallback;
66
+ },
67
+ async confirm(question, def = false) {
68
+ if (!rl) return def;
69
+ const hint = fmt.dim(`[${def ? 'Y/n' : 'y/N'}]`);
70
+ const answer = (await rl.question(`${fmt.strong(question)} ${hint} `))
71
+ .trim()
72
+ .toLowerCase();
73
+ return answer === '' ? def : answer === 'y' || answer === 'yes';
74
+ },
75
+ async select(question, choices, def) {
76
+ if (!rl) return def;
77
+ stdout.write(fmt.strong(question) + '\n');
78
+ choices.forEach((c, i) =>
79
+ stdout.write(
80
+ ` ${fmt.accent(String(i + 1))}) ${c}${c === def ? fmt.dim(' (default)') : ''}\n`,
81
+ ),
82
+ );
83
+ const answer = (
84
+ await rl.question(`${fmt.dim(`choose [1-${choices.length}]:`)} `)
85
+ ).trim();
86
+ if (answer === '') return def;
87
+ const pick = choices[Number(answer) - 1] ?? choices.find((c) => c === answer);
88
+ return pick ?? def;
89
+ },
90
+ close: () => rl?.close(),
91
+ };
92
+ }
93
+
94
+ /** Test/script driver — canned answers in order, output captured for assertions. */
95
+ export function scriptIo(answers: readonly (string | boolean)[] = []): Io & { output: string[] } {
96
+ const output: string[] = [];
97
+ const queue = [...answers];
98
+ const next = () => queue.shift();
99
+ const io: Io & { output: string[] } = {
100
+ interactive: true,
101
+ fmt: PLAIN_FMT,
102
+ output,
103
+ print: (msg = '') => output.push(msg),
104
+ ok: (msg) => output.push(`ok ${msg}`),
105
+ warn: (msg) => output.push(`warn ${msg}`),
106
+ error: (msg) => output.push(`error ${msg}`),
107
+ async ask(_q, fallback = '') {
108
+ const a = next();
109
+ return typeof a === 'string' && a !== '' ? a : fallback;
110
+ },
111
+ async confirm(_q, def = false) {
112
+ const a = next();
113
+ if (typeof a === 'boolean') return a;
114
+ if (typeof a === 'string' && a !== '') return a.toLowerCase().startsWith('y');
115
+ return def;
116
+ },
117
+ async select(_q, choices, def) {
118
+ const a = next();
119
+ if (typeof a === 'string' && a !== '') {
120
+ return choices[Number(a) - 1] ?? choices.find((c) => c === a) ?? def;
121
+ }
122
+ return def;
123
+ },
124
+ close: () => {},
125
+ };
126
+ return io;
127
+ }
package/src/parse.ts ADDED
@@ -0,0 +1,53 @@
1
+ export interface ParsedArgs {
2
+ /** Positional args (commands, names). */
3
+ _: string[];
4
+ /** `--flag`, `--flag=value`, `--flag value`, `-abc` shorts. */
5
+ flags: Record<string, string | boolean>;
6
+ }
7
+
8
+ export function parseArgs(argv: readonly string[]): ParsedArgs {
9
+ const _: string[] = [];
10
+ const flags: Record<string, string | boolean> = {};
11
+ for (let i = 0; i < argv.length; i++) {
12
+ const a = argv[i];
13
+ if (a === '--') {
14
+ _.push(...argv.slice(i + 1));
15
+ break;
16
+ }
17
+ if (a.startsWith('--')) {
18
+ const eq = a.indexOf('=');
19
+ if (eq !== -1) {
20
+ flags[a.slice(2, eq)] = a.slice(eq + 1);
21
+ } else if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) {
22
+ flags[a.slice(2)] = argv[++i];
23
+ } else {
24
+ flags[a.slice(2)] = true;
25
+ }
26
+ } else if (a.startsWith('-') && a.length > 1) {
27
+ for (const ch of a.slice(1)) flags[ch] = true;
28
+ } else {
29
+ _.push(a);
30
+ }
31
+ }
32
+ return { _, flags };
33
+ }
34
+
35
+ export const flag = (args: ParsedArgs, name: string): string | undefined =>
36
+ typeof args.flags[name] === 'string' ? (args.flags[name] as string) : undefined;
37
+
38
+ export const hasFlag = (args: ParsedArgs, ...names: string[]): boolean =>
39
+ names.some((n) => args.flags[n] === true || typeof args.flags[n] === 'string');
40
+
41
+ /** kebab-case → camelCase (`incidents-api` → `incidentsApi`). */
42
+ export const camel = (name: string): string =>
43
+ name.replace(/[-_](.)/g, (_m, c: string) => c.toUpperCase());
44
+
45
+ /** kebab-case → PascalCase (`incidents-api` → `IncidentsApi`). */
46
+ export const pascal = (name: string): string => {
47
+ const c = camel(name);
48
+ return c.charAt(0).toUpperCase() + c.slice(1);
49
+ };
50
+
51
+ /** Validate a generated-file basename — keeps paths inside the target dir. */
52
+ export const validName = (name: string): boolean =>
53
+ /^[a-z][a-z0-9]*([-_][a-z0-9]+)*$/.test(name);
package/src/project.ts ADDED
@@ -0,0 +1,132 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+
4
+ export type Framework =
5
+ | 'angular'
6
+ | 'nestjs'
7
+ | 'nextjs'
8
+ | 'node'
9
+ | 'react'
10
+ | 'solid'
11
+ | 'svelte'
12
+ | 'vanilla'
13
+ | 'vue';
14
+
15
+ export type Pm = 'npm' | 'pnpm' | 'yarn' | 'bun';
16
+
17
+ export interface PkgJson {
18
+ name?: string;
19
+ dependencies?: Record<string, string>;
20
+ devDependencies?: Record<string, string>;
21
+ [key: string]: unknown;
22
+ }
23
+
24
+ export interface ProjectInfo {
25
+ /** Dir containing the nearest package.json, or null outside a project. */
26
+ root: string | null;
27
+ pkg: PkgJson | null;
28
+ framework: Framework;
29
+ pm: Pm;
30
+ /** Preferred source dir — `src` when present, else the project root. */
31
+ srcDir: string;
32
+ }
33
+
34
+ /** Walk up from `cwd` to the nearest dir containing a package.json. */
35
+ export function findRoot(cwd: string): string | null {
36
+ let dir = cwd;
37
+ for (;;) {
38
+ if (existsSync(join(dir, 'package.json'))) return dir;
39
+ const parent = dirname(dir);
40
+ if (parent === dir) return null;
41
+ dir = parent;
42
+ }
43
+ }
44
+
45
+ export function readPkg(dir: string): PkgJson | null {
46
+ try {
47
+ return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as PkgJson;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ const DEPS_OF = (pkg: PkgJson | null): Record<string, string> => ({
54
+ ...(pkg?.dependencies ?? {}),
55
+ ...(pkg?.devDependencies ?? {}),
56
+ });
57
+
58
+ /**
59
+ * Order is the contract — a nestjs app also depends on express-style libs,
60
+ * a nextjs app also depends on react. Most specific host wins first.
61
+ */
62
+ const DETECT: readonly [Framework, readonly string[]][] = [
63
+ ['nestjs', ['@nestjs/core', '@nestjs/common']],
64
+ ['nextjs', ['next']],
65
+ ['angular', ['@angular/core']],
66
+ ['svelte', ['svelte']],
67
+ ['solid', ['solid-js']],
68
+ ['vue', ['vue']],
69
+ ['react', ['react', 'react-dom']],
70
+ ['node', ['@atolljs/node', 'express', 'fastify', 'hono', 'koa', '@nestjs/core']],
71
+ ];
72
+
73
+ export function detectFramework(pkg: PkgJson | null): Framework {
74
+ const deps = DEPS_OF(pkg);
75
+ for (const [fw, names] of DETECT) {
76
+ if (names.some((n) => deps[n] !== undefined)) return fw;
77
+ }
78
+ return 'vanilla';
79
+ }
80
+
81
+ export function detectPm(root: string): Pm {
82
+ if (existsSync(join(root, 'pnpm-lock.yaml'))) return 'pnpm';
83
+ if (existsSync(join(root, 'yarn.lock'))) return 'yarn';
84
+ if (existsSync(join(root, 'bun.lockb')) || existsSync(join(root, 'bun.lock'))) return 'bun';
85
+ return 'npm';
86
+ }
87
+
88
+ export function detectProject(cwd: string): ProjectInfo {
89
+ const root = findRoot(cwd);
90
+ if (!root) {
91
+ return { root: null, pkg: null, framework: 'vanilla', pm: 'npm', srcDir: cwd };
92
+ }
93
+ const pkg = readPkg(root);
94
+ return {
95
+ root,
96
+ pkg,
97
+ framework: detectFramework(pkg),
98
+ pm: detectPm(root),
99
+ srcDir: existsSync(join(root, 'src')) ? join(root, 'src') : root,
100
+ };
101
+ }
102
+
103
+ /** The dependency line `init` installs per detected framework (zod is
104
+ * part of the public API surface — generated workers declare schemas). */
105
+ export const ATOLL_DEPS: Record<Framework, readonly string[]> = {
106
+ react: ['@atolljs/core', '@atolljs/react', '@atolljs/react-island', 'zod'],
107
+ vue: ['@atolljs/core', '@atolljs/vue', '@atolljs/vue-island', 'zod'],
108
+ solid: ['@atolljs/core', '@atolljs/solidjs', '@atolljs/solid-island', 'zod'],
109
+ svelte: ['@atolljs/core', '@atolljs/svelte', '@atolljs/svelte-island', 'zod'],
110
+ angular: ['@atolljs/core', '@atolljs/angular', '@atolljs/angular-island', 'zod'],
111
+ nextjs: ['@atolljs/core', '@atolljs/nextjs', 'zod'],
112
+ nestjs: ['@atolljs/core', '@atolljs/nestjs', '@atolljs/node', 'zod'],
113
+ node: ['@atolljs/core', '@atolljs/node', 'zod'],
114
+ vanilla: ['@atolljs/core', 'zod'],
115
+ };
116
+
117
+ export const ISLAND_FRAMEWORKS: readonly Framework[] = [
118
+ 'react',
119
+ 'vue',
120
+ 'solid',
121
+ 'svelte',
122
+ 'angular',
123
+ ];
124
+
125
+ export const pmInstallCmd = (pm: Pm, pkgs: readonly string[]): string =>
126
+ `${pm} ${pm === 'npm' ? 'install' : 'add'} ${pkgs.join(' ')}`;
127
+
128
+ /** Which deps from `want` are absent from the project's manifest. */
129
+ export function missingDeps(pkg: PkgJson | null, want: readonly string[]): string[] {
130
+ const deps = DEPS_OF(pkg);
131
+ return want.filter((d) => deps[d] === undefined);
132
+ }