@simple-auth-kit/cli 1.0.0 → 1.1.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,6 +21,7 @@ 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 diff # shows the actual content diff for every file that differs from the registry (api combos only)
24
25
  npx @simple-auth-kit/cli # full command/flag reference + available combos
25
26
  ```
26
27
 
@@ -29,6 +30,11 @@ without `--force` or `--check`, a file that's changed locally since install prom
29
30
  per file — before it's overwritten; outside a TTY (CI/scripts) it's left alone by default and
30
31
  reported at the end, same as `--force` always applying and `--check` never writing anything.
31
32
 
33
+ **Dependencies are installed for you**, shadcn-`add`-style — no separate `npm install` step.
34
+ The package manager is auto-detected from a lockfile already in the target directory (npm if
35
+ none found); override it with `--pm <npm|pnpm|yarn|bun>`, or skip the install entirely with
36
+ `--skip-install` if you'd rather review `package.json` first.
37
+
32
38
  ## Available combos
33
39
 
34
40
  | Kind | Combo | Installs as |
@@ -1,17 +1,29 @@
1
1
  #!/usr/bin/env node
2
- // Thin launcher so `npm link` (run once, inside cli/) makes `simple-auth-kit` a real global command —
3
- // matching shadcn's `npx shadcn add <component>` ergonomics: run it from inside any consumer
4
- // project, no `cd` into this repo, no `--into` needed (it already defaults to cwd). Spawns the
5
- // local tsx binary by absolute path so this works regardless of the caller's cwd or global PATH.
2
+ // Thin launcher so `npm link` (or the published @simple-auth-kit/cli via npx) makes
3
+ // `simple-auth-kit` a real command — matching shadcn's `npx shadcn add <component>` ergonomics:
4
+ // run it from inside any consumer project, no `cd` into this repo, no `--into` needed (it
5
+ // already defaults to cwd).
6
+ //
7
+ // Resolves tsx via Node's own module resolution (require.resolve), not a hardcoded
8
+ // "../node_modules/.bin/tsx" relative path — that broke under plain npm/npx installs, which
9
+ // hoist tsx to a shared top-level node_modules rather than nesting it inside this package's own
10
+ // node_modules (pnpm's per-package node_modules made this invisible in monorepo dev). Reading
11
+ // tsx's own package.json "bin" field, rather than hardcoding "dist/cli.mjs", stays correct even
12
+ // if tsx's internal file layout changes in a future version.
6
13
  import { spawnSync } from "node:child_process";
14
+ import { createRequire } from "node:module";
7
15
  import { dirname, join } from "node:path";
8
16
  import { fileURLToPath } from "node:url";
9
17
 
10
18
  const here = dirname(fileURLToPath(import.meta.url));
11
- const tsxBin = join(here, "..", "node_modules", ".bin", "tsx");
12
19
  const entry = join(here, "..", "simple-auth-kit.ts");
13
20
 
14
- const result = spawnSync(tsxBin, [entry, ...process.argv.slice(2)], {
21
+ const require = createRequire(import.meta.url);
22
+ const tsxPkgJson = require.resolve("tsx/package.json");
23
+ const tsxBin = typeof require(tsxPkgJson).bin === "string" ? require(tsxPkgJson).bin : require(tsxPkgJson).bin.tsx;
24
+ const tsxCli = join(dirname(tsxPkgJson), tsxBin);
25
+
26
+ const result = spawnSync(process.execPath, [tsxCli, entry, ...process.argv.slice(2)], {
15
27
  stdio: "inherit",
16
28
  cwd: process.cwd(),
17
29
  });
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.0",
3
+ "version": "1.1.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"]);
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,15 +732,57 @@ 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;
754
+ return;
755
+ }
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;
656
760
  return;
657
761
  }
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}`);
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
+ }
661
786
  }
662
787
 
663
788
  async function main() {
@@ -689,12 +814,15 @@ async function main() {
689
814
  console.log("Usage: simple-auth-kit <init|add|update|diff> [...]");
690
815
  console.log(` init [--config-only] [--kind ...] [--into <path>] (fresh setup: guided "what do you need" picker, like bare "add")`);
691
816
  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>]`);
817
+ console.log(` add <combo> [--workspaces] [--force] [--skip-install] [--into <path>] [--path <dir>] [--alias <alias>]`);
693
818
  console.log(` add [--kind api,admin,mobile] [--framework <name>,...] [--workspaces] [--into <path>] [--name <appName>]`);
694
819
  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)`);
820
+ console.log(` update [--check] [--force] [--skip-install] [--into <path>] (re-installs whatever combo+variant auth.lock.json already records)`);
696
821
  console.log(` --check: report what would change, without writing anything (merge-mode combos only)`);
822
+ 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)`);
697
823
  console.log(` In a TTY, without --force or --check: a file changed locally since install prompts to overwrite, per file.`);
824
+ console.log(` --skip-install: don't run the package manager after copying files — the default is to install for you, shadcn-\`add\`-style.`);
825
+ 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.`);
698
826
  console.log(`\nAvailable combos:`);
699
827
  for (const kind of ["api", "admin", "mobile"] as Kind[]) {
700
828
  const names = combosByKind(registry, kind).map(([name]) => name);