@simple-auth-kit/cli 1.4.1 → 1.4.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simple-auth-kit/cli",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
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": {
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ dist/
3
+ *.tsbuildinfo
4
+ coverage/
5
+ .env
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ dist/
3
+ *.tsbuildinfo
4
+ coverage/
5
+ .env
6
+ generated/
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ dist/
3
+ *.tsbuildinfo
4
+ coverage/
5
+ .env
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ dist/
3
+ *.tsbuildinfo
4
+ coverage/
5
+ .env
6
+ generated/
@@ -0,0 +1,4 @@
1
+ node_modules/
2
+ dist/
3
+ *.tsbuildinfo
4
+ coverage/
package/registry.json CHANGED
@@ -36,6 +36,7 @@
36
36
  "postInstall": [
37
37
  "Set DATABASE_URL in .env (see .env.example)",
38
38
  "Set AUTH_JWT_SECRET in .env — a base64-encoded 256-bit (or longer) secret, e.g.: openssl rand -base64 32. The app refuses to start without it; there is no default and nothing is generated for you. Changing it invalidates every outstanding token.",
39
+ "Add \"@/prisma/*\": [\"./generated/prisma/*\"] to your tsconfig.json's compilerOptions.paths — the copied source imports the generated Prisma client via this alias, since prisma.config.ts/prisma/ (and the client it generates) install at the project root regardless of where --path put everything else",
39
40
  "Run: npx prisma generate",
40
41
  "Run: npx prisma migrate deploy",
41
42
  "Seed RBAC: add \"seed\": \"tsx src/lib/auth/src/seed.ts\" to your package.json scripts (adjust the path if you installed elsewhere), then run: npm run seed — it is idempotent, and seeds the permission catalog and the default admin/member roles. Nothing is authorized until it has run at least once.",
@@ -102,6 +103,7 @@
102
103
  "postInstall": [
103
104
  "Set DATABASE_URL in .env (see .env.example)",
104
105
  "Set AUTH_JWT_SECRET in .env — a base64-encoded 256-bit (or longer) secret, e.g.: openssl rand -base64 32. The app refuses to start without it; there is no default and nothing is generated for you. Changing it invalidates every outstanding token.",
106
+ "Add \"@/prisma/*\": [\"./generated/prisma/*\"] to your tsconfig.json's compilerOptions.paths — the copied source imports the generated Prisma client via this alias, since prisma.config.ts/prisma/ (and the client it generates) install at the project root regardless of where --path put everything else",
105
107
  "Run: npx prisma generate",
106
108
  "Run: npx prisma migrate deploy",
107
109
  "Seed RBAC: add \"seed\": \"tsx src/lib/auth/src/seed.ts\" to your package.json scripts (adjust the path if you installed elsewhere), then run: npm run seed — it is idempotent, and seeds the permission catalog and the default admin/member roles. Nothing is authorized until it has run at least once.",
@@ -17,13 +17,27 @@
17
17
  // non-interactive/scripted use.
18
18
  import { existsSync } from "node:fs";
19
19
  import { spawnSync } from "node:child_process";
20
- import { basename, dirname, join, relative, resolve } from "node:path";
20
+ import { basename, dirname, join, resolve } from "node:path";
21
21
  import { access, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
22
22
  import { fileURLToPath } from "node:url";
23
23
  import { createTwoFilesPatch } from "diff";
24
24
  import prompts from "prompts";
25
- import { copyDir, copyOneFile, CopyOptions, CopyResult, DiffEntry, NEVER_COPY, pruneRemovedFiles, SCAFFOLD_NEVER_COPY, sha256, toPosix } from "./lib/copy.js";
26
- import { reconcileManifest, renameNative, type NativeIdentity } from "./lib/rename-native.js";
25
+ import {
26
+ copyDir,
27
+ copyOneFile,
28
+ CopyOptions,
29
+ CopyResult,
30
+ DiffEntry,
31
+ NEVER_COPY,
32
+ pruneRemovedFiles,
33
+ SCAFFOLD_NEVER_COPY,
34
+ sha256,
35
+ } from "./lib/copy.js";
36
+ import {
37
+ reconcileManifest,
38
+ renameNative,
39
+ type NativeIdentity,
40
+ } from "./lib/rename-native.js";
27
41
 
28
42
  const CLI_DIR = dirname(fileURLToPath(import.meta.url));
29
43
  // Two contexts, and they disagree about where the registry is:
@@ -32,18 +46,35 @@ const CLI_DIR = dirname(fileURLToPath(import.meta.url));
32
46
  // materialized right before `npm publish`/`npm pack`).
33
47
  // - monorepo dev checkout: this file lives at packages/cli/, so the real registry/ is two
34
48
  // levels up, at the repo root.
35
- const REGISTRY_ROOT = existsSync(join(CLI_DIR, "registry")) ? join(CLI_DIR, "registry") : resolve(CLI_DIR, "..", "..", "registry");
49
+ const REGISTRY_ROOT = existsSync(join(CLI_DIR, "registry"))
50
+ ? join(CLI_DIR, "registry")
51
+ : resolve(CLI_DIR, "..", "..", "registry");
36
52
  const CONFIG_FILENAME = ".simple-auth-kit.json";
37
53
  const DEFAULT_VARIANT = "base";
38
54
 
39
55
  type Kind = "api" | "admin" | "mobile";
40
56
  type InstallMode = "merge" | "scaffold";
41
57
 
42
- const KIND_LABELS: Record<Kind, string> = { api: "API (backend)", admin: "Admin console", mobile: "Mobile app" };
43
- const KIND_FRAMEWORK_NOUN: Record<Kind, string> = { api: "API stack", admin: "admin framework", mobile: "mobile framework" };
58
+ const KIND_LABELS: Record<Kind, string> = {
59
+ api: "API (backend)",
60
+ admin: "Admin console",
61
+ mobile: "Mobile app",
62
+ };
63
+ const KIND_FRAMEWORK_NOUN: Record<Kind, string> = {
64
+ api: "API stack",
65
+ admin: "admin framework",
66
+ mobile: "mobile framework",
67
+ };
44
68
 
45
69
  /** Flags that take no value. Everything else consumes the next argv entry. */
46
- const BOOLEAN_FLAGS = new Set(["workspaces", "force", "check", "config-only", "skip-install", "help"]);
70
+ const BOOLEAN_FLAGS = new Set([
71
+ "workspaces",
72
+ "force",
73
+ "check",
74
+ "config-only",
75
+ "skip-install",
76
+ "help",
77
+ ]);
47
78
 
48
79
  interface SimpleAuthKitConfig {
49
80
  path: string;
@@ -83,12 +114,21 @@ interface AuthLock {
83
114
  files?: Record<string, string>;
84
115
  }
85
116
 
86
- const DEFAULT_CONFIG: SimpleAuthKitConfig = { path: "src/lib/auth", alias: "@/lib/auth", ignore: [] };
117
+ const DEFAULT_CONFIG: SimpleAuthKitConfig = {
118
+ path: "src/lib/auth",
119
+ alias: "@/lib/auth",
120
+ ignore: [],
121
+ };
87
122
 
88
123
  const comboKind = (combo: ComboEntry): Kind => combo.kind ?? "api";
89
- const comboInstallMode = (combo: ComboEntry): InstallMode => combo.installMode ?? (comboKind(combo) === "api" ? "merge" : "scaffold");
90
-
91
- function parseArgs(argv: string[]): { command?: string; positional: string[]; flags: Record<string, string | true> } {
124
+ const comboInstallMode = (combo: ComboEntry): InstallMode =>
125
+ combo.installMode ?? (comboKind(combo) === "api" ? "merge" : "scaffold");
126
+
127
+ function parseArgs(argv: string[]): {
128
+ command?: string;
129
+ positional: string[];
130
+ flags: Record<string, string | true>;
131
+ } {
92
132
  const [command, ...rest] = argv;
93
133
  const positional: string[] = [];
94
134
  const flags: Record<string, string | true> = {};
@@ -109,9 +149,15 @@ function parseArgs(argv: string[]): { command?: string; positional: string[]; fl
109
149
  return { command, positional, flags };
110
150
  }
111
151
 
112
- const flagString = (value: string | true | undefined): string | undefined => (typeof value === "string" ? value : undefined);
152
+ const flagString = (value: string | true | undefined): string | undefined =>
153
+ typeof value === "string" ? value : undefined;
113
154
  const flagList = (value: string | true | undefined): string[] | undefined =>
114
- typeof value === "string" ? value.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
155
+ typeof value === "string"
156
+ ? value
157
+ .split(",")
158
+ .map((s) => s.trim())
159
+ .filter(Boolean)
160
+ : undefined;
115
161
 
116
162
  async function loadRegistry(): Promise<Registry> {
117
163
  return JSON.parse(await readFile(join(CLI_DIR, "registry.json"), "utf8"));
@@ -128,7 +174,9 @@ async function loadConfig(targetRoot: string): Promise<SimpleAuthKitConfig> {
128
174
 
129
175
  async function loadLock(targetRoot: string): Promise<AuthLock> {
130
176
  try {
131
- return JSON.parse(await readFile(join(targetRoot, "auth.lock.json"), "utf8"));
177
+ return JSON.parse(
178
+ await readFile(join(targetRoot, "auth.lock.json"), "utf8"),
179
+ );
132
180
  } catch {
133
181
  return {};
134
182
  }
@@ -163,18 +211,29 @@ async function isEmptyDir(dir: string): Promise<boolean> {
163
211
  * reported at the end by printInstallSummary) outside a TTY or under --check, since there's
164
212
  * nothing useful to prompt into either case.
165
213
  */
166
- async function resolveForcePaths(runDry: () => Promise<{ result: CopyResult }>, flags: Record<string, string | true>): Promise<Set<string>> {
167
- if (flags.force === true || flags.check === true || !isTTY()) return new Set();
214
+ async function resolveForcePaths(
215
+ runDry: () => Promise<{ result: CopyResult }>,
216
+ flags: Record<string, string | true>,
217
+ ): Promise<Set<string>> {
218
+ if (flags.force === true || flags.check === true || !isTTY())
219
+ return new Set();
168
220
 
169
221
  const { result } = await runDry();
170
222
  if (!result.skipped.length) return new Set();
171
223
 
172
- console.log(`\n${result.skipped.length} file(s) have changed locally since the last install:`);
224
+ console.log(
225
+ `\n${result.skipped.length} file(s) have changed locally since the last install:`,
226
+ );
173
227
  const picked = await ask<string[]>({
174
228
  type: "multiselect",
175
229
  name: "paths",
176
- message: "Overwrite any of these with the latest version? (unselected ones are left alone)",
177
- choices: result.skipped.map((file) => ({ title: file, value: file, selected: false })),
230
+ message:
231
+ "Overwrite any of these with the latest version? (unselected ones are left alone)",
232
+ choices: result.skipped.map((file) => ({
233
+ title: file,
234
+ value: file,
235
+ selected: false,
236
+ })),
178
237
  instructions: false,
179
238
  });
180
239
  return new Set(picked ?? []);
@@ -186,10 +245,16 @@ type PackageManager = (typeof PACKAGE_MANAGERS)[number];
186
245
  /** Detected from whichever lockfile is already sitting in targetRoot — unambiguous, so used
187
246
  * without asking. Returns null when nothing on disk says which tool to use and `--pm` wasn't
188
247
  * given either, leaving it to the caller to ask (interactive) or default (non-interactive). */
189
- function detectPackageManagerFromLockfile(targetRoot: string): PackageManager | null {
248
+ function detectPackageManagerFromLockfile(
249
+ targetRoot: string,
250
+ ): PackageManager | null {
190
251
  if (existsSync(join(targetRoot, "pnpm-lock.yaml"))) return "pnpm";
191
252
  if (existsSync(join(targetRoot, "yarn.lock"))) return "yarn";
192
- if (existsSync(join(targetRoot, "bun.lockb")) || existsSync(join(targetRoot, "bun.lock"))) return "bun";
253
+ if (
254
+ existsSync(join(targetRoot, "bun.lockb")) ||
255
+ existsSync(join(targetRoot, "bun.lock"))
256
+ )
257
+ return "bun";
193
258
  if (existsSync(join(targetRoot, "package-lock.json"))) return "npm";
194
259
  return null;
195
260
  }
@@ -198,11 +263,17 @@ function detectPackageManagerFromLockfile(targetRoot: string): PackageManager |
198
263
  * silently. Only when neither says anything — most commonly a scaffold install into an empty
199
264
  * directory — and this is a real terminal do we ask; a non-interactive run in that same situation
200
265
  * still falls back to npm rather than blocking. */
201
- async function resolvePackageManager(targetRoot: string, flags: Record<string, string | true>): Promise<PackageManager> {
266
+ async function resolvePackageManager(
267
+ targetRoot: string,
268
+ flags: Record<string, string | true>,
269
+ ): Promise<PackageManager> {
202
270
  const requested = flagString(flags.pm);
203
271
  if (requested) {
204
- if ((PACKAGE_MANAGERS as readonly string[]).includes(requested)) return requested as PackageManager;
205
- console.error(`--pm "${requested}" isn't one of ${PACKAGE_MANAGERS.join(", ")} — falling back to auto-detection.`);
272
+ if ((PACKAGE_MANAGERS as readonly string[]).includes(requested))
273
+ return requested as PackageManager;
274
+ console.error(
275
+ `--pm "${requested}" isn't one of ${PACKAGE_MANAGERS.join(", ")} — falling back to auto-detection.`,
276
+ );
206
277
  }
207
278
 
208
279
  const detected = detectPackageManagerFromLockfile(targetRoot);
@@ -229,13 +300,19 @@ async function resolvePackageManager(targetRoot: string, flags: Record<string, s
229
300
  * to add (an update that touched no files has nothing worth re-installing for). `--pm
230
301
  * <npm|pnpm|yarn|bun>` picks the tool explicitly instead of auto-detecting/asking.
231
302
  */
232
- async function installDependencies(targetRoot: string, deps: string[], flags: Record<string, string | true>): Promise<void> {
303
+ async function installDependencies(
304
+ targetRoot: string,
305
+ deps: string[],
306
+ flags: Record<string, string | true>,
307
+ ): Promise<void> {
233
308
  if (flags["skip-install"] === true) return;
234
309
 
235
310
  const pm = await resolvePackageManager(targetRoot, flags);
236
311
  // "install everything in package.json" (zero deps named) is the same bare verb across all
237
312
  // four; naming specific packages is "install <pkgs>" for npm, "add <pkgs>" for the others.
238
- const args = deps.length ? [pm === "npm" ? "install" : "add", ...deps] : ["install"];
313
+ const args = deps.length
314
+ ? [pm === "npm" ? "install" : "add", ...deps]
315
+ : ["install"];
239
316
 
240
317
  console.log(`\nInstalling dependencies (${pm})...`);
241
318
  let result = spawnSync(pm, args, { cwd: targetRoot, stdio: "inherit" });
@@ -247,12 +324,19 @@ async function installDependencies(targetRoot: string, deps: string[], flags: Re
247
324
  // a later `update` (a version bump re-flags a build), so approve unconditionally and retry
248
325
  // once rather than just telling the consumer to do it by hand each time — `approve-builds
249
326
  // --all` is a no-op when nothing is pending.
250
- console.log(`\n${pm} blocked some install scripts — running "pnpm approve-builds --all" and retrying...`);
251
- spawnSync(pm, ["approve-builds", "--all"], { cwd: targetRoot, stdio: "inherit" });
327
+ console.log(
328
+ `\n${pm} blocked some install scripts — running "pnpm approve-builds --all" and retrying...`,
329
+ );
330
+ spawnSync(pm, ["approve-builds", "--all"], {
331
+ cwd: targetRoot,
332
+ stdio: "inherit",
333
+ });
252
334
  result = spawnSync(pm, args, { cwd: targetRoot, stdio: "inherit" });
253
335
  }
254
336
  if (result.status !== 0) {
255
- console.error(`\n${pm} install exited with an error — run it yourself: cd ${targetRoot} && ${pm} ${args.join(" ")}`);
337
+ console.error(
338
+ `\n${pm} install exited with an error — run it yourself: cd ${targetRoot} && ${pm} ${args.join(" ")}`,
339
+ );
256
340
  }
257
341
  }
258
342
 
@@ -264,14 +348,23 @@ async function installDependencies(targetRoot: string, deps: string[], flags: Re
264
348
  * the fresh-project entry point, not just a config file. Pass --config-only to get the old
265
349
  * behavior back (write the config and stop there, no install).
266
350
  */
267
- async function cmdInit(targetRoot: string, flags: Record<string, string | true>) {
351
+ async function cmdInit(
352
+ targetRoot: string,
353
+ flags: Record<string, string | true>,
354
+ ) {
268
355
  const config: SimpleAuthKitConfig = {
269
356
  path: flagString(flags.path) ?? DEFAULT_CONFIG.path,
270
357
  alias: flagString(flags.alias) ?? DEFAULT_CONFIG.alias,
271
358
  ignore: [],
272
359
  };
273
- await writeFile(join(targetRoot, CONFIG_FILENAME), JSON.stringify(config, null, 2) + "\n", "utf8");
274
- console.log(`Wrote ${CONFIG_FILENAME} — combos will install into ${config.path} (import alias ${config.alias})`);
360
+ await writeFile(
361
+ join(targetRoot, CONFIG_FILENAME),
362
+ JSON.stringify(config, null, 2) + "\n",
363
+ "utf8",
364
+ );
365
+ console.log(
366
+ `Wrote ${CONFIG_FILENAME} — combos will install into ${config.path} (import alias ${config.alias})`,
367
+ );
275
368
 
276
369
  if (flags["config-only"] === true) return;
277
370
 
@@ -279,29 +372,44 @@ async function cmdInit(targetRoot: string, flags: Record<string, string | true>)
279
372
  await cmdCreate(targetRoot, flags);
280
373
  }
281
374
 
282
- function resolveVariant(combo: ComboEntry, comboName: string, requested: string): string | null {
375
+ function resolveVariant(
376
+ combo: ComboEntry,
377
+ comboName: string,
378
+ requested: string,
379
+ ): string | null {
283
380
  if (combo.variants.length === 0) {
284
- console.error(`Combo "${comboName}" has not been migrated to the variant layout yet (see registry/README.md) — nothing to install.`);
381
+ console.error(
382
+ `Combo "${comboName}" has not been migrated to the variant layout yet (see registry/README.md) — nothing to install.`,
383
+ );
285
384
  return null;
286
385
  }
287
386
  if (!combo.variants.includes(requested)) {
288
- console.error(`Combo "${comboName}" has no "${requested}" variant. Available: ${combo.variants.join(", ")}`);
387
+ console.error(
388
+ `Combo "${comboName}" has no "${requested}" variant. Available: ${combo.variants.join(", ")}`,
389
+ );
289
390
  return null;
290
391
  }
291
392
  return requested;
292
393
  }
293
394
 
294
395
  function requestedVariant(flags: Record<string, string | true>): string {
295
- return flags.workspaces === true ? "workspaces" : (flagString(flags.variant) ?? DEFAULT_VARIANT);
396
+ return flags.workspaces === true
397
+ ? "workspaces"
398
+ : (flagString(flags.variant) ?? DEFAULT_VARIANT);
296
399
  }
297
400
 
298
- function printInstallSummary(result: CopyResult, pruned: { removed: string[]; keptModified: string[] }) {
401
+ function printInstallSummary(
402
+ result: CopyResult,
403
+ pruned: { removed: string[]; keptModified: string[] },
404
+ ) {
299
405
  if (result.updated.length) {
300
406
  console.log(`\nUpdated (new or changed since last install):`);
301
407
  for (const file of result.updated) console.log(` ${file}`);
302
408
  }
303
409
  if (result.skipped.length) {
304
- console.log(`\nLeft alone — modified since the last install (re-run with --force to overwrite):`);
410
+ console.log(
411
+ `\nLeft alone — modified since the last install (re-run with --force to overwrite):`,
412
+ );
305
413
  for (const file of result.skipped) console.log(` ${file}`);
306
414
  }
307
415
  if (result.ignored.length) {
@@ -313,13 +421,18 @@ function printInstallSummary(result: CopyResult, pruned: { removed: string[]; ke
313
421
  for (const file of pruned.removed) console.log(` ${file}`);
314
422
  }
315
423
  if (pruned.keptModified.length) {
316
- console.log(`\nNo longer part of this install, but modified locally, so kept (delete by hand if you don't want them):`);
424
+ console.log(
425
+ `\nNo longer part of this install, but modified locally, so kept (delete by hand if you don't want them):`,
426
+ );
317
427
  for (const file of pruned.keptModified) console.log(` ${file}`);
318
428
  }
319
429
  }
320
430
 
321
431
  function printPostInstallNotes(combo: ComboEntry, variant: string) {
322
- const notes = [...combo.postInstall, ...(combo.variantPostInstall?.[variant] ?? [])];
432
+ const notes = [
433
+ ...combo.postInstall,
434
+ ...(combo.variantPostInstall?.[variant] ?? []),
435
+ ];
323
436
  if (notes.length) {
324
437
  console.log(`\nNext steps:`);
325
438
  for (const note of notes) console.log(` - ${note}`);
@@ -334,11 +447,23 @@ function printPostInstallNotes(combo: ComboEntry, variant: string) {
334
447
  * to schema.prisma's own location, so moving prisma.config.ts/prisma/ to the project root also
335
448
  * moves the generated client. The registry source's `src/*.ts` files import it as
336
449
  * `"../generated/prisma/client.js"` — correct for the registry's own dev/typecheck loop (where
337
- * nothing has moved), but wrong once copied into a real install, so installMerge rewrites that
338
- * literal string in every copied file to the new correct relative path. Drizzle has no equivalent
339
- * generated artifact to redirect. */
340
- const ORM_LAYOUTS: { configFile: string; dataDir: string; generatedClientImport?: string }[] = [
341
- { configFile: "prisma.config.ts", dataDir: "prisma", generatedClientImport: "../generated/prisma/client.js" },
450
+ * nothing has moved) and installMerge rewrites that literal string in every copied file to
451
+ * `@/prisma/client.js` instead of a computed relative path: the generated client always lands at
452
+ * the project root regardless of how deep the importing file sits (unlike `@/lib/auth/core`,
453
+ * whose depth actually varies with `--path`), so a fixed alias is both simpler and stable across
454
+ * installs. The consumer adds `"@/prisma/*": ["./generated/prisma/*"]` to their tsconfig `paths`
455
+ * once (see postInstall) — the CLI never writes tsconfig itself, same as `@/lib/auth`. Drizzle has
456
+ * no equivalent generated artifact to redirect. */
457
+ const ORM_LAYOUTS: {
458
+ configFile: string;
459
+ dataDir: string;
460
+ generatedClientImport?: string;
461
+ }[] = [
462
+ {
463
+ configFile: "prisma.config.ts",
464
+ dataDir: "prisma",
465
+ generatedClientImport: "../generated/prisma/client.js",
466
+ },
342
467
  { configFile: "drizzle.config.ts", dataDir: "drizzle" },
343
468
  ];
344
469
 
@@ -358,7 +483,13 @@ interface MergePlan {
358
483
 
359
484
  /** Everything about a merge-mode install that doesn't depend on force/dryRun/forcePaths —
360
485
  * computed once, shared by installMerge's real run and cmdDiff's read-only one. */
361
- async function buildMergePlan(combo: ComboEntry, variant: string, targetRoot: string, flags: Record<string, string | true>, previous: Record<string, string>): Promise<MergePlan> {
486
+ async function buildMergePlan(
487
+ combo: ComboEntry,
488
+ variant: string,
489
+ targetRoot: string,
490
+ flags: Record<string, string | true>,
491
+ previous: Record<string, string>,
492
+ ): Promise<MergePlan> {
362
493
  const config = await loadConfig(targetRoot);
363
494
  const installPath = flagString(flags.path) ?? config.path;
364
495
  const alias = flagString(flags.alias) ?? config.alias;
@@ -374,19 +505,35 @@ async function buildMergePlan(combo: ComboEntry, variant: string, targetRoot: st
374
505
  }
375
506
  return null;
376
507
  })();
377
- const skipFromShared = orm ? new Set([...NEVER_COPY, orm.configFile]) : NEVER_COPY;
378
- const skipFromVariant = orm ? new Set([...NEVER_COPY, orm.dataDir]) : NEVER_COPY;
508
+ const skipFromShared = orm
509
+ ? new Set([...NEVER_COPY, orm.configFile])
510
+ : NEVER_COPY;
511
+ const skipFromVariant = orm
512
+ ? new Set([...NEVER_COPY, orm.dataDir])
513
+ : NEVER_COPY;
379
514
 
380
515
  const extraRewrites = orm?.generatedClientImport
381
516
  ? [
382
517
  {
383
518
  from: orm.generatedClientImport,
384
- to: toPosix(relative(join(destRoot, "src"), join(targetRoot, "generated", "prisma", "client.js"))),
519
+ to: "@/prisma/client.js",
385
520
  },
386
521
  ]
387
522
  : undefined;
388
523
 
389
- return { destRoot, installPath, alias, sharedDir, variantDir, orm, skipFromShared, skipFromVariant, extraRewrites, config, previous };
524
+ return {
525
+ destRoot,
526
+ installPath,
527
+ alias,
528
+ sharedDir,
529
+ variantDir,
530
+ orm,
531
+ skipFromShared,
532
+ skipFromVariant,
533
+ extraRewrites,
534
+ config,
535
+ previous,
536
+ };
390
537
  }
391
538
 
392
539
  /** The actual copy pass for a merge-mode install — shared by installMerge (writes for real, or
@@ -405,9 +552,22 @@ async function runMergeCopy(
405
552
  plan: MergePlan,
406
553
  registry: Registry,
407
554
  targetRoot: string,
408
- opts: { force: boolean; forcePaths: Set<string>; dryRun: boolean; collectDiffs?: DiffEntry[] },
409
- ): Promise<{ result: CopyResult; pruned: { removed: string[]; keptModified: string[] } }> {
410
- const result: CopyResult = { manifest: {}, skipped: [], ignored: [], updated: [] };
555
+ opts: {
556
+ force: boolean;
557
+ forcePaths: Set<string>;
558
+ dryRun: boolean;
559
+ collectDiffs?: DiffEntry[];
560
+ },
561
+ ): Promise<{
562
+ result: CopyResult;
563
+ pruned: { removed: string[]; keptModified: string[] };
564
+ }> {
565
+ const result: CopyResult = {
566
+ manifest: {},
567
+ skipped: [],
568
+ ignored: [],
569
+ updated: [],
570
+ };
411
571
  const copyOpts: CopyOptions = {
412
572
  aliasFrom: "@/lib/auth/core",
413
573
  aliasTo: `${plan.alias}/core`,
@@ -421,33 +581,90 @@ async function runMergeCopy(
421
581
  collectDiffs: opts.collectDiffs,
422
582
  };
423
583
 
424
- await copyDir(join(REGISTRY_ROOT, registry.core.dir), join(plan.destRoot, "core"), copyOpts, plan.destRoot, result);
425
- await copyDir(plan.sharedDir, plan.destRoot, { ...copyOpts, neverCopy: plan.skipFromShared }, plan.destRoot, result);
426
- await copyDir(plan.variantDir, plan.destRoot, { ...copyOpts, neverCopy: plan.skipFromVariant }, plan.destRoot, result);
584
+ await copyDir(
585
+ join(REGISTRY_ROOT, registry.core.dir),
586
+ join(plan.destRoot, "core"),
587
+ copyOpts,
588
+ plan.destRoot,
589
+ result,
590
+ );
591
+ await copyDir(
592
+ plan.sharedDir,
593
+ plan.destRoot,
594
+ { ...copyOpts, neverCopy: plan.skipFromShared },
595
+ plan.destRoot,
596
+ result,
597
+ );
598
+ await copyDir(
599
+ plan.variantDir,
600
+ plan.destRoot,
601
+ { ...copyOpts, neverCopy: plan.skipFromVariant },
602
+ plan.destRoot,
603
+ result,
604
+ );
427
605
 
428
606
  if (plan.orm) {
429
- await copyOneFile(join(plan.sharedDir, plan.orm.configFile), join(targetRoot, plan.orm.configFile), plan.destRoot, copyOpts, result);
430
- await copyDir(join(plan.variantDir, plan.orm.dataDir), join(targetRoot, plan.orm.dataDir), copyOpts, plan.destRoot, result);
607
+ await copyOneFile(
608
+ join(plan.sharedDir, plan.orm.configFile),
609
+ join(targetRoot, plan.orm.configFile),
610
+ plan.destRoot,
611
+ copyOpts,
612
+ result,
613
+ );
614
+ await copyDir(
615
+ join(plan.variantDir, plan.orm.dataDir),
616
+ join(targetRoot, plan.orm.dataDir),
617
+ copyOpts,
618
+ plan.destRoot,
619
+ result,
620
+ );
431
621
  }
432
622
 
433
623
  // Switching variants has to remove the old variant's files, or the project ends up with both wired in.
434
- const pruned = await pruneRemovedFiles(plan.destRoot, plan.previous, result, { force: opts.force, ignore: plan.config.ignore, dryRun: opts.dryRun });
624
+ const pruned = await pruneRemovedFiles(plan.destRoot, plan.previous, result, {
625
+ force: opts.force,
626
+ ignore: plan.config.ignore,
627
+ dryRun: opts.dryRun,
628
+ });
435
629
  return { result, pruned };
436
630
  }
437
631
 
438
- async function installMerge(comboName: string, combo: ComboEntry, variant: string, registry: Registry, targetRoot: string, flags: Record<string, string | true>) {
632
+ async function installMerge(
633
+ comboName: string,
634
+ combo: ComboEntry,
635
+ variant: string,
636
+ registry: Registry,
637
+ targetRoot: string,
638
+ flags: Record<string, string | true>,
639
+ ) {
439
640
  const lock = await loadLock(targetRoot);
440
641
  const previous = lock.files ?? {};
441
642
  const force = flags.force === true;
442
643
  const checkOnly = flags.check === true;
443
644
 
444
- const plan = await buildMergePlan(combo, variant, targetRoot, flags, previous);
645
+ const plan = await buildMergePlan(
646
+ combo,
647
+ variant,
648
+ targetRoot,
649
+ flags,
650
+ previous,
651
+ );
445
652
 
446
653
  if (checkOnly) {
447
- const { result, pruned } = await runMergeCopy(plan, registry, targetRoot, { force, forcePaths: new Set(), dryRun: true });
448
- console.log(`\nCheck only — nothing written. "${comboName}" (${variant} variant) in ${plan.installPath} (alias ${plan.alias}):`);
654
+ const { result, pruned } = await runMergeCopy(plan, registry, targetRoot, {
655
+ force,
656
+ forcePaths: new Set(),
657
+ dryRun: true,
658
+ });
659
+ console.log(
660
+ `\nCheck only — nothing written. "${comboName}" (${variant} variant) in ${plan.installPath} (alias ${plan.alias}):`,
661
+ );
449
662
  printInstallSummary(result, pruned);
450
- const silent = !result.updated.length && !result.skipped.length && !pruned.removed.length && !pruned.keptModified.length;
663
+ const silent =
664
+ !result.updated.length &&
665
+ !result.skipped.length &&
666
+ !pruned.removed.length &&
667
+ !pruned.keptModified.length;
451
668
  if (silent) console.log(`\nUp to date — nothing would change.`);
452
669
  return;
453
670
  }
@@ -455,19 +672,45 @@ async function installMerge(comboName: string, combo: ComboEntry, variant: strin
455
672
  // Interactive + no --force: find locally-modified conflicts first (a dry run, nothing written),
456
673
  // and let the user pick which — if any — to overwrite anyway, shadcn-"this file already exists"
457
674
  // style, instead of the non-interactive default of silently leaving all of them alone.
458
- const forcePaths = await resolveForcePaths(() => runMergeCopy(plan, registry, targetRoot, { force: false, forcePaths: new Set(), dryRun: true }), flags);
459
- const { result, pruned } = await runMergeCopy(plan, registry, targetRoot, { force, forcePaths, dryRun: false });
675
+ const forcePaths = await resolveForcePaths(
676
+ () =>
677
+ runMergeCopy(plan, registry, targetRoot, {
678
+ force: false,
679
+ forcePaths: new Set(),
680
+ dryRun: true,
681
+ }),
682
+ flags,
683
+ );
684
+ const { result, pruned } = await runMergeCopy(plan, registry, targetRoot, {
685
+ force,
686
+ forcePaths,
687
+ dryRun: false,
688
+ });
460
689
 
461
690
  await writeFile(
462
691
  join(targetRoot, "auth.lock.json"),
463
- JSON.stringify({ ...lock, combo: comboName, variant, installedAt: new Date().toISOString(), files: result.manifest }, null, 2) + "\n",
692
+ JSON.stringify(
693
+ {
694
+ ...lock,
695
+ combo: comboName,
696
+ variant,
697
+ installedAt: new Date().toISOString(),
698
+ files: result.manifest,
699
+ },
700
+ null,
701
+ 2,
702
+ ) + "\n",
464
703
  "utf8",
465
704
  );
466
705
 
467
- console.log(`\nInstalled "${comboName}" (${variant} variant) into ${plan.installPath} (alias ${plan.alias})`);
706
+ console.log(
707
+ `\nInstalled "${comboName}" (${variant} variant) into ${plan.installPath} (alias ${plan.alias})`,
708
+ );
468
709
  printInstallSummary(result, pruned);
469
710
 
470
- const peerDeps = [...new Set([...registry.core.peerDependencies, ...combo.peerDependencies])];
711
+ const peerDeps = [
712
+ ...new Set([...registry.core.peerDependencies, ...combo.peerDependencies]),
713
+ ];
471
714
  console.log(`\nPeer dependencies: ${peerDeps.join(" ")}`);
472
715
  if (result.updated.length) {
473
716
  await installDependencies(targetRoot, peerDeps, flags);
@@ -480,7 +723,13 @@ async function installMerge(comboName: string, combo: ComboEntry, variant: strin
480
723
  /** "scaffold" install — admin/mobile apps materialized as a whole standalone project directly
481
724
  * into targetRoot: no core layer, no alias rewrite, package.json is real content (copied and
482
725
  * name/description-templated), not the registry's own dev wiring. */
483
- async function installScaffold(comboName: string, combo: ComboEntry, variant: string, targetRoot: string, flags: Record<string, string | true>) {
726
+ async function installScaffold(
727
+ comboName: string,
728
+ combo: ComboEntry,
729
+ variant: string,
730
+ targetRoot: string,
731
+ flags: Record<string, string | true>,
732
+ ) {
484
733
  const lock = await loadLock(targetRoot);
485
734
  const previous = lock.files ?? {};
486
735
  const force = flags.force === true;
@@ -498,24 +747,65 @@ async function installScaffold(comboName: string, combo: ComboEntry, variant: st
498
747
 
499
748
  const comboDir = join(REGISTRY_ROOT, combo.dir);
500
749
 
501
- const runCopy = async (opts: { force: boolean; forcePaths: Set<string>; dryRun: boolean }) => {
502
- const result: CopyResult = { manifest: {}, skipped: [], ignored: [], updated: [] };
503
- const copyOpts = { previous, force: opts.force, forcePaths: opts.forcePaths, neverCopy: SCAFFOLD_NEVER_COPY, dryRun: opts.dryRun };
504
- await copyDir(join(comboDir, combo.sharedDir ?? "shared"), targetRoot, copyOpts, targetRoot, result);
505
- await copyDir(join(comboDir, combo.variantsDir ?? "variants", variant), targetRoot, copyOpts, targetRoot, result);
506
- const pruned = await pruneRemovedFiles(targetRoot, previous, result, { force: opts.force, dryRun: opts.dryRun });
750
+ const runCopy = async (opts: {
751
+ force: boolean;
752
+ forcePaths: Set<string>;
753
+ dryRun: boolean;
754
+ }) => {
755
+ const result: CopyResult = {
756
+ manifest: {},
757
+ skipped: [],
758
+ ignored: [],
759
+ updated: [],
760
+ };
761
+ const copyOpts = {
762
+ previous,
763
+ force: opts.force,
764
+ forcePaths: opts.forcePaths,
765
+ neverCopy: SCAFFOLD_NEVER_COPY,
766
+ dryRun: opts.dryRun,
767
+ };
768
+ await copyDir(
769
+ join(comboDir, combo.sharedDir ?? "shared"),
770
+ targetRoot,
771
+ copyOpts,
772
+ targetRoot,
773
+ result,
774
+ );
775
+ await copyDir(
776
+ join(comboDir, combo.variantsDir ?? "variants", variant),
777
+ targetRoot,
778
+ copyOpts,
779
+ targetRoot,
780
+ result,
781
+ );
782
+ const pruned = await pruneRemovedFiles(targetRoot, previous, result, {
783
+ force: opts.force,
784
+ dryRun: opts.dryRun,
785
+ });
507
786
  return { result, pruned };
508
787
  };
509
788
 
510
789
  // Same shadcn-style "this file already exists, overwrite?" prompt as installMerge — see
511
790
  // resolveForcePaths. A no-op dry run on a genuinely fresh install (nothing to conflict with).
512
- const forcePaths = await resolveForcePaths(() => runCopy({ force: false, forcePaths: new Set(), dryRun: true }), flags);
513
- const { result, pruned } = await runCopy({ force, forcePaths, dryRun: false });
791
+ const forcePaths = await resolveForcePaths(
792
+ () => runCopy({ force: false, forcePaths: new Set(), dryRun: true }),
793
+ flags,
794
+ );
795
+ const { result, pruned } = await runCopy({
796
+ force,
797
+ forcePaths,
798
+ dryRun: false,
799
+ });
514
800
 
515
801
  const appName = flagString(flags.name) ?? basename(targetRoot);
516
802
 
517
803
  if (combo.nativeIdentity) {
518
- const renamed = await renameNative(targetRoot, combo.nativeIdentity, appName);
804
+ const renamed = await renameNative(
805
+ targetRoot,
806
+ combo.nativeIdentity,
807
+ appName,
808
+ );
519
809
  result.manifest = reconcileManifest(result.manifest, renamed);
520
810
  }
521
811
 
@@ -533,11 +823,23 @@ async function installScaffold(comboName: string, combo: ComboEntry, variant: st
533
823
 
534
824
  await writeFile(
535
825
  join(targetRoot, "auth.lock.json"),
536
- JSON.stringify({ ...lock, combo: comboName, variant, installedAt: new Date().toISOString(), files: result.manifest }, null, 2) + "\n",
826
+ JSON.stringify(
827
+ {
828
+ ...lock,
829
+ combo: comboName,
830
+ variant,
831
+ installedAt: new Date().toISOString(),
832
+ files: result.manifest,
833
+ },
834
+ null,
835
+ 2,
836
+ ) + "\n",
537
837
  "utf8",
538
838
  );
539
839
 
540
- console.log(`\nGenerated "${appName}" (${comboName}, ${variant} variant) into ${targetRoot}`);
840
+ console.log(
841
+ `\nGenerated "${appName}" (${comboName}, ${variant} variant) into ${targetRoot}`,
842
+ );
541
843
  printInstallSummary(result, pruned);
542
844
  if (result.updated.length) {
543
845
  // Dependencies are already declared in package.json — no specific packages to name, just
@@ -549,7 +851,14 @@ async function installScaffold(comboName: string, combo: ComboEntry, variant: st
549
851
  printPostInstallNotes(combo, variant);
550
852
  }
551
853
 
552
- async function installCombo(comboName: string, combo: ComboEntry, variant: string, registry: Registry, targetRoot: string, flags: Record<string, string | true>) {
854
+ async function installCombo(
855
+ comboName: string,
856
+ combo: ComboEntry,
857
+ variant: string,
858
+ registry: Registry,
859
+ targetRoot: string,
860
+ flags: Record<string, string | true>,
861
+ ) {
553
862
  if (comboInstallMode(combo) === "scaffold") {
554
863
  await installScaffold(comboName, combo, variant, targetRoot, flags);
555
864
  } else {
@@ -557,11 +866,17 @@ async function installCombo(comboName: string, combo: ComboEntry, variant: strin
557
866
  }
558
867
  }
559
868
 
560
- async function cmdAdd(comboName: string, targetRoot: string, flags: Record<string, string | true>) {
869
+ async function cmdAdd(
870
+ comboName: string,
871
+ targetRoot: string,
872
+ flags: Record<string, string | true>,
873
+ ) {
561
874
  const registry = await loadRegistry();
562
875
  const combo = registry.combos[comboName];
563
876
  if (!combo) {
564
- console.error(`Unknown combo "${comboName}". Available: ${Object.keys(registry.combos).join(", ")}`);
877
+ console.error(
878
+ `Unknown combo "${comboName}". Available: ${Object.keys(registry.combos).join(", ")}`,
879
+ );
565
880
  process.exitCode = 1;
566
881
  return;
567
882
  }
@@ -575,15 +890,22 @@ async function cmdAdd(comboName: string, targetRoot: string, flags: Record<strin
575
890
  await installCombo(comboName, combo, variant, registry, targetRoot, flags);
576
891
  }
577
892
 
578
- function combosByKind(registry: Registry, kind: Kind): Array<[string, ComboEntry]> {
579
- return Object.entries(registry.combos).filter(([, combo]) => comboKind(combo) === kind);
893
+ function combosByKind(
894
+ registry: Registry,
895
+ kind: Kind,
896
+ ): Array<[string, ComboEntry]> {
897
+ return Object.entries(registry.combos).filter(
898
+ ([, combo]) => comboKind(combo) === kind,
899
+ );
580
900
  }
581
901
 
582
902
  const isTTY = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
583
903
 
584
904
  /** Wraps a single `prompts()` call so a cancel (Ctrl+C) surfaces as `null` instead of `undefined`,
585
905
  * which prompts itself doesn't distinguish from "field asked but left empty". */
586
- async function ask<T>(question: Parameters<typeof prompts>[0] & { name: string }): Promise<T | null> {
906
+ async function ask<T>(
907
+ question: Parameters<typeof prompts>[0] & { name: string },
908
+ ): Promise<T | null> {
587
909
  const res = await prompts(question as never);
588
910
  const value = (res as Record<string, unknown>)[question.name as string];
589
911
  return value === undefined ? null : (value as T);
@@ -595,7 +917,14 @@ async function ask<T>(question: Parameters<typeof prompts>[0] & { name: string }
595
917
  * choice applies to every kind picked this run, matching how `variants` is already a single
596
918
  * cross-cutting concept rather than per-combo.
597
919
  */
598
- async function resolvePlan(registry: Registry, flags: Record<string, string | true>): Promise<Array<{ comboName: string; combo: ComboEntry; variant: string }> | null> {
920
+ async function resolvePlan(
921
+ registry: Registry,
922
+ flags: Record<string, string | true>,
923
+ ): Promise<Array<{
924
+ comboName: string;
925
+ combo: ComboEntry;
926
+ variant: string;
927
+ }> | null> {
599
928
  const flagKinds = flagList(flags.kind) as Kind[] | undefined;
600
929
  const flagFrameworks = flagList(flags.framework);
601
930
  const interactive = isTTY();
@@ -608,7 +937,10 @@ async function resolvePlan(registry: Registry, flags: Record<string, string | tr
608
937
  type: "multiselect",
609
938
  name: "kinds",
610
939
  message: "What would you like to generate?",
611
- choices: (Object.keys(KIND_LABELS) as Kind[]).map((value) => ({ title: KIND_LABELS[value], value })),
940
+ choices: (Object.keys(KIND_LABELS) as Kind[]).map((value) => ({
941
+ title: KIND_LABELS[value],
942
+ value,
943
+ })),
612
944
  min: 1,
613
945
  instructions: false,
614
946
  });
@@ -618,7 +950,9 @@ async function resolvePlan(registry: Registry, flags: Record<string, string | tr
618
950
  }
619
951
  kinds = picked;
620
952
  } else {
621
- console.error("Non-interactive session: pass --kind api,admin,mobile (comma-separated), or a specific combo name (simple-auth-kit add <combo>).");
953
+ console.error(
954
+ "Non-interactive session: pass --kind api,admin,mobile (comma-separated), or a specific combo name (simple-auth-kit add <combo>).",
955
+ );
622
956
  return null;
623
957
  }
624
958
 
@@ -642,7 +976,9 @@ async function resolvePlan(registry: Registry, flags: Record<string, string | tr
642
976
 
643
977
  let comboName = flagFrameworks?.[i];
644
978
  if (comboName && !available.some(([name]) => name === comboName)) {
645
- const match = available.find(([name]) => name === comboName || name.endsWith(`-${comboName}`));
979
+ const match = available.find(
980
+ ([name]) => name === comboName || name.endsWith(`-${comboName}`),
981
+ );
646
982
  comboName = match?.[0];
647
983
  }
648
984
 
@@ -662,7 +998,9 @@ async function resolvePlan(registry: Registry, flags: Record<string, string | tr
662
998
  }
663
999
  comboName = picked;
664
1000
  } else {
665
- console.error(`Multiple "${kind}" combos available (${available.map(([name]) => name).join(", ")}) — pass --framework to disambiguate.`);
1001
+ console.error(
1002
+ `Multiple "${kind}" combos available (${available.map(([name]) => name).join(", ")}) — pass --framework to disambiguate.`,
1003
+ );
666
1004
  return null;
667
1005
  }
668
1006
  }
@@ -682,7 +1020,12 @@ async function resolvePlan(registry: Registry, flags: Record<string, string | tr
682
1020
  } else if (requestedVariantFlag === "base") {
683
1021
  wantsWorkspaces = false;
684
1022
  } else if (interactive) {
685
- const picked = await ask<boolean>({ type: "confirm", name: "workspaces", message: "Include workspaces support?", initial: false });
1023
+ const picked = await ask<boolean>({
1024
+ type: "confirm",
1025
+ name: "workspaces",
1026
+ message: "Include workspaces support?",
1027
+ initial: false,
1028
+ });
686
1029
  if (picked === null) {
687
1030
  console.log("Cancelled.");
688
1031
  return null;
@@ -692,16 +1035,27 @@ async function resolvePlan(registry: Registry, flags: Record<string, string | tr
692
1035
  wantsWorkspaces = false;
693
1036
  }
694
1037
 
695
- const selections: Array<{ comboName: string; combo: ComboEntry; variant: string }> = [];
1038
+ const selections: Array<{
1039
+ comboName: string;
1040
+ combo: ComboEntry;
1041
+ variant: string;
1042
+ }> = [];
696
1043
  for (const pick of picks) {
697
- const variant = resolveVariant(pick.combo, pick.comboName, wantsWorkspaces ? "workspaces" : DEFAULT_VARIANT);
1044
+ const variant = resolveVariant(
1045
+ pick.combo,
1046
+ pick.comboName,
1047
+ wantsWorkspaces ? "workspaces" : DEFAULT_VARIANT,
1048
+ );
698
1049
  if (!variant) return null;
699
1050
  selections.push({ ...pick, variant });
700
1051
  }
701
1052
  return selections;
702
1053
  }
703
1054
 
704
- async function cmdCreate(targetRoot: string, flags: Record<string, string | true>) {
1055
+ async function cmdCreate(
1056
+ targetRoot: string,
1057
+ flags: Record<string, string | true>,
1058
+ ) {
705
1059
  const registry = await loadRegistry();
706
1060
  const selections = await resolvePlan(registry, flags);
707
1061
  if (!selections) {
@@ -723,8 +1077,18 @@ async function cmdCreate(targetRoot: string, flags: Record<string, string | true
723
1077
  // (they don't have their own package.json identity), but two scaffold apps both literally
724
1078
  // named e.g. "combo-test" would collide if anything ever treats them as sibling packages
725
1079
  // (a pnpm/npm workspace, for one). Suffix with the combo name once there's more than one.
726
- const installFlags = namespaced && flagString(flags.name) ? { ...flags, name: `${flagString(flags.name)}-${comboName}` } : flags;
727
- await installCombo(comboName, combo, variant, registry, installRoot, installFlags);
1080
+ const installFlags =
1081
+ namespaced && flagString(flags.name)
1082
+ ? { ...flags, name: `${flagString(flags.name)}-${comboName}` }
1083
+ : flags;
1084
+ await installCombo(
1085
+ comboName,
1086
+ combo,
1087
+ variant,
1088
+ registry,
1089
+ installRoot,
1090
+ installFlags,
1091
+ );
728
1092
  }
729
1093
  }
730
1094
 
@@ -735,10 +1099,15 @@ async function cmdCreate(targetRoot: string, flags: Record<string, string | true
735
1099
  * anything you have is left alone and reported, exactly like a first install. Pass `--force`
736
1100
  * yourself if you really do want to overwrite local edits.
737
1101
  */
738
- async function cmdUpdate(targetRoot: string, flags: Record<string, string | true>) {
1102
+ async function cmdUpdate(
1103
+ targetRoot: string,
1104
+ flags: Record<string, string | true>,
1105
+ ) {
739
1106
  const lock = await loadLock(targetRoot);
740
1107
  if (!lock.combo || !lock.variant) {
741
- console.error(`No auth.lock.json (or it's missing combo/variant) in ${targetRoot} — nothing to update. Use "add <combo>" for a first install.`);
1108
+ console.error(
1109
+ `No auth.lock.json (or it's missing combo/variant) in ${targetRoot} — nothing to update. Use "add <combo>" for a first install.`,
1110
+ );
742
1111
  process.exitCode = 1;
743
1112
  return;
744
1113
  }
@@ -746,7 +1115,9 @@ async function cmdUpdate(targetRoot: string, flags: Record<string, string | true
746
1115
  const registry = await loadRegistry();
747
1116
  const combo = registry.combos[lock.combo];
748
1117
  if (!combo) {
749
- console.error(`auth.lock.json names combo "${lock.combo}", which no longer exists in this registry.`);
1118
+ console.error(
1119
+ `auth.lock.json names combo "${lock.combo}", which no longer exists in this registry.`,
1120
+ );
750
1121
  process.exitCode = 1;
751
1122
  return;
752
1123
  }
@@ -758,12 +1129,16 @@ async function cmdUpdate(targetRoot: string, flags: Record<string, string | true
758
1129
  }
759
1130
 
760
1131
  if (flags.check === true && comboInstallMode(combo) === "scaffold") {
761
- console.error(`--check isn't supported for "${lock.combo}" (a scaffold-mode combo) — only merge-mode (api) combos support a dry run.`);
1132
+ console.error(
1133
+ `--check isn't supported for "${lock.combo}" (a scaffold-mode combo) — only merge-mode (api) combos support a dry run.`,
1134
+ );
762
1135
  process.exitCode = 1;
763
1136
  return;
764
1137
  }
765
1138
 
766
- console.log(`Updating "${lock.combo}" (${variant} variant) in ${targetRoot}${flags.force === true ? " — --force: local edits will be overwritten" : ""}`);
1139
+ console.log(
1140
+ `Updating "${lock.combo}" (${variant} variant) in ${targetRoot}${flags.force === true ? " — --force: local edits will be overwritten" : ""}`,
1141
+ );
767
1142
  await installCombo(lock.combo, combo, variant, registry, targetRoot, flags);
768
1143
  }
769
1144
 
@@ -776,7 +1151,9 @@ async function cmdUpdate(targetRoot: string, flags: Record<string, string | true
776
1151
  async function cmdDiff(targetRoot: string) {
777
1152
  const lock = await loadLock(targetRoot);
778
1153
  if (!lock.combo || !lock.variant) {
779
- console.error(`No auth.lock.json (or it's missing combo/variant) in ${targetRoot} — nothing installed to diff.`);
1154
+ console.error(
1155
+ `No auth.lock.json (or it's missing combo/variant) in ${targetRoot} — nothing installed to diff.`,
1156
+ );
780
1157
  process.exitCode = 1;
781
1158
  return;
782
1159
  }
@@ -784,13 +1161,17 @@ async function cmdDiff(targetRoot: string) {
784
1161
  const registry = await loadRegistry();
785
1162
  const combo = registry.combos[lock.combo];
786
1163
  if (!combo) {
787
- console.error(`auth.lock.json names combo "${lock.combo}", which no longer exists in this registry.`);
1164
+ console.error(
1165
+ `auth.lock.json names combo "${lock.combo}", which no longer exists in this registry.`,
1166
+ );
788
1167
  process.exitCode = 1;
789
1168
  return;
790
1169
  }
791
1170
 
792
1171
  if (comboInstallMode(combo) === "scaffold") {
793
- console.error(`diff isn't supported yet for "${lock.combo}" (a scaffold-mode combo) — only merge-mode (api) combos support it.`);
1172
+ console.error(
1173
+ `diff isn't supported yet for "${lock.combo}" (a scaffold-mode combo) — only merge-mode (api) combos support it.`,
1174
+ );
794
1175
  process.exitCode = 1;
795
1176
  return;
796
1177
  }
@@ -801,21 +1182,41 @@ async function cmdDiff(targetRoot: string) {
801
1182
  return;
802
1183
  }
803
1184
 
804
- const plan = await buildMergePlan(combo, variant, targetRoot, {}, lock.files ?? {});
1185
+ const plan = await buildMergePlan(
1186
+ combo,
1187
+ variant,
1188
+ targetRoot,
1189
+ {},
1190
+ lock.files ?? {},
1191
+ );
805
1192
  const diffs: DiffEntry[] = [];
806
1193
  // force:true so a locally-modified file's difference is captured too — diff wants to show
807
1194
  // everything that differs, not just what a real (non---force) install would apply; dryRun:true
808
1195
  // so nothing is written.
809
- await runMergeCopy(plan, registry, targetRoot, { force: true, forcePaths: new Set(), dryRun: true, collectDiffs: diffs });
1196
+ await runMergeCopy(plan, registry, targetRoot, {
1197
+ force: true,
1198
+ forcePaths: new Set(),
1199
+ dryRun: true,
1200
+ collectDiffs: diffs,
1201
+ });
810
1202
 
811
1203
  if (!diffs.length) {
812
- console.log(`No differences — every tracked file in "${lock.combo}" (${variant} variant) matches the current registry.`);
1204
+ console.log(
1205
+ `No differences — every tracked file in "${lock.combo}" (${variant} variant) matches the current registry.`,
1206
+ );
813
1207
  return;
814
1208
  }
815
1209
 
816
1210
  console.log(`${diffs.length} file(s) differ from the current registry:`);
817
1211
  for (const d of diffs) {
818
- const patch = createTwoFilesPatch(d.path, d.path, d.oldContent ?? "", d.newContent, "installed", "registry");
1212
+ const patch = createTwoFilesPatch(
1213
+ d.path,
1214
+ d.path,
1215
+ d.oldContent ?? "",
1216
+ d.newContent,
1217
+ "installed",
1218
+ "registry",
1219
+ );
819
1220
  process.stdout.write(`\n${patch}`);
820
1221
  }
821
1222
  }
@@ -823,25 +1224,50 @@ async function cmdDiff(targetRoot: string) {
823
1224
  async function printUsage(exitCode: number) {
824
1225
  const registry = await loadRegistry();
825
1226
  console.log("Usage: simple-auth-kit <init|add|update|diff> [...]");
826
- console.log(` init [--config-only] [--kind ...] [--into <path>] (fresh setup: guided "what do you need" picker, like bare "add")`);
827
- console.log(` --config-only: just write .simple-auth-kit.json and stop, no install`);
828
- console.log(` add <combo> [--workspaces] [--force] [--skip-install] [--into <path>] [--path <dir>] [--alias <alias>]`);
829
- console.log(` add [--kind api,admin,mobile] [--framework <name>,...] [--workspaces] [--into <path>] [--name <appName>]`);
830
- console.log(` (bare "add", or "add" with --kind but no combo, launches a guided prompt for whatever's missing)`);
831
- console.log(` update [--check] [--force] [--skip-install] [--into <path>] (re-installs whatever combo+variant auth.lock.json already records)`);
832
- console.log(` --check: report what would change, without writing anything (merge-mode combos only)`);
833
- 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)`);
834
- console.log(` In a TTY, without --force or --check: a file changed locally since install prompts to overwrite, per file.`);
835
- console.log(` --skip-install: don't run the package manager after copying files — the default is to install for you, shadcn-\`add\`-style.`);
836
- 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.`);
1227
+ console.log(
1228
+ ` init [--config-only] [--kind ...] [--into <path>] (fresh setup: guided "what do you need" picker, like bare "add")`,
1229
+ );
1230
+ console.log(
1231
+ ` --config-only: just write .simple-auth-kit.json and stop, no install`,
1232
+ );
1233
+ console.log(
1234
+ ` add <combo> [--workspaces] [--force] [--skip-install] [--into <path>] [--path <dir>] [--alias <alias>]`,
1235
+ );
1236
+ console.log(
1237
+ ` add [--kind api,admin,mobile] [--framework <name>,...] [--workspaces] [--into <path>] [--name <appName>]`,
1238
+ );
1239
+ console.log(
1240
+ ` (bare "add", or "add" with --kind but no combo, launches a guided prompt for whatever's missing)`,
1241
+ );
1242
+ console.log(
1243
+ ` update [--check] [--force] [--skip-install] [--into <path>] (re-installs whatever combo+variant auth.lock.json already records)`,
1244
+ );
1245
+ console.log(
1246
+ ` --check: report what would change, without writing anything (merge-mode combos only)`,
1247
+ );
1248
+ console.log(
1249
+ ` diff [--into <path>] (shows the actual content diff for every tracked file that differs from the current registry — read-only; merge-mode combos only)`,
1250
+ );
1251
+ console.log(
1252
+ ` In a TTY, without --force or --check: a file changed locally since install prompts to overwrite, per file.`,
1253
+ );
1254
+ console.log(
1255
+ ` --skip-install: don't run the package manager after copying files — the default is to install for you, shadcn-\`add\`-style.`,
1256
+ );
1257
+ console.log(
1258
+ ` --pm <npm|pnpm|yarn|bun>: which package manager to install with — default: detected from a lockfile in the target directory, npm if none found.`,
1259
+ );
837
1260
  console.log(`\nAvailable combos:`);
838
1261
  for (const kind of ["api", "admin", "mobile"] as Kind[]) {
839
1262
  const names = combosByKind(registry, kind).map(([name]) => name);
840
- if (names.length) console.log(` ${KIND_LABELS[kind]}: ${names.join(", ")}`);
1263
+ if (names.length)
1264
+ console.log(` ${KIND_LABELS[kind]}: ${names.join(", ")}`);
841
1265
  }
842
1266
  console.log(`\nVariants (choose one at install time):`);
843
1267
  for (const [name, variant] of Object.entries(registry.variants)) {
844
- console.log(` ${name}${variant.flag ? ` (${variant.flag})` : " (default)"} — ${variant.description}`);
1268
+ console.log(
1269
+ ` ${name}${variant.flag ? ` (${variant.flag})` : " (default)"} — ${variant.description}`,
1270
+ );
845
1271
  }
846
1272
  process.exitCode = exitCode;
847
1273
  }
@@ -850,7 +1276,12 @@ async function main() {
850
1276
  const { command, positional, flags } = parseArgs(process.argv.slice(2));
851
1277
  const targetRoot = resolve(process.cwd(), flagString(flags.into) ?? ".");
852
1278
 
853
- if (command === "--help" || command === "-h" || command === "help" || flags.help === true) {
1279
+ if (
1280
+ command === "--help" ||
1281
+ command === "-h" ||
1282
+ command === "help" ||
1283
+ flags.help === true
1284
+ ) {
854
1285
  await printUsage(0);
855
1286
  return;
856
1287
  }