create-nx-archweave 0.1.0 → 0.2.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.
@@ -1,74 +0,0 @@
1
- import { AI_HOST_CHOICES, PLUGIN_CHOICES } from "./types.js";
2
- const hostLabel = (host) => AI_HOST_CHOICES.find((choice) => choice.id === host)?.label ?? host;
3
- /** `--plugins` against the picked hosts: an unknown id or a plugin of a host not picked is
4
- * an error. bin.ts runs it before any question when both flags were passed. */
5
- export function resolvePluginFlag(ids, aiHosts) {
6
- const plugins = [];
7
- for (const id of ids) {
8
- const plugin = PLUGIN_CHOICES.find((choice) => choice.id === id);
9
- if (!plugin)
10
- return { error: `Unknown plugin "${id}" in --plugins. Supported: ${PLUGIN_CHOICES.map((choice) => choice.id).join(', ')}.` };
11
- if (!aiHosts.includes(plugin.host)) {
12
- return {
13
- error: `--plugins=${id} installs into ${hostLabel(plugin.host)}, which is not among the AI hosts (${aiHosts.join(', ')}) — ` +
14
- `add ${plugin.host} to --ai-hosts or drop the plugin.`,
15
- };
16
- }
17
- plugins.push(plugin);
18
- }
19
- return { plugins };
20
- }
21
- /** `1`, `1,2`, `ponytail` or a mix, among `offered`; empty means none. Undefined for
22
- * anything else — the question is asked again. */
23
- export function parsePluginAnswer(answer, offered) {
24
- const tokens = answer.split(/[\s,]+/).filter((token) => token.length > 0);
25
- const picked = [];
26
- for (const token of tokens) {
27
- const byNumber = /^\d+$/.test(token) ? offered[Number(token) - 1] : undefined;
28
- const plugin = byNumber ?? offered.find((choice) => choice.id === token.toLowerCase());
29
- if (!plugin)
30
- return undefined;
31
- if (!picked.includes(plugin))
32
- picked.push(plugin);
33
- }
34
- return picked;
35
- }
36
- export function pluginQuestion(host, offered) {
37
- return [
38
- `? Plugins for ${hostLabel(host)} — install now, into this workspace (project scope)?`,
39
- ...offered.map((plugin, index) => ` ${index + 1}) ${plugin.label} — ${plugin.description} (${plugin.license})`),
40
- 'Choose, e.g. 1 — Enter to skip []: ',
41
- ].join('\n');
42
- }
43
- /**
44
- * The optional plugins, asked last. A flag always wins; it
45
- * may only name plugins of a picked host. Without a terminal and without the flag: none.
46
- * Asked once per picked host that has catalog plugins and whose CLI was found — installing
47
- * runs that CLI, so a host this machine lacks gets a one-line "later" hint instead.
48
- */
49
- export async function choosePlugins(input, ask, log) {
50
- if (input.flag !== undefined)
51
- return resolvePluginFlag(input.flag, input.aiHosts);
52
- if (!input.interactive)
53
- return { plugins: [] };
54
- const plugins = [];
55
- for (const host of input.aiHosts) {
56
- const offered = PLUGIN_CHOICES.filter((plugin) => plugin.host === host);
57
- if (offered.length === 0)
58
- continue;
59
- if (input.installedHosts !== undefined && input.installedHosts[host] === undefined) {
60
- log(`${hostLabel(host)} was not found, so its plugins (${offered.map((plugin) => plugin.id).join(', ')}) are not offered. ` +
61
- `Once it is installed, inside the workspace: ${input.nxG} @clearlydev/nx-archweave:install-integration --tool=<plugin> --apply`);
62
- continue;
63
- }
64
- const question = pluginQuestion(host, offered);
65
- for (;;) {
66
- const picked = parsePluginAnswer(await ask(question), offered);
67
- if (picked) {
68
- plugins.push(...picked);
69
- break;
70
- }
71
- }
72
- }
73
- return { plugins };
74
- }
@@ -1,7 +0,0 @@
1
- /** The major version of a `<manager> --version`-style string, or undefined when it does
2
- * not start with one. The compatibility rule that uses it lives in package-manager.ts
3
- * (one gate for flag, launcher and question). */
4
- export function parseMajor(version) {
5
- const match = /^\s*v?(\d+)\./.exec(version ?? '');
6
- return match ? Number(match[1]) : undefined;
7
- }
@@ -1,150 +0,0 @@
1
- import { AI_HOST_CHOICES, BACKEND_VALUES, DEFAULT_AI_HOSTS, FRONTEND_VALUES, LOCALE_VALUES, PRESET_VALUES, REVIEW_PROVIDER_CHOICES, } from "./types.js";
2
- async function askChoice(ask, question, choices) {
3
- for (;;) {
4
- const answer = (await ask(question)).trim();
5
- if (choices.includes(answer))
6
- return answer;
7
- }
8
- }
9
- function parseYesNo(answer) {
10
- return /^y(es)?$/i.test(answer.trim());
11
- }
12
- /** `1,3`, `1 3`, `codex,cursor` or a mix; empty means the default. Undefined for anything
13
- * that is not a listed number or id — the question is asked again. */
14
- export function parseAiHostAnswer(answer) {
15
- const tokens = answer.split(/[\s,]+/).filter((token) => token.length > 0);
16
- if (tokens.length === 0)
17
- return [...DEFAULT_AI_HOSTS];
18
- const picked = [];
19
- for (const token of tokens) {
20
- const byNumber = /^\d+$/.test(token) ? AI_HOST_CHOICES[Number(token) - 1] : undefined;
21
- const host = byNumber ?? AI_HOST_CHOICES.find((choice) => choice.id === token.toLowerCase());
22
- if (!host)
23
- return undefined;
24
- if (!picked.includes(host.id))
25
- picked.push(host.id);
26
- }
27
- return picked;
28
- }
29
- /** `installed 2.1.300` / `not found`, or nothing when the hosts were not probed. */
30
- export function hostStatus(host, installed) {
31
- if (installed === undefined)
32
- return '';
33
- const version = installed[host];
34
- if (version === undefined)
35
- return 'not found';
36
- return version.length > 0 ? `installed ${version}` : 'installed';
37
- }
38
- export function aiHostsQuestion(installed) {
39
- const width = Math.max(...AI_HOST_CHOICES.map((host) => host.label.length)) + 2;
40
- return [
41
- '? AI coding tools you use (choose one or more)',
42
- ...AI_HOST_CHOICES.map((host, index) => {
43
- const status = hostStatus(host.id, installed);
44
- return ` ${index + 1}) ${status ? host.label.padEnd(width) + status : host.label}`;
45
- }),
46
- `Choose, e.g. 1,3 [1]: `,
47
- ].join('\n');
48
- }
49
- async function askAiHosts(ask, installed) {
50
- const question = aiHostsQuestion(installed);
51
- for (;;) {
52
- const picked = parseAiHostAnswer(await ask(question));
53
- if (picked)
54
- return picked;
55
- }
56
- }
57
- /**
58
- * Only asks about whatever `input` doesn't already have (flags always win; only the
59
- * gaps become interactive questions):
60
- * - preset: asked if missing.
61
- * - frontend: asked if missing, for every preset; `none` makes a backend-only workspace,
62
- * so a backend is then required (and `none` is not offered when the backend is none).
63
- * - backend: asked if missing, for every preset — the stack choice is never
64
- * minimized away.
65
- * - locale: quick-start forces 'en' with NO question (keep later questions minimal);
66
- * solo/team ask if missing.
67
- * - aiHosts: asked if missing, for every preset, as a numbered multi-select;
68
- * never without a terminal (the default host then).
69
- * - review: asked with a single yes/no ONLY when the resolved preset is quick-start
70
- * (the opt-in preset, which asks a single install-or-not question) AND no
71
- * --review/--no-review flag was given.
72
- * solo/team never ask — an explicit flag still overrides their preset default,
73
- * but absent one, `review` stays undefined for the init generator to default.
74
- *
75
- * NOTE: "quick-start is the only preset that asks about review" is this CLI's own rule,
76
- * but it mirrors what
77
- * `packages/template-engine/src/presets/preset-config.ts` encodes (quick-start is
78
- * opt-in, solo/team default review on) without importing it — the CLI must not
79
- * depend on template-engine. Like `types.ts`'s enum copies,
80
- * this is a deliberate hand-synced literal: if a preset's review default ever
81
- * changes there, update this question gate to match.
82
- */
83
- export async function collectMissingInputs(input, ask) {
84
- const preset = input.preset ?? (await askChoice(ask, `Preset (${PRESET_VALUES.join('/')}): `, PRESET_VALUES));
85
- // none/none would build nothing: whichever is asked second leaves `none` out when the
86
- // other is already none (both passed as flags is refused by parse-args).
87
- const frontendChoices = input.backend === 'none' ? FRONTEND_VALUES.filter((value) => value !== 'none') : FRONTEND_VALUES;
88
- const frontend = input.frontend ?? (await askChoice(ask, `Frontend (${frontendChoices.join('/')}): `, frontendChoices));
89
- const backendChoices = frontend === 'none' ? BACKEND_VALUES.filter((value) => value !== 'none') : BACKEND_VALUES;
90
- const backend = input.backend ?? (await askChoice(ask, `Backend (${backendChoices.join('/')}): `, backendChoices));
91
- const locale = preset === 'quick-start'
92
- ? input.locale ?? 'en'
93
- : input.locale ?? (await askChoice(ask, `Locale (${LOCALE_VALUES.join('/')}): `, LOCALE_VALUES));
94
- // Asked for every preset: the hosts decide which files the workspace gets.
95
- const aiHosts = input.aiHosts ?? (input.interactive ? await askAiHosts(ask, input.installedHosts) : [...DEFAULT_AI_HOSTS]);
96
- let review = input.review;
97
- if (review === undefined && preset === 'quick-start') {
98
- const answer = await ask('Enable AI PR review? (y/N): ');
99
- review = parseYesNo(answer);
100
- }
101
- // The provider is asked only when review will be on: an explicit yes, or solo/team's own
102
- // default (the same hand-synced rule as the question above). Enter keeps init's default.
103
- let reviewProvider = input.reviewProvider;
104
- if (reviewProvider === undefined && reviewWillBeOn(preset, review) && input.interactive) {
105
- reviewProvider = await askReviewProvider(ask, aiHosts[0]);
106
- }
107
- return { preset, frontend, backend, locale, aiHosts, review, reviewProvider };
108
- }
109
- /** Whether init will set up AI PR review: an explicit --review / --no-review, else every preset
110
- * but quick-start (hand-synced with template-engine's preset defaults, like the question). */
111
- export function reviewWillBeOn(preset, review) {
112
- return review ?? preset !== 'quick-start';
113
- }
114
- /** `--review-provider` with review off is refused rather than dropped without a word. Undefined
115
- * when there is nothing to refuse (no flag, or review on). */
116
- export function reviewProviderConflict(preset, review, reviewProvider) {
117
- if (reviewProvider === undefined)
118
- return undefined;
119
- if (review === false)
120
- return '--review-provider needs AI PR review on, but --no-review was passed (or review was declined): drop one of them.';
121
- if (preset === undefined || reviewWillBeOn(preset, review))
122
- return undefined;
123
- return '--review-provider needs AI PR review on, and the quick-start preset leaves it off: add --review, or drop --review-provider.';
124
- }
125
- export function reviewProviderQuestion(firstHost) {
126
- const host = AI_HOST_CHOICES.find((choice) => choice.id === firstHost);
127
- const defaultIndex = REVIEW_PROVIDER_CHOICES.findIndex((provider) => provider.id === host?.reviewProvider);
128
- return [
129
- '? AI PR review provider',
130
- ...REVIEW_PROVIDER_CHOICES.map((provider, index) => ` ${index + 1}) ${provider.label}${index === defaultIndex ? ` (default — ${host?.label})` : ''}`),
131
- `Choose 1-${REVIEW_PROVIDER_CHOICES.length} [${defaultIndex + 1}]: `,
132
- ].join('\n');
133
- }
134
- /** A number or an id; empty → undefined (init's default). Anything else is asked again. */
135
- export function parseReviewProviderAnswer(answer) {
136
- const token = answer.trim().toLowerCase();
137
- if (token === '')
138
- return undefined;
139
- const byNumber = /^\d+$/.test(token) ? REVIEW_PROVIDER_CHOICES[Number(token) - 1] : undefined;
140
- const provider = byNumber ?? REVIEW_PROVIDER_CHOICES.find((choice) => choice.id === token);
141
- return provider ? provider.id : null;
142
- }
143
- async function askReviewProvider(ask, firstHost) {
144
- const question = reviewProviderQuestion(firstHost);
145
- for (;;) {
146
- const picked = parseReviewProviderAnswer(await ask(question));
147
- if (picked !== null)
148
- return picked;
149
- }
150
- }
@@ -1,71 +0,0 @@
1
- // This file exists in packages/shared and packages/create-nx-archweave (which imports
2
- // nothing); keep the two identical — the CLI's spec compares them.
3
- import { statSync } from 'node:fs';
4
- import { win32 } from 'node:path';
5
- function isFile(path) {
6
- try {
7
- return statSync(path).isFile();
8
- }
9
- catch {
10
- return false;
11
- }
12
- }
13
- const DEFAULT_ENV = { platform: process.platform, env: process.env, isFile };
14
- /** Windows keeps PATH and PATHEXT under any letter case. */
15
- function envValue(env, name) {
16
- const key = Object.keys(env).find((candidate) => candidate.toUpperCase() === name);
17
- return key === undefined ? undefined : env[key];
18
- }
19
- /** Where Windows would find `command`: as given when it has a path, else on PATH, trying
20
- * each PATHEXT extension unless it already has one. Undefined when nothing matches. */
21
- export function resolveWindowsCommand(command, environment) {
22
- const exts = (envValue(environment.env, 'PATHEXT') ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
23
- const candidates = (base) => (win32.extname(base) !== '' ? [base] : exts.map((ext) => base + ext));
24
- if (win32.isAbsolute(command) || /[\\/]/.test(command)) {
25
- return candidates(command).find(environment.isFile);
26
- }
27
- const dirs = (envValue(environment.env, 'PATH') ?? '').split(';').filter(Boolean);
28
- for (const dir of dirs) {
29
- const found = candidates(win32.join(dir, command)).find(environment.isFile);
30
- if (found !== undefined)
31
- return found;
32
- }
33
- return undefined;
34
- }
35
- // cmd.exe's metacharacters, escaped with ^ (the algorithm cross-spawn uses — MIT,
36
- // https://github.com/moxystudio/node-cross-spawn — after https://qntm.org/cmd).
37
- const META_CHARS = /([()\][%!^"`<>&|;, *?])/g;
38
- /** A project-local `node_modules\.bin\*.cmd` shim hands its arguments to cmd.exe a second
39
- * time, so they are escaped twice (cross-spawn does the same). */
40
- const BIN_SHIM = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i;
41
- /** One argument for `cmd.exe /d /s /c "…"`: quoted for the program's own argv parsing,
42
- * then every cmd.exe metacharacter escaped (twice for a node_modules\.bin shim). `%VAR%`
43
- * is not fully protected from expansion — the arguments passed here are the program's own,
44
- * never user text, as with cross-spawn. */
45
- export function escapeCmdArgument(arg, doubleEscape = false) {
46
- let quoted = arg.replace(/(\\*)"/g, '$1$1\\"');
47
- quoted = quoted.replace(/(\\*)$/, '$1$1');
48
- const escaped = `"${quoted}"`.replace(META_CHARS, '^$1');
49
- return doubleEscape ? escaped.replace(META_CHARS, '^$1') : escaped;
50
- }
51
- /**
52
- * How to spawn `command args` without a shell, on every platform. Node cannot start a
53
- * Windows `.cmd` / `.bat` shim itself (ENOENT without the extension; EINVAL with it, since
54
- * the CVE-2024-27980 fix) — and npm, npx, pnpm, yarn and an npm-installed claude are all
55
- * such shims there. On Windows those run through `cmd.exe /d /s /c` with each argument
56
- * escaped; everything else (a real .exe, any other platform) is spawned unchanged.
57
- */
58
- export function spawnableCommand(command, args, environment = DEFAULT_ENV) {
59
- if (environment.platform !== 'win32')
60
- return { command, args: [...args] };
61
- const resolved = resolveWindowsCommand(command, environment);
62
- if (resolved === undefined || !/\.(cmd|bat)$/i.test(resolved))
63
- return { command, args: [...args] };
64
- const doubleEscape = BIN_SHIM.test(resolved);
65
- const line = [resolved.replace(META_CHARS, '^$1'), ...args.map((arg) => escapeCmdArgument(arg, doubleEscape))].join(' ');
66
- return {
67
- command: envValue(environment.env, 'COMSPEC') ?? 'cmd.exe',
68
- args: ['/d', '/s', '/c', `"${line}"`],
69
- windowsVerbatimArguments: true,
70
- };
71
- }
package/dist/cli/types.js DELETED
@@ -1,53 +0,0 @@
1
- /**
2
- * Literal option types shared by the parse/prompt/plan layers.
3
- *
4
- * These are intentionally NOT imported from `@nx-archweave/shared` or
5
- * `@nx-archweave/template-engine`: the CLI stream owns none of that policy (no content
6
- * or policy knowledge), it only forwards option values — so the literals here are
7
- * copied from `packages/nx-plugin/src/generators/init/schema.json`'s `preset`,
8
- * `frontend`, and `locale` enums, not derived from them. If the schema's enum ever
9
- * changes, this file must be updated to match by hand.
10
- */
11
- export const PRESET_VALUES = ['quick-start', 'solo', 'team'];
12
- export const FRONTEND_VALUES = ['react-vite', 'next', 'none'];
13
- export const BACKEND_VALUES = ['express', 'nest', 'none'];
14
- export const LOCALE_VALUES = ['ko', 'en'];
15
- /** `reviewProvider` is the host's default review provider — a copy of template-engine's
16
- * AI host table (types.spec.ts keeps it equal), shown as the review question's default. */
17
- export const AI_HOST_CHOICES = [
18
- { id: 'claude-code', label: 'Claude Code', nxAgent: 'claude', reviewProvider: 'claude' },
19
- { id: 'codex', label: 'Codex', nxAgent: 'codex', reviewProvider: 'gpt' },
20
- { id: 'cursor', label: 'Cursor', nxAgent: 'cursor', reviewProvider: 'cursor' },
21
- { id: 'gemini-cli', label: 'Gemini CLI', nxAgent: 'gemini', reviewProvider: 'gemini' },
22
- ];
23
- /** init's `reviewProvider` enum (types.spec.ts keeps it equal), with display labels. */
24
- export const REVIEW_PROVIDER_CHOICES = [
25
- { id: 'claude', label: 'Claude' },
26
- { id: 'gemini', label: 'Gemini' },
27
- { id: 'gpt', label: 'GPT' },
28
- { id: 'cursor', label: 'Cursor' },
29
- ];
30
- export const REVIEW_PROVIDER_VALUES = REVIEW_PROVIDER_CHOICES.map((provider) => provider.id);
31
- export const AI_HOST_VALUES = AI_HOST_CHOICES.map((host) => host.id);
32
- /** Used when nothing picked a host: a non-interactive run without --ai-hosts, or Enter. */
33
- export const DEFAULT_AI_HOSTS = ['claude-code'];
34
- /** The command each host's `--version` probe runs, measured
35
- * 2026-10-15: claude → "2.1.300 (Claude Code)", codex → "codex-cli 0.162.1", gemini →
36
- * "0.64.0". Cursor's installer links `cursor-agent` and a generic `agent`; only the
37
- * unambiguous name is probed, as Cursor's install guides name it. */
38
- export const AI_HOST_PROBES = {
39
- 'claude-code': 'claude',
40
- codex: 'codex',
41
- cursor: 'cursor-agent',
42
- 'gemini-cli': 'gemini',
43
- };
44
- export const PLUGIN_CHOICES = [
45
- {
46
- id: 'ponytail',
47
- label: 'ponytail',
48
- description: 'over-engineering review rules and skills — "makes your AI agent think like the laziest senior dev in the room"',
49
- license: 'MIT',
50
- host: 'claude-code',
51
- },
52
- ];
53
- export const PLUGIN_VALUES = PLUGIN_CHOICES.map((plugin) => plugin.id);
@@ -1,71 +0,0 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
- import { AI_HOST_CHOICES, PLUGIN_CHOICES } from "./cli/types.js";
5
- // Keep in sync with packages/nx-plugin/generators.json (same order).
6
- const AVAILABLE_GENERATORS = [
7
- 'doctor',
8
- 'init',
9
- 'add-integration',
10
- 'add-review',
11
- 'add-backend',
12
- 'add-agent-rules',
13
- 'add-github-templates',
14
- 'add-fsd-lite',
15
- 'page',
16
- 'widget',
17
- 'feature',
18
- 'entity',
19
- 'shared',
20
- 'install-integration',
21
- 'record-integration',
22
- 'allow-builds',
23
- ];
24
- /** This package's own version, read from its package.json — the nearest one up from this file
25
- * that is ours, which holds from source, from the tsc output and from the bundles alike. */
26
- function ownVersion() {
27
- let dir = dirname(fileURLToPath(import.meta.url));
28
- for (;;) {
29
- const manifest = join(dir, 'package.json');
30
- if (existsSync(manifest)) {
31
- const pkg = JSON.parse(readFileSync(manifest, 'utf-8'));
32
- if (pkg.name === 'create-nx-archweave' && pkg.version)
33
- return pkg.version;
34
- }
35
- const parent = dirname(dir);
36
- if (parent === dir)
37
- throw new Error('create-nx-archweave: its own package.json was not found');
38
- dir = parent;
39
- }
40
- }
41
- export const CLI_VERSION = ownVersion();
42
- const PLUGIN_LIST = PLUGIN_CHOICES.map((plugin) => `${plugin.id} (${AI_HOST_CHOICES.find((host) => host.id === plugin.host)?.label ?? plugin.host})`).join(', ');
43
- export function renderUsage() {
44
- return [
45
- 'Usage: create-nx-archweave <name> [options]',
46
- '',
47
- 'Options:',
48
- ' --preset <quick-start|solo|team> Preset that configures the generated workspace',
49
- ' --frontend <react-vite|next|none> Target frontend (none: a backend-only workspace — needs --backend)',
50
- ' --backend <express|nest|none> Backend app, wired with shared-types and enforced boundaries (asked if omitted)',
51
- ' --locale <ko|en> Generated content language (asked if omitted; quick-start: en)',
52
- ' --ai-hosts <list> AI coding tools, comma-separated: claude-code, codex, cursor, gemini-cli',
53
- ' (asked if omitted, showing which are installed; without a terminal: claude-code)',
54
- ' --plugins <list|none> Optional plugins for the picked AI hosts, comma-separated: ' + PLUGIN_LIST,
55
- ' (asked if omitted, for hosts installed here; without a terminal: none)',
56
- ' --review, --no-review Force AI PR review on/off (quick-start asks y/N; solo/team: on)',
57
- ' --review-provider <name> claude, gemini, gpt or cursor (asked when review is on; default: the first AI tool\'s)',
58
- ' --plugin-source <spec> Override the nx-archweave plugin package/version to install',
59
- ' --package-manager <name> npm, pnpm, yarn or bun (asked if omitted; without a terminal: the one that ran this command, else npm)',
60
- ' -h, --help Show this help',
61
- ' -v, --version Show the CLI version',
62
- '',
63
- 'Any required option left unset above is asked interactively.',
64
- '',
65
- 'Available generators (once installed):',
66
- ` ${AVAILABLE_GENERATORS.join(', ')}`,
67
- ].join('\n');
68
- }
69
- export function renderVersion() {
70
- return CLI_VERSION;
71
- }