create-nx-archweave 0.1.1 → 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.
package/dist/cli/plan.js DELETED
@@ -1,434 +0,0 @@
1
- import { AI_HOST_CHOICES } from "./types.js";
2
- import { CLI_VERSION } from "../render-usage.js";
3
- /** The plugin release this CLI was built and tested with — caret-pinned so a later
4
- * breaking 0.x plugin never pairs with an older CLI (release prep, 2026-10-17). */
5
- const DEFAULT_PLUGIN_SOURCE = `@clearlydev/nx-archweave@^${CLI_VERSION}`;
6
- /** E2E-CI finding (2026-10-08): the react-monorepo layout differed between CI and
7
- * local runs — first blamed on a 23.2.0 → 23.2.1 minor bump, later traced to the
8
- * agent detection documented on createWorkspaceSourceArgs (2026-10-13). The CLI pins the exact version its own E2E
9
- * gate validated either way — bumping this constant is done together with a green
10
- * `node tools/e2e-create.mjs` run for both PMs. */
11
- const VALIDATED_CREATE_NX_WORKSPACE_VERSION = '23.3.0';
12
- /** npm 10 (bundled with Node 22) crashes inside create-nx-workspace's own install
13
- * of the react template's dependency tree — an arborist bug in its peer-set
14
- * resolution, reproduced on every npm 10 release up to 10.9.9 and absent in npm 11
15
- * (E2E CI finding, 2026-10-13). The next preset installs fine on npm 10
16
- * (2026-10-14), so only react-vite + npm is affected: package-manager.ts never lets a
17
- * known npm 10 pick it, and the create step keeps this hint for the case the version
18
- * could not be read. */
19
- export const NPM_10_CREATE_WORKSPACE_HINT = "If the npm error above is \"Cannot read properties of null (reading 'edgesOut')\", that is a known npm 10 bug " +
20
- 'with the current Nx workspace dependencies — upgrade npm (npm install -g npm@11), or, if pnpm is installed, re-run with --package-manager=pnpm.';
21
- /** yarn 1 cannot resolve the `workspace:*` packages Nx's react template declares
22
- * (measured 2026-10-15); the same guard and fallback as npm 10 above. */
23
- export const YARN_1_CREATE_WORKSPACE_HINT = 'If the yarn error above is "Couldn\'t find package …@workspace:*", that is yarn 1 (classic), which cannot resolve ' +
24
- "the workspace:* packages in Nx's react template — use yarn 2+ (corepack enable, then yarn set version stable) or re-run with another package manager.";
25
- /** `nx` through the workspace's own manager. npm never gets a bare `nx` (its exec needs `--`). */
26
- function nxArgs(pm) {
27
- return pm === 'npm' ? ['exec', 'nx', '--'] : ['nx'];
28
- }
29
- /** `pnpm nx g` / `npm exec nx -- g` — how the user runs a generator in the new workspace. */
30
- export function nxGenerateCommand(pm) {
31
- return `${pm} ${nxArgs(pm).join(' ')} g`;
32
- }
33
- /** A dev dependency at the workspace root. pnpm: -w (it refuses a root install without
34
- * it — ERR_PNPM_ADDING_TO_ROOT). npm: plain root install, never -w (it means
35
- * --workspace=<name> there). bun: plain root add (measured, bun 1.4). yarn: classic
36
- * needs -W, berry rejects it — so the created workspace decides (`.yarnrc.yml` / a
37
- * `__metadata:` lockfile mark berry). */
38
- function addDevArgs(pm, spec, ctx) {
39
- if (pm === 'pnpm')
40
- return ['add', '-D', '-w', spec];
41
- if (pm === 'npm')
42
- return ['install', '-D', spec];
43
- if (pm === 'bun')
44
- return ['add', '-d', spec];
45
- return ctx && isYarnBerry(ctx) ? ['add', '-D', spec] : ['add', '-D', '-W', spec];
46
- }
47
- export function isYarnBerry(ctx) {
48
- return ctx.readFile('.yarnrc.yml') !== undefined || /^__metadata:/m.test(ctx.readFile('yarn.lock') ?? '');
49
- }
50
- /** yarn berry refuses to write a lockfile when it detects CI (YN0028, measured), which
51
- * would fail create-nx-workspace's own install in CI and agent runs. Classic ignores it. */
52
- const YARN_PLAN_ENV = { YARN_ENABLE_IMMUTABLE_INSTALLS: 'false' };
53
- /**
54
- * Maps our `frontend` option to what `create-nx-workspace` builds the workspace
55
- * from: react-vite → `--template=nrwl/react-template`, next → `--preset=next`.
56
- *
57
- * Why react-vite names the template (E2E CI finding, 2026-10-13): with
58
- * `--preset=react-monorepo`, create-nx-workspace 23.2 builds the fullstack
59
- * GitHub template (apps/shop + an Express apps/api) ONLY when it detects an AI
60
- * agent in the environment (CLAUDECODE, CLAUDE_CODE, OPENCODE, CODEX_THREAD_ID,
61
- * ...); from a plain terminal — and in CI — the same flags run the classic preset
62
- * (apps/<name> only, no api, a different package layout). Every measurement this
63
- * CLI and its docs rely on was taken on the template, so naming it makes every
64
- * user get that workspace. The template flow downloads from github.com.
65
- *
66
- * `--preset=next` needs no further flag with `--interactive=false` — Nx fills
67
- * every unspecified option with its own default (non-interactive probe against
68
- * create-nx-workspace v23.2.1, 2026-09-16).
69
- */
70
- function createWorkspaceSourceArgs(frontend, backend) {
71
- if (frontend === 'react-vite')
72
- return ['--template=nrwl/react-template'];
73
- if (frontend === 'next')
74
- return ['--preset=next'];
75
- // Backend-only: Nx's own preset builds the api, named the way every other path names
76
- // it (measured 2026-10-16, 23.2.0: express → apps/api as a package.json project,
77
- // nest → apps/api with a project.json; both with apps/api-e2e).
78
- return [`--preset=${backend}`, '--appName=api'];
79
- }
80
- function buildNextSteps(input) {
81
- const lines = [
82
- `cd ${input.name}`,
83
- ...(input.backend !== 'none'
84
- ? [`${input.packageManager} install (links the new shared-types workspace package — do this before the first commit)`]
85
- : []),
86
- 'git add -A && git commit -m "chore: initial commit"',
87
- '(create-nx-archweave never commits for you — review what the init report above generated first.)',
88
- ];
89
- if (input.review === false)
90
- return lines;
91
- // Deliberately vague about WHICH secret: the init report above prints the
92
- // authoritative secret name for the chosen provider (owned by
93
- // template-engine/review) — repeating a specific name here would duplicate
94
- // that content knowledge into the CLI.
95
- if (input.review === true) {
96
- lines.push('AI PR review is enabled: add the review secret named in the init report above to the repository before opening a pull request.', 'Verify the default branch matches what the init report above expects before relying on branch protection.');
97
- }
98
- else {
99
- lines.push('If the init report above shows AI PR review enabled, add the review secret it names to the repository before opening a pull request, and verify the default branch first.');
100
- }
101
- return lines;
102
- }
103
- /**
104
- * Pure: builds the ordered command list plus the next-steps guidance text
105
- * (step 5). No I/O — `cli/execute.ts` is what actually runs
106
- * these commands.
107
- */
108
- export function buildPlan(input) {
109
- const pluginSource = input.pluginSource ?? DEFAULT_PLUGIN_SOURCE;
110
- const workspaceSource = createWorkspaceSourceArgs(input.frontend, input.backend);
111
- // One flag per host — create-nx-workspace rejects a comma list (measured, 23.2.0). The
112
- // react template ships every host's files anyway; the flag decides them for next.
113
- const aiAgentArgs = input.aiHosts.map((host) => `--aiAgents=${AI_HOST_CHOICES.find((choice) => choice.id === host)?.nxAgent ?? host}`);
114
- const pm = input.packageManager;
115
- // npm's -w means --workspace=<name>, never "workspace root" — npm installs
116
- // at the root by default, so the flag is simply absent there.
117
- const nxPrefix = nxArgs(pm);
118
- const initArgs = [
119
- ...nxPrefix,
120
- 'g',
121
- '@clearlydev/nx-archweave:init',
122
- `--preset=${input.preset}`,
123
- `--frontend=${input.frontend}`,
124
- `--backend=${input.backend}`,
125
- `--locale=${input.locale}`,
126
- `--aiHosts=${input.aiHosts.join(',')}`,
127
- `--packageManager=${pm}`,
128
- // Step 2 (the plugin install) always dirties the fresh workspace's tree
129
- // (package.json + lockfile), so init's clean-tree refusal would otherwise
130
- // always fire here. This is orchestration knowledge, not policy: the CLI
131
- // knows what its own previous step did.
132
- '--allowDirty=true',
133
- ];
134
- if (input.review !== undefined) {
135
- initArgs.push(`--review=${input.review}`);
136
- }
137
- if (input.reviewProvider !== undefined && input.review !== false) {
138
- initArgs.push(`--reviewProvider=${input.reviewProvider}`);
139
- }
140
- // Decision (i): the CLI just created this workspace, so init writes the ESLint rules for
141
- // real instead of printing snippets — the scope constraints (backend axis) and the FSD
142
- // layer rule alike.
143
- initArgs.push('--applyEslint=true');
144
- const steps = [
145
- {
146
- id: 'create-workspace',
147
- description: `Create the Nx workspace "${input.name}"`,
148
- // pnpm launches with its own dlx; npm, yarn and bun through npx (always present
149
- // with Node, and how every yarn and bun row of the measurements was produced).
150
- command: pm === 'pnpm' ? 'pnpm' : 'npx',
151
- args: pm === 'pnpm'
152
- ? [
153
- 'dlx',
154
- `create-nx-workspace@${VALIDATED_CREATE_NX_WORKSPACE_VERSION}`,
155
- input.name,
156
- ...workspaceSource,
157
- ...aiAgentArgs,
158
- '--pm=pnpm',
159
- '--nxCloud=skip',
160
- '--interactive=false',
161
- ]
162
- : [
163
- '-y',
164
- `create-nx-workspace@${VALIDATED_CREATE_NX_WORKSPACE_VERSION}`,
165
- input.name,
166
- ...workspaceSource,
167
- ...aiAgentArgs,
168
- `--pm=${pm}`,
169
- '--nxCloud=skip',
170
- '--interactive=false',
171
- ],
172
- cwd: null,
173
- ...(input.frontend === 'react-vite' && pm === 'npm' ? { failureHint: NPM_10_CREATE_WORKSPACE_HINT } : {}),
174
- ...(input.frontend === 'react-vite' && pm === 'yarn' ? { failureHint: YARN_1_CREATE_WORKSPACE_HINT } : {}),
175
- },
176
- {
177
- id: 'install-plugin',
178
- description: `Install ${pluginSource}`,
179
- command: pm,
180
- args: addDevArgs(pm, pluginSource),
181
- cwd: input.name,
182
- // yarn's flags depend on its flavor, which only the created workspace tells.
183
- ...(pm === 'yarn' ? { finalizeArgs: (ctx) => addDevArgs(pm, pluginSource, ctx) } : {}),
184
- },
185
- // Backend-only: the preset already built apps/api, so neither official step runs.
186
- ...(input.backend === 'none' || input.frontend === 'none'
187
- ? []
188
- : backendSteps(input.backend, input.name, pm, `${pm} ${initArgs.join(' ')}`)),
189
- {
190
- id: 'run-init',
191
- description: 'Run the nx-archweave init generator',
192
- command: pm,
193
- args: initArgs,
194
- cwd: input.name,
195
- },
196
- ...(pm === 'pnpm' ? [settleBuildApprovalsStep(input.name)] : []),
197
- ...(pm === 'bun' ? [listBlockedScriptsStep(input.name)] : []),
198
- ...(input.plugins ?? []).map((plugin) => installPluginStep(plugin, input.name, pm)),
199
- ];
200
- return {
201
- steps,
202
- nextSteps: buildNextSteps(input),
203
- ...(pm === 'pnpm' ? { env: PNPM_PLAN_ENV } : {}),
204
- ...(pm === 'yarn' ? { env: YARN_PLAN_ENV } : {}),
205
- };
206
- }
207
- /** Last of all: install-integration runs the host's own
208
- * installer, verifies the result live and records it — at project scope, so the plugin is
209
- * recorded in the workspace rather than in the user's settings. Optional: the workspace is
210
- * complete without it. The generator reports a failed install itself and still exits 0, so
211
- * the note points at its report; a non-zero exit (it crashed) gets the retry command. */
212
- function installPluginStep(plugin, workspaceName, pm) {
213
- const hostLabel = AI_HOST_CHOICES.find((choice) => choice.id === plugin.host)?.label ?? plugin.host;
214
- const args = [
215
- ...nxArgs(pm),
216
- 'g',
217
- '@clearlydev/nx-archweave:install-integration',
218
- `--tool=${plugin.id}`,
219
- `--target=${plugin.host}`,
220
- '--scope=project',
221
- '--apply',
222
- ];
223
- return {
224
- id: 'install-ai-plugin',
225
- description: `Install the ${plugin.label} plugin for ${hostLabel} (project scope)`,
226
- command: pm,
227
- args,
228
- cwd: workspaceName,
229
- optional: true,
230
- successNote: () => `the ${plugin.label} report above says whether it was installed and recorded; if not, it names what to do next.`,
231
- failureHint: `The workspace itself is complete. To retry inside "${workspaceName}": ${pm} ${args.join(' ')}`,
232
- };
233
- }
234
- /** pnpm 11 turned `strictDepBuilds` on: an install that meets a dependency build
235
- * script with no `allowBuilds` decision writes a placeholder and fails
236
- * (ERR_PNPM_IGNORED_BUILDS) — inside create-nx-workspace's own install of the next
237
- * preset, and in any install an Nx generator runs later. Every pnpm step runs with
238
- * it off (pnpm 11 reads `pnpm_config_<snake_case>` variables), so a skipped script
239
- * stays skipped; the final settle step records those skips as decisions. pnpm 10
240
- * never fails here, so this is a no-op there. */
241
- const PNPM_PLAN_ENV = { pnpm_config_strict_dep_builds: 'false' };
242
- /** The value pnpm 11 writes under `allowBuilds` for a dependency whose build script
243
- * it skipped without a decision (pnpm 11.9 `handleIgnoredBuilds`). Left in place,
244
- * the next plain `pnpm install` fails on it again. */
245
- export const PNPM_PENDING_BUILD_PLACEHOLDER = 'set this to true or false';
246
- /** Package names pnpm left awaiting a build decision in pnpm-workspace.yaml — the
247
- * `allowBuilds` entries still holding the placeholder, in the block form pnpm's
248
- * writer uses and the single-line flow form it keeps when the file had
249
- * `allowBuilds: {}`. Keys may contain colons (`foo@file:../foo`), so a line is
250
- * anchored on the placeholder, not split at the first colon; comments never count.
251
- * The file is pnpm's own output, and an unexpected shape just yields no names (the
252
- * user then sees pnpm's own ERR_PNPM_IGNORED_BUILDS guidance on the next install). */
253
- export function pendingBuildDecisions(workspaceYaml) {
254
- const placeholder = PNPM_PENDING_BUILD_PLACEHOLDER;
255
- const entry = new RegExp(`^(['"]?)(.+)\\1:\\s+(['"]?)${placeholder}\\3$`);
256
- const pendingKey = (text) => {
257
- const trimmed = text.trim();
258
- if (trimmed.startsWith('#'))
259
- return undefined;
260
- return entry.exec(trimmed)?.[2];
261
- };
262
- const names = [];
263
- let inAllowBuilds = false;
264
- for (const line of workspaceYaml.split(/\r?\n/)) {
265
- if (/^\s*#/.test(line))
266
- continue; // a comment, even at column 0, neither ends nor joins a block
267
- if (/^\S/.test(line)) {
268
- inAllowBuilds = /^allowBuilds:\s*$/.test(line);
269
- const flow = /^allowBuilds:\s*\{(.*)\}\s*$/.exec(line);
270
- if (flow) {
271
- for (const item of flow[1].split(',')) {
272
- const name = pendingKey(item);
273
- if (name !== undefined)
274
- names.push(name);
275
- }
276
- }
277
- continue;
278
- }
279
- if (!inAllowBuilds)
280
- continue;
281
- const name = pendingKey(line);
282
- if (name !== undefined)
283
- names.push(name);
284
- }
285
- return names;
286
- }
287
- /** Last pnpm step: every build script pnpm skipped during the flow (and wrote down
288
- * as pending) is recorded as NOT allowed, through pnpm's own non-interactive
289
- * `approve-builds !<pkg>` — the conservative choice (no third-party install script
290
- * runs without the user's say), and the one Nx's own templates make for @swc/core
291
- * and esbuild. Without it the user's first `pnpm install` fails on the placeholders. */
292
- function settleBuildApprovalsStep(workspaceName) {
293
- return {
294
- id: 'settle-build-approvals',
295
- description: 'Record the dependency build scripts pnpm skipped as not allowed (pnpm-workspace.yaml allowBuilds)',
296
- command: 'pnpm',
297
- args: ['approve-builds'],
298
- cwd: workspaceName,
299
- finalizeArgs: (ctx) => {
300
- const yaml = ctx.readFile('pnpm-workspace.yaml');
301
- const pending = yaml === undefined ? [] : pendingBuildDecisions(yaml);
302
- if (pending.length === 0)
303
- return { skip: 'no dependency build script is awaiting a decision' };
304
- return ['approve-builds', ...pending.map((name) => `!${name}`)];
305
- },
306
- // Once denied, pnpm 11.9's `approve-builds` no longer lists a package (it only
307
- // offers what is still pending), so the way back is the file plus a rebuild —
308
- // which the plugin's allow-builds generator (installed by an earlier step) does.
309
- successNote: (args) => {
310
- const names = args.filter((arg) => arg.startsWith('!')).map((arg) => arg.slice(1));
311
- return (`pnpm skipped the build scripts of ${names.join(', ')}; they stay disabled (allowBuilds: false in pnpm-workspace.yaml). ` +
312
- `To allow one later, run inside "${workspaceName}": pnpm nx g @clearlydev/nx-archweave:allow-builds --packages=<package> --apply`);
313
- },
314
- failureHint: 'The workspace itself is complete — every earlier step finished; only recording the build decisions failed. ' +
315
- 'Inside it, set each allowBuilds entry in pnpm-workspace.yaml that still reads "set this to true or false" to true or false yourself, then run pnpm install.',
316
- };
317
- }
318
- /** Last bun step: bun blocks the install scripts of dependencies outside its trusted
319
- * list without a word during the install (measured: @swc/core's postinstall in Nx's react
320
- * template). Read-only — `bun pm untrusted` prints bun's own list, and exits 0 with
321
- * "Found 0 untrusted dependencies" when nothing was blocked (measured, bun 1.4.2). The
322
- * scripts stay blocked; the note names the one-line way back. */
323
- function listBlockedScriptsStep(workspaceName) {
324
- return {
325
- id: 'list-blocked-scripts',
326
- description: 'List the dependency install scripts bun blocked (bun pm untrusted)',
327
- command: 'bun',
328
- args: ['pm', 'untrusted'],
329
- cwd: workspaceName,
330
- successNote: () => 'bun blocks the install scripts of dependencies it does not trust; the list above (bun pm untrusted) shows the ones it blocked here — they stay blocked. ' +
331
- `To allow one later, run inside "${workspaceName}": bun pm trust <package>`,
332
- failureHint: 'The workspace itself is complete — every earlier step finished; only listing the blocked install scripts failed. ' +
333
- `Inside "${workspaceName}", run bun pm untrusted to see them.`,
334
- };
335
- }
336
- /** The two official-generator steps (3 and 4). Both need
337
- * execution-time facts: the created workspace's nx version (so @nx/* versions
338
- * can never drift from nx's) and the frontend app's directory name. */
339
- function backendSteps(backend, workspaceName, pm, initCommand) {
340
- const nxG = nxGenerateCommand(pm);
341
- // Measured realities (probed 2026-10-05 against create-nx-workspace
342
- // v23.2.x): the react-vite path builds Nx's fullstack template (named
343
- // explicitly — see createWorkspaceSourceArgs), which ALREADY ships an Express
344
- // apps/api — so for express the two official
345
- // steps are satisfied-by-template (skip), and for nest the api slot is
346
- // taken (clear refusal instead of a collision). The next preset (re-measured
347
- // 2026-10-16, Nx 23.2.0) builds apps/<name> + apps/<name>-e2e with no api, so
348
- // both steps run there as on a template without one. A workspace without apps/
349
- // (a layout neither path builds today) still stops with the manual path.
350
- const templateApi = (ctx) => ctx.listDir('apps').includes('api');
351
- const standaloneError = 'this workspace has no apps/ directory — CLI backend wiring needs the frontend under apps/. ' +
352
- 'Create the api app with the official @nx generator, then run ' +
353
- `\`${nxG} @clearlydev/nx-archweave:add-backend\` yourself. ` +
354
- `Finish setup inside "${workspaceName}" with: ${initCommand}`;
355
- const nestConflictError = "the workspace template already ships an Express apps/api — re-run with --backend=express " +
356
- 'to wire it, or remove apps/api and scaffold Nest yourself, then run add-backend. ' +
357
- `Either way, finish setup inside "${workspaceName}" with: ${initCommand}`;
358
- return [
359
- {
360
- id: 'install-backend-plugin',
361
- description: `Install @nx/${backend} (pinned to the workspace's nx version)`,
362
- command: pm,
363
- args: addDevArgs(pm, `@nx/${backend}`),
364
- cwd: workspaceName,
365
- finalizeArgs: (ctx) => {
366
- if (ctx.listDir('apps').length === 0)
367
- return { error: standaloneError };
368
- if (templateApi(ctx)) {
369
- return backend === 'express'
370
- ? { skip: 'the workspace template already ships the Express api' }
371
- : { error: nestConflictError };
372
- }
373
- const raw = ctx.readFile('package.json');
374
- if (raw === undefined)
375
- return { error: "could not read the created workspace's package.json to pin the @nx version" };
376
- try {
377
- const parsed = JSON.parse(raw);
378
- const nxVersion = parsed.devDependencies?.['nx'] ?? parsed.dependencies?.['nx'];
379
- if (!nxVersion)
380
- return { error: "nx version not found in the created workspace's package.json" };
381
- return addDevArgs(pm, `@nx/${backend}@${nxVersion}`, ctx);
382
- }
383
- catch {
384
- return { error: "could not parse the created workspace's package.json" };
385
- }
386
- },
387
- },
388
- {
389
- id: 'create-api-app',
390
- description: `Create the api app with the official @nx/${backend} generator`,
391
- command: pm,
392
- // Since Nx 20 the positional is a directory relative to the workspace root —
393
- // a bare `api` would land at the root instead of beside the frontend.
394
- args: [...nxArgs(pm), 'g', `@nx/${backend}:application`, 'apps/api'],
395
- cwd: workspaceName,
396
- finalizeArgs: (ctx) => {
397
- const all = ctx.listDir('apps');
398
- if (all.length === 0)
399
- return { error: standaloneError };
400
- if (all.includes('api')) {
401
- return backend === 'express'
402
- ? { skip: 'the workspace template already ships the Express api' }
403
- : { error: nestConflictError };
404
- }
405
- const apps = all.filter((name) => name !== 'api' && name !== 'e2e' && !name.endsWith('-e2e'));
406
- if (apps.length === 0)
407
- return { error: 'no frontend app directory found under apps/ in the created workspace' };
408
- if (apps.length > 1)
409
- return { error: `multiple app directories found under apps/ (${apps.join(', ')}) — cannot pick --frontendProject` };
410
- // The template registers SCOPED project names (@org/web) while the
411
- // directory stays apps/web — --frontendProject needs the project name
412
- // (E2E-CI finding: the directory name made nx fail with "Cannot find
413
- // configuration"). project.json name, then package.json name, then dir.
414
- const frontendProject = (() => {
415
- for (const file of [`apps/${apps[0]}/project.json`, `apps/${apps[0]}/package.json`]) {
416
- const raw = ctx.readFile(file);
417
- if (raw === undefined)
418
- continue;
419
- try {
420
- const name = JSON.parse(raw).name;
421
- if (typeof name === 'string' && name.length > 0)
422
- return name;
423
- }
424
- catch {
425
- // unreadable — fall through
426
- }
427
- }
428
- return apps[0];
429
- })();
430
- return [...nxArgs(pm), 'g', `@nx/${backend}:application`, 'apps/api', `--frontendProject=${frontendProject}`];
431
- },
432
- },
433
- ];
434
- }
@@ -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
- }