@simple-auth-kit/cli 1.0.1 → 1.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/README.md CHANGED
@@ -21,7 +21,9 @@ npx @simple-auth-kit/cli init # fresh setup: guided "what
21
21
  npx @simple-auth-kit/cli add <combo> [--workspaces] # install a specific combo
22
22
  npx @simple-auth-kit/cli add # guided picker, same flow as init (no config write)
23
23
  npx @simple-auth-kit/cli update [--check] # re-installs whatever add last recorded — no args needed
24
- npx @simple-auth-kit/cli # full command/flag reference + available combos
24
+ npx @simple-auth-kit/cli diff # shows the actual content diff for every file that differs from the registry (api combos only)
25
+ npx @simple-auth-kit/cli # bare, in a real terminal: same guided picker as `add`
26
+ npx @simple-auth-kit/cli --help # full command/flag reference + available combos
25
27
  ```
26
28
 
27
29
  `--into <path>` targets any directory (defaults to the current one). In a real terminal,
@@ -29,15 +31,37 @@ without `--force` or `--check`, a file that's changed locally since install prom
29
31
  per file — before it's overwritten; outside a TTY (CI/scripts) it's left alone by default and
30
32
  reported at the end, same as `--force` always applying and `--check` never writing anything.
31
33
 
34
+ **Dependencies are installed for you**, shadcn-`add`-style — no separate `npm install` step.
35
+ The package manager is auto-detected from a lockfile already in the target directory (npm if
36
+ none found); override it with `--pm <npm|pnpm|yarn|bun>`, or skip the install entirely with
37
+ `--skip-install` if you'd rather review `package.json` first.
38
+
32
39
  ## Available combos
33
40
 
34
- | Kind | Combo | Installs as |
35
- |---|---|---|
36
- | `api` | `nestjs-prisma`, `nestjs-drizzle`, `express-prisma`, `express-drizzle` | merged into `src/lib/auth` of an existing project (the Prisma/Drizzle config + schema land at the project root instead — see below) |
37
- | `admin` | `admin-nextjs`, `admin-react` | a whole new standalone app, scaffolded at the target (refuses a non-empty target unless `--force`) |
38
- | `mobile` | `mobile-expo`, `mobile-bare-rn` | a whole new standalone app, scaffolded at the target (same non-empty-target rule) |
41
+ Add `--workspaces` to any of these for the workspaces variant.
42
+
43
+ **`api`** merged into `src/lib/auth` of an existing project (the Prisma/Drizzle config +
44
+ schema land at the project root instead see below):
45
+ ```bash
46
+ npx @simple-auth-kit/cli add nestjs-prisma --into .
47
+ npx @simple-auth-kit/cli add nestjs-drizzle --into .
48
+ npx @simple-auth-kit/cli add express-prisma --into .
49
+ npx @simple-auth-kit/cli add express-drizzle --into .
50
+ ```
51
+
52
+ **`admin`** — a whole new standalone app, scaffolded at the target (refuses a non-empty target
53
+ unless `--force`):
54
+ ```bash
55
+ npx @simple-auth-kit/cli add admin-nextjs --into ./admin
56
+ npx @simple-auth-kit/cli add admin-react --into ./admin
57
+ ```
39
58
 
40
- Add `--workspaces` to any `api`/`admin`/`mobile` combo for the workspaces variant.
59
+ **`mobile`** a whole new standalone app, scaffolded at the target (same non-empty-target
60
+ rule):
61
+ ```bash
62
+ npx @simple-auth-kit/cli add mobile-expo --into ./mobile
63
+ npx @simple-auth-kit/cli add mobile-bare-rn --into ./mobile
64
+ ```
41
65
 
42
66
  ## Your own database models are safe
43
67
 
package/lib/copy.ts CHANGED
@@ -48,6 +48,22 @@ export interface CopyOptions {
48
48
  * deleting anything on disk — for a "what would change" check before actually applying it.
49
49
  */
50
50
  dryRun?: boolean;
51
+ /**
52
+ * When set, every file whose on-disk content differs from what's about to be written gets a
53
+ * `{path, oldContent, newContent}` entry pushed onto this array — for rendering an actual
54
+ * diff (see `cmdDiff`), not just a filename list. Typically paired with `force: true` and
55
+ * `dryRun: true` so a locally-modified file's difference is captured too, not silently
56
+ * skipped the way a real install would.
57
+ */
58
+ collectDiffs?: DiffEntry[];
59
+ }
60
+
61
+ export interface DiffEntry {
62
+ /** Path relative to destRoot, POSIX separators — same form as CopyResult's arrays. */
63
+ path: string;
64
+ /** null when the file doesn't exist on disk yet (a brand-new file). */
65
+ oldContent: string | null;
66
+ newContent: string;
51
67
  }
52
68
 
53
69
  export interface CopyResult {
@@ -111,6 +127,7 @@ export async function copyOneFile(srcPath: string, destPath: string, destRoot: s
111
127
 
112
128
  result.updated.push(rel);
113
129
  result.manifest[rel] = newHash;
130
+ opts.collectDiffs?.push({ path: rel, oldContent: existing, newContent: content });
114
131
  if (opts.dryRun) return;
115
132
 
116
133
  await mkdir(dirname(destPath), { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simple-auth-kit/cli",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "license": "MIT",
5
5
  "description": "simple-auth-kit add <combo> — copies registry source (backend combos, admin consoles, mobile apps) into a consumer repo, shadcn-CLI-style. Nothing is ever installed as a runtime dependency of the consumer.",
6
6
  "bin": {
@@ -20,6 +20,7 @@
20
20
  "prepack": "node scripts/bundle-registry.mjs"
21
21
  },
22
22
  "dependencies": {
23
+ "diff": "^9.0.0",
23
24
  "prompts": "^2.4.2",
24
25
  "tsx": "^4.23.11"
25
26
  },
@@ -16,11 +16,13 @@
16
16
  // workspaces support — or reads the same choices from --kind/--framework/--workspaces for
17
17
  // non-interactive/scripted use.
18
18
  import { existsSync } from "node:fs";
19
+ import { spawnSync } from "node:child_process";
19
20
  import { basename, dirname, join, relative, resolve } from "node:path";
20
21
  import { access, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
21
22
  import { fileURLToPath } from "node:url";
23
+ import { createTwoFilesPatch } from "diff";
22
24
  import prompts from "prompts";
23
- import { copyDir, copyOneFile, CopyResult, NEVER_COPY, pruneRemovedFiles, SCAFFOLD_NEVER_COPY, sha256, toPosix } from "./lib/copy.js";
25
+ import { copyDir, copyOneFile, CopyOptions, CopyResult, DiffEntry, NEVER_COPY, pruneRemovedFiles, SCAFFOLD_NEVER_COPY, sha256, toPosix } from "./lib/copy.js";
24
26
  import { reconcileManifest, renameNative, type NativeIdentity } from "./lib/rename-native.js";
25
27
 
26
28
  const CLI_DIR = dirname(fileURLToPath(import.meta.url));
@@ -41,7 +43,7 @@ const KIND_LABELS: Record<Kind, string> = { api: "API (backend)", admin: "Admin
41
43
  const KIND_FRAMEWORK_NOUN: Record<Kind, string> = { api: "API stack", admin: "admin framework", mobile: "mobile framework" };
42
44
 
43
45
  /** Flags that take no value. Everything else consumes the next argv entry. */
44
- const BOOLEAN_FLAGS = new Set(["workspaces", "force", "check", "config-only"]);
46
+ const BOOLEAN_FLAGS = new Set(["workspaces", "force", "check", "config-only", "skip-install", "help"]);
45
47
 
46
48
  interface SimpleAuthKitConfig {
47
49
  path: string;
@@ -178,6 +180,47 @@ async function resolveForcePaths(runDry: () => Promise<{ result: CopyResult }>,
178
180
  return new Set(picked ?? []);
179
181
  }
180
182
 
183
+ const PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"] as const;
184
+ type PackageManager = (typeof PACKAGE_MANAGERS)[number];
185
+
186
+ /** Detected from whichever lockfile is already sitting in targetRoot — npm if none match,
187
+ * matching what a fresh `npm install`-first project would have. Overridden by `--pm` when
188
+ * given, so e.g. a first-ever install (no lockfile yet to detect from) can still pick pnpm. */
189
+ function detectPackageManager(targetRoot: string, flags: Record<string, string | true>): PackageManager {
190
+ const requested = flagString(flags.pm);
191
+ if (requested) {
192
+ if ((PACKAGE_MANAGERS as readonly string[]).includes(requested)) return requested as PackageManager;
193
+ console.error(`--pm "${requested}" isn't one of ${PACKAGE_MANAGERS.join(", ")} — falling back to auto-detection.`);
194
+ }
195
+ if (existsSync(join(targetRoot, "pnpm-lock.yaml"))) return "pnpm";
196
+ if (existsSync(join(targetRoot, "yarn.lock"))) return "yarn";
197
+ if (existsSync(join(targetRoot, "bun.lockb")) || existsSync(join(targetRoot, "bun.lock"))) return "bun";
198
+ return "npm";
199
+ }
200
+
201
+ /**
202
+ * Actually runs the dependency install, shadcn-`add`-style, instead of just printing the
203
+ * command and leaving it to the consumer. `deps` empty means "just install whatever's in
204
+ * package.json" (the scaffold-mode case — dependencies are already declared, nothing to name).
205
+ * Skipped entirely under `--skip-install`, or when `deps` is non-empty but there's nothing new
206
+ * to add (an update that touched no files has nothing worth re-installing for). `--pm
207
+ * <npm|pnpm|yarn|bun>` picks the tool explicitly instead of auto-detecting it from a lockfile.
208
+ */
209
+ function installDependencies(targetRoot: string, deps: string[], flags: Record<string, string | true>): void {
210
+ if (flags["skip-install"] === true) return;
211
+
212
+ const pm = detectPackageManager(targetRoot, flags);
213
+ // "install everything in package.json" (zero deps named) is the same bare verb across all
214
+ // four; naming specific packages is "install <pkgs>" for npm, "add <pkgs>" for the others.
215
+ const args = deps.length ? [pm === "npm" ? "install" : "add", ...deps] : ["install"];
216
+
217
+ console.log(`\nInstalling dependencies (${pm})...`);
218
+ const result = spawnSync(pm, args, { cwd: targetRoot, stdio: "inherit" });
219
+ if (result.status !== 0) {
220
+ console.error(`\n${pm} install exited with an error — run it yourself: cd ${targetRoot} && ${pm} ${args.join(" ")}`);
221
+ }
222
+ }
223
+
181
224
  /**
182
225
  * The fresh-setup entry point: writes .simple-auth-kit.json (so merge-mode combos have an
183
226
  * install path/alias to anchor to), then launches the same guided "what do you need"
@@ -264,28 +307,28 @@ const ORM_LAYOUTS: { configFile: string; dataDir: string; generatedClientImport?
264
307
  { configFile: "drizzle.config.ts", dataDir: "drizzle" },
265
308
  ];
266
309
 
267
- /** "merge" install — core + shared + variant merged into an existing project at
268
- * <targetRoot>/<installPath>, with the core import alias rewritten. One exception: an ORM combo's
269
- * config file and data directory (see ORM_LAYOUTS) land at the *project root* instead — e.g. the
270
- * Prisma combos' `prisma.config.ts` + `prisma/`, or the Drizzle combos' `drizzle.config.ts` +
271
- * `drizzle/`.
272
- *
273
- * Both are still copied with `destRoot` (not targetRoot) as the manifest-key root, which makes
274
- * `copyOneFile` compute "../"-relative keys for them — deliberate, not an oversight: those keys
275
- * can never collide with a real destRoot-relative key, so `pruneRemovedFiles` below correctly
276
- * removes an ORM folder a pre-migration install left nested inside destRoot, without disturbing
277
- * anything else's keys. */
278
- async function installMerge(comboName: string, combo: ComboEntry, variant: string, registry: Registry, targetRoot: string, flags: Record<string, string | true>) {
310
+ interface MergePlan {
311
+ destRoot: string;
312
+ installPath: string;
313
+ alias: string;
314
+ sharedDir: string;
315
+ variantDir: string;
316
+ orm: (typeof ORM_LAYOUTS)[number] | null;
317
+ skipFromShared: Set<string>;
318
+ skipFromVariant: Set<string>;
319
+ extraRewrites: { from: string; to: string }[] | undefined;
320
+ config: SimpleAuthKitConfig;
321
+ previous: Record<string, string>;
322
+ }
323
+
324
+ /** Everything about a merge-mode install that doesn't depend on force/dryRun/forcePaths —
325
+ * computed once, shared by installMerge's real run and cmdDiff's read-only one. */
326
+ async function buildMergePlan(combo: ComboEntry, variant: string, targetRoot: string, flags: Record<string, string | true>, previous: Record<string, string>): Promise<MergePlan> {
279
327
  const config = await loadConfig(targetRoot);
280
328
  const installPath = flagString(flags.path) ?? config.path;
281
329
  const alias = flagString(flags.alias) ?? config.alias;
282
330
  const destRoot = resolve(targetRoot, installPath);
283
331
 
284
- const lock = await loadLock(targetRoot);
285
- const previous = lock.files ?? {};
286
- const force = flags.force === true;
287
- const checkOnly = flags.check === true;
288
-
289
332
  const comboDir = join(REGISTRY_ROOT, combo.dir);
290
333
  const sharedDir = join(comboDir, combo.sharedDir ?? "shared");
291
334
  const variantDir = join(comboDir, combo.variantsDir ?? "variants", variant);
@@ -308,37 +351,66 @@ async function installMerge(comboName: string, combo: ComboEntry, variant: strin
308
351
  ]
309
352
  : undefined;
310
353
 
311
- const runCopy = async (opts: { force: boolean; forcePaths: Set<string>; dryRun: boolean }) => {
312
- const result: CopyResult = { manifest: {}, skipped: [], ignored: [], updated: [] };
313
- const copyOpts = {
314
- aliasFrom: "@/lib/auth/core",
315
- aliasTo: `${alias}/core`,
316
- extraRewrites,
317
- previous,
318
- force: opts.force,
319
- forcePaths: opts.forcePaths,
320
- ignore: config.ignore,
321
- neverCopy: NEVER_COPY,
322
- dryRun: opts.dryRun,
323
- };
324
-
325
- await copyDir(join(REGISTRY_ROOT, registry.core.dir), join(destRoot, "core"), copyOpts, destRoot, result);
326
- await copyDir(sharedDir, destRoot, { ...copyOpts, neverCopy: skipFromShared }, destRoot, result);
327
- await copyDir(variantDir, destRoot, { ...copyOpts, neverCopy: skipFromVariant }, destRoot, result);
328
-
329
- if (orm) {
330
- await copyOneFile(join(sharedDir, orm.configFile), join(targetRoot, orm.configFile), destRoot, copyOpts, result);
331
- await copyDir(join(variantDir, orm.dataDir), join(targetRoot, orm.dataDir), copyOpts, destRoot, result);
332
- }
354
+ return { destRoot, installPath, alias, sharedDir, variantDir, orm, skipFromShared, skipFromVariant, extraRewrites, config, previous };
355
+ }
333
356
 
334
- // Switching variants has to remove the old variant's files, or the project ends up with both wired in.
335
- const pruned = await pruneRemovedFiles(destRoot, previous, result, { force: opts.force, ignore: config.ignore, dryRun: opts.dryRun });
336
- return { result, pruned };
357
+ /** The actual copy pass for a merge-mode install shared by installMerge (writes for real, or
358
+ * dry-runs for --check/the interactive-prompt pre-check) and cmdDiff (always a dry run, always
359
+ * force:true so a locally-modified file's difference is captured too instead of silently
360
+ * skipped). One exception to "everything merges into destRoot": an ORM combo's config file and
361
+ * data directory (see ORM_LAYOUTS) land at the *project root* instead — e.g. the Prisma combos'
362
+ * `prisma.config.ts` + `prisma/`, or the Drizzle combos' `drizzle.config.ts` + `drizzle/`.
363
+ *
364
+ * Both are still copied with destRoot (not targetRoot) as the manifest-key root, which makes
365
+ * copyOneFile compute "../"-relative keys for them — deliberate, not an oversight: those keys
366
+ * can never collide with a real destRoot-relative key, so pruneRemovedFiles below correctly
367
+ * removes an ORM folder a pre-migration install left nested inside destRoot, without disturbing
368
+ * anything else's keys. */
369
+ async function runMergeCopy(
370
+ plan: MergePlan,
371
+ registry: Registry,
372
+ targetRoot: string,
373
+ opts: { force: boolean; forcePaths: Set<string>; dryRun: boolean; collectDiffs?: DiffEntry[] },
374
+ ): Promise<{ result: CopyResult; pruned: { removed: string[]; keptModified: string[] } }> {
375
+ const result: CopyResult = { manifest: {}, skipped: [], ignored: [], updated: [] };
376
+ const copyOpts: CopyOptions = {
377
+ aliasFrom: "@/lib/auth/core",
378
+ aliasTo: `${plan.alias}/core`,
379
+ extraRewrites: plan.extraRewrites,
380
+ previous: plan.previous,
381
+ force: opts.force,
382
+ forcePaths: opts.forcePaths,
383
+ ignore: plan.config.ignore,
384
+ neverCopy: NEVER_COPY,
385
+ dryRun: opts.dryRun,
386
+ collectDiffs: opts.collectDiffs,
337
387
  };
338
388
 
389
+ await copyDir(join(REGISTRY_ROOT, registry.core.dir), join(plan.destRoot, "core"), copyOpts, plan.destRoot, result);
390
+ await copyDir(plan.sharedDir, plan.destRoot, { ...copyOpts, neverCopy: plan.skipFromShared }, plan.destRoot, result);
391
+ await copyDir(plan.variantDir, plan.destRoot, { ...copyOpts, neverCopy: plan.skipFromVariant }, plan.destRoot, result);
392
+
393
+ if (plan.orm) {
394
+ await copyOneFile(join(plan.sharedDir, plan.orm.configFile), join(targetRoot, plan.orm.configFile), plan.destRoot, copyOpts, result);
395
+ await copyDir(join(plan.variantDir, plan.orm.dataDir), join(targetRoot, plan.orm.dataDir), copyOpts, plan.destRoot, result);
396
+ }
397
+
398
+ // Switching variants has to remove the old variant's files, or the project ends up with both wired in.
399
+ const pruned = await pruneRemovedFiles(plan.destRoot, plan.previous, result, { force: opts.force, ignore: plan.config.ignore, dryRun: opts.dryRun });
400
+ return { result, pruned };
401
+ }
402
+
403
+ async function installMerge(comboName: string, combo: ComboEntry, variant: string, registry: Registry, targetRoot: string, flags: Record<string, string | true>) {
404
+ const lock = await loadLock(targetRoot);
405
+ const previous = lock.files ?? {};
406
+ const force = flags.force === true;
407
+ const checkOnly = flags.check === true;
408
+
409
+ const plan = await buildMergePlan(combo, variant, targetRoot, flags, previous);
410
+
339
411
  if (checkOnly) {
340
- const { result, pruned } = await runCopy({ force, forcePaths: new Set(), dryRun: true });
341
- console.log(`\nCheck only — nothing written. "${comboName}" (${variant} variant) in ${installPath} (alias ${alias}):`);
412
+ const { result, pruned } = await runMergeCopy(plan, registry, targetRoot, { force, forcePaths: new Set(), dryRun: true });
413
+ console.log(`\nCheck only — nothing written. "${comboName}" (${variant} variant) in ${plan.installPath} (alias ${plan.alias}):`);
342
414
  printInstallSummary(result, pruned);
343
415
  const silent = !result.updated.length && !result.skipped.length && !pruned.removed.length && !pruned.keptModified.length;
344
416
  if (silent) console.log(`\nUp to date — nothing would change.`);
@@ -348,8 +420,8 @@ async function installMerge(comboName: string, combo: ComboEntry, variant: strin
348
420
  // Interactive + no --force: find locally-modified conflicts first (a dry run, nothing written),
349
421
  // and let the user pick which — if any — to overwrite anyway, shadcn-"this file already exists"
350
422
  // style, instead of the non-interactive default of silently leaving all of them alone.
351
- const forcePaths = await resolveForcePaths(() => runCopy({ force: false, forcePaths: new Set(), dryRun: true }), flags);
352
- const { result, pruned } = await runCopy({ force, forcePaths, dryRun: false });
423
+ const forcePaths = await resolveForcePaths(() => runMergeCopy(plan, registry, targetRoot, { force: false, forcePaths: new Set(), dryRun: true }), flags);
424
+ const { result, pruned } = await runMergeCopy(plan, registry, targetRoot, { force, forcePaths, dryRun: false });
353
425
 
354
426
  await writeFile(
355
427
  join(targetRoot, "auth.lock.json"),
@@ -357,11 +429,16 @@ async function installMerge(comboName: string, combo: ComboEntry, variant: strin
357
429
  "utf8",
358
430
  );
359
431
 
360
- console.log(`\nInstalled "${comboName}" (${variant} variant) into ${installPath} (alias ${alias})`);
432
+ console.log(`\nInstalled "${comboName}" (${variant} variant) into ${plan.installPath} (alias ${plan.alias})`);
361
433
  printInstallSummary(result, pruned);
362
434
 
363
435
  const peerDeps = [...new Set([...registry.core.peerDependencies, ...combo.peerDependencies])];
364
- console.log(`\nInstall peer dependencies:\n npm install ${peerDeps.join(" ")}`);
436
+ console.log(`\nPeer dependencies: ${peerDeps.join(" ")}`);
437
+ if (result.updated.length) {
438
+ installDependencies(targetRoot, peerDeps, flags);
439
+ } else if (flags["skip-install"] !== true) {
440
+ console.log(`(nothing changed this run — skipping install)`);
441
+ }
365
442
  printPostInstallNotes(combo, variant);
366
443
  }
367
444
 
@@ -427,7 +504,13 @@ async function installScaffold(comboName: string, combo: ComboEntry, variant: st
427
504
 
428
505
  console.log(`\nGenerated "${appName}" (${comboName}, ${variant} variant) into ${targetRoot}`);
429
506
  printInstallSummary(result, pruned);
430
- console.log(`\nDependencies are already declared in package.json — run \`npm install\` (or \`pnpm install\`) in ${targetRoot}.`);
507
+ if (result.updated.length) {
508
+ // Dependencies are already declared in package.json — no specific packages to name, just
509
+ // "install whatever's there" (installDependencies with an empty list does exactly that).
510
+ installDependencies(targetRoot, [], flags);
511
+ } else if (flags["skip-install"] !== true) {
512
+ console.log(`\n(nothing changed this run — skipping install)`);
513
+ }
431
514
  printPostInstallNotes(combo, variant);
432
515
  }
433
516
 
@@ -649,21 +732,100 @@ async function cmdUpdate(targetRoot: string, flags: Record<string, string | true
649
732
  await installCombo(lock.combo, combo, variant, registry, targetRoot, flags);
650
733
  }
651
734
 
735
+ /**
736
+ * Shows the actual content diff between what's on disk and what the current registry would
737
+ * produce — for every tracked file that differs, whether that's because the registry changed
738
+ * upstream or because you hand-edited the file yourself (both are worth seeing before deciding
739
+ * what to do). Read-only: never writes anything, unlike `update`.
740
+ */
652
741
  async function cmdDiff(targetRoot: string) {
653
742
  const lock = await loadLock(targetRoot);
654
- if (!lock.files) {
655
- console.error(`No auth.lock.json found in ${targetRoot} — nothing installed yet.`);
743
+ if (!lock.combo || !lock.variant) {
744
+ console.error(`No auth.lock.json (or it's missing combo/variant) in ${targetRoot} — nothing installed to diff.`);
745
+ process.exitCode = 1;
746
+ return;
747
+ }
748
+
749
+ const registry = await loadRegistry();
750
+ const combo = registry.combos[lock.combo];
751
+ if (!combo) {
752
+ console.error(`auth.lock.json names combo "${lock.combo}", which no longer exists in this registry.`);
753
+ process.exitCode = 1;
656
754
  return;
657
755
  }
658
- console.log(`diff is not implemented yet (deferred until there's more than one install to maintain) — installed files:`);
659
- console.log(` combo: ${lock.combo}, variant: ${lock.variant ?? DEFAULT_VARIANT}`);
660
- for (const file of Object.keys(lock.files)) console.log(` ${file}`);
756
+
757
+ if (comboInstallMode(combo) === "scaffold") {
758
+ console.error(`diff isn't supported yet for "${lock.combo}" (a scaffold-mode combo) — only merge-mode (api) combos support it.`);
759
+ process.exitCode = 1;
760
+ return;
761
+ }
762
+
763
+ const variant = resolveVariant(combo, lock.combo, lock.variant);
764
+ if (!variant) {
765
+ process.exitCode = 1;
766
+ return;
767
+ }
768
+
769
+ const plan = await buildMergePlan(combo, variant, targetRoot, {}, lock.files ?? {});
770
+ const diffs: DiffEntry[] = [];
771
+ // force:true so a locally-modified file's difference is captured too — diff wants to show
772
+ // everything that differs, not just what a real (non---force) install would apply; dryRun:true
773
+ // so nothing is written.
774
+ await runMergeCopy(plan, registry, targetRoot, { force: true, forcePaths: new Set(), dryRun: true, collectDiffs: diffs });
775
+
776
+ if (!diffs.length) {
777
+ console.log(`No differences — every tracked file in "${lock.combo}" (${variant} variant) matches the current registry.`);
778
+ return;
779
+ }
780
+
781
+ console.log(`${diffs.length} file(s) differ from the current registry:`);
782
+ for (const d of diffs) {
783
+ const patch = createTwoFilesPatch(d.path, d.path, d.oldContent ?? "", d.newContent, "installed", "registry");
784
+ process.stdout.write(`\n${patch}`);
785
+ }
786
+ }
787
+
788
+ async function printUsage(exitCode: number) {
789
+ const registry = await loadRegistry();
790
+ console.log("Usage: simple-auth-kit <init|add|update|diff> [...]");
791
+ console.log(` init [--config-only] [--kind ...] [--into <path>] (fresh setup: guided "what do you need" picker, like bare "add")`);
792
+ console.log(` --config-only: just write .simple-auth-kit.json and stop, no install`);
793
+ console.log(` add <combo> [--workspaces] [--force] [--skip-install] [--into <path>] [--path <dir>] [--alias <alias>]`);
794
+ console.log(` add [--kind api,admin,mobile] [--framework <name>,...] [--workspaces] [--into <path>] [--name <appName>]`);
795
+ console.log(` (bare "add", or "add" with --kind but no combo, launches a guided prompt for whatever's missing)`);
796
+ console.log(` update [--check] [--force] [--skip-install] [--into <path>] (re-installs whatever combo+variant auth.lock.json already records)`);
797
+ console.log(` --check: report what would change, without writing anything (merge-mode combos only)`);
798
+ console.log(` diff [--into <path>] (shows the actual content diff for every tracked file that differs from the current registry — read-only; merge-mode combos only)`);
799
+ console.log(` In a TTY, without --force or --check: a file changed locally since install prompts to overwrite, per file.`);
800
+ console.log(` --skip-install: don't run the package manager after copying files — the default is to install for you, shadcn-\`add\`-style.`);
801
+ console.log(` --pm <npm|pnpm|yarn|bun>: which package manager to install with — default: detected from a lockfile in the target directory, npm if none found.`);
802
+ console.log(`\nAvailable combos:`);
803
+ for (const kind of ["api", "admin", "mobile"] as Kind[]) {
804
+ const names = combosByKind(registry, kind).map(([name]) => name);
805
+ if (names.length) console.log(` ${KIND_LABELS[kind]}: ${names.join(", ")}`);
806
+ }
807
+ console.log(`\nVariants (choose one at install time):`);
808
+ for (const [name, variant] of Object.entries(registry.variants)) {
809
+ console.log(` ${name}${variant.flag ? ` (${variant.flag})` : " (default)"} — ${variant.description}`);
810
+ }
811
+ process.exitCode = exitCode;
661
812
  }
662
813
 
663
814
  async function main() {
664
815
  const { command, positional, flags } = parseArgs(process.argv.slice(2));
665
816
  const targetRoot = resolve(process.cwd(), flagString(flags.into) ?? ".");
666
817
 
818
+ if (command === "--help" || command === "-h" || command === "help" || flags.help === true) {
819
+ await printUsage(0);
820
+ return;
821
+ }
822
+ if (!command && isTTY()) {
823
+ // Bare `npx @simple-auth-kit/cli` in a real terminal — go straight to the guided picker
824
+ // (same flow as `add` with no combo) instead of just printing help text.
825
+ await cmdCreate(targetRoot, flags);
826
+ return;
827
+ }
828
+
667
829
  switch (command) {
668
830
  case "init":
669
831
  await cmdInit(targetRoot, flags);
@@ -684,28 +846,8 @@ async function main() {
684
846
  case "diff":
685
847
  await cmdDiff(targetRoot);
686
848
  break;
687
- default: {
688
- const registry = await loadRegistry();
689
- console.log("Usage: simple-auth-kit <init|add|update|diff> [...]");
690
- console.log(` init [--config-only] [--kind ...] [--into <path>] (fresh setup: guided "what do you need" picker, like bare "add")`);
691
- console.log(` --config-only: just write .simple-auth-kit.json and stop, no install`);
692
- console.log(` add <combo> [--workspaces] [--force] [--into <path>] [--path <dir>] [--alias <alias>]`);
693
- console.log(` add [--kind api,admin,mobile] [--framework <name>,...] [--workspaces] [--into <path>] [--name <appName>]`);
694
- console.log(` (bare "add", or "add" with --kind but no combo, launches a guided prompt for whatever's missing)`);
695
- console.log(` update [--check] [--force] [--into <path>] (re-installs whatever combo+variant auth.lock.json already records)`);
696
- console.log(` --check: report what would change, without writing anything (merge-mode combos only)`);
697
- console.log(` In a TTY, without --force or --check: a file changed locally since install prompts to overwrite, per file.`);
698
- console.log(`\nAvailable combos:`);
699
- for (const kind of ["api", "admin", "mobile"] as Kind[]) {
700
- const names = combosByKind(registry, kind).map(([name]) => name);
701
- if (names.length) console.log(` ${KIND_LABELS[kind]}: ${names.join(", ")}`);
702
- }
703
- console.log(`\nVariants (choose one at install time):`);
704
- for (const [name, variant] of Object.entries(registry.variants)) {
705
- console.log(` ${name}${variant.flag ? ` (${variant.flag})` : " (default)"} — ${variant.description}`);
706
- }
707
- process.exitCode = command ? 1 : 0;
708
- }
849
+ default:
850
+ await printUsage(command ? 1 : 0);
709
851
  }
710
852
  }
711
853